mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-10-31 12:42:09 +00:00 
			
		
		
		
	Merge pull request #673 from omarroth/add-playlists
Add initial support for custom playlists
This commit is contained in:
		| @@ -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); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
|                     }); | ||||
|                 } | ||||
|   | ||||
							
								
								
									
										47
									
								
								assets/js/playlist_widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								assets/js/playlist_widget.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
|                     }); | ||||
|                 } | ||||
|   | ||||
							
								
								
									
										19
									
								
								config/sql/playlist_videos.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								config/sql/playlist_videos.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										18
									
								
								config/sql/playlists.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								config/sql/playlists.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										10
									
								
								config/sql/privacy.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								config/sql/privacy.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| -- Type: public.privacy | ||||
|  | ||||
| -- DROP TYPE public.privacy; | ||||
|  | ||||
| CREATE TYPE public.privacy AS ENUM | ||||
| ( | ||||
|     'Public', | ||||
|     'Unlisted', | ||||
|     'Private' | ||||
| ); | ||||
| @@ -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: ": "الإصدار الحالي: " | ||||
| } | ||||
| } | ||||
| @@ -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: " | ||||
| } | ||||
| } | ||||
| @@ -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": "Προβολή σημειώσεων", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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: " | ||||
| } | ||||
| } | ||||
| @@ -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: " | ||||
| } | ||||
| } | ||||
| @@ -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": "", | ||||
|   | ||||
| @@ -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 : " | ||||
| } | ||||
| } | ||||
| @@ -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: " | ||||
| } | ||||
| } | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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: " | ||||
| } | ||||
| } | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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": "", | ||||
|   | ||||
| @@ -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": "Показать аннотации", | ||||
|   | ||||
| @@ -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": "Показати анотації", | ||||
|   | ||||
| @@ -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": "显示注释", | ||||
|   | ||||
							
								
								
									
										944
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										944
									
								
								src/invidious.cr
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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", "<br>") | ||||
|   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 | ||||
|   <h3> | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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': "(?<session_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 | ||||
|   | ||||
| @@ -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 } | ||||
|   | ||||
							
								
								
									
										56
									
								
								src/invidious/views/add_playlist_items.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/invidious/views/add_playlist_items.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= playlist.title %> - Invidious</title> | ||||
| <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> | ||||
| <% end %> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5"></div> | ||||
|     <div class="pure-u-1 pure-u-lg-3-5"> | ||||
|         <div class="h-box"> | ||||
|             <form class="pure-form pure-form-aligned" action="/add_playlist_items" method="get"> | ||||
|                 <legend><a href="/playlist?list=<%= playlist.id %>"><%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend> | ||||
|  | ||||
|                 <fieldset> | ||||
|                     <input class="pure-input-1" type="search" name="q" <% if query %>value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>> | ||||
|                     <input type="hidden" name="list" value="<%= plid %>"> | ||||
|                 </fieldset> | ||||
|             </form> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5"></div> | ||||
| </div> | ||||
|  | ||||
| <script> | ||||
| var playlist_data = { | ||||
|     csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', | ||||
| } | ||||
| </script> | ||||
| <script src="/js/playlist_widget.js"></script> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <% videos.each_slice(4) do |slice| %> | ||||
|             <% slice.each do |item| %> | ||||
|                 <%= rendered "components/item" %> | ||||
|             <% end %> | ||||
|     <% end %> | ||||
| </div> | ||||
|  | ||||
| <% if query %> | ||||
|     <div class="pure-g h-box"> | ||||
|         <div class="pure-u-1 pure-u-lg-1-5"> | ||||
|             <% if page > 1 %> | ||||
|                 <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> | ||||
|                     <%= translate(locale, "Previous page") %> | ||||
|                 </a> | ||||
|             <% end %> | ||||
|         </div> | ||||
|         <div class="pure-u-1 pure-u-lg-3-5"></div> | ||||
|         <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> | ||||
|             <% if count >= 20 %> | ||||
|                 <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> | ||||
|                     <%= translate(locale, "Next page") %> | ||||
|                 </a> | ||||
|             <% end %> | ||||
|         </div> | ||||
|     </div> | ||||
| <% end %> | ||||
| @@ -13,7 +13,7 @@ | ||||
|             <p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p> | ||||
|             <% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %> | ||||
|             <h5><%= item.description_html %></h5> | ||||
|         <% 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 %> | ||||
|                     <div class="thumbnail"> | ||||
|                         <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> | ||||
|                         <% if plid = env.get?("remove_playlist_items") %> | ||||
|                             <form onsubmit="return false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                                 <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                                 <p class="watched"> | ||||
|                                     <a onclick="remove_playlist_item(this)" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)"> | ||||
|                                         <button type="submit" style="all:unset"> | ||||
|                                             <i class="icon ion-md-trash"></i> | ||||
|                                         </button> | ||||
|                                     </a> | ||||
|                                 </p> | ||||
|                             </form> | ||||
|                         <% end %> | ||||
|  | ||||
|                         <% if item.responds_to?(:live_now) && item.live_now %> | ||||
|                             <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> | ||||
|                         <% elsif item.length_seconds != 0 %> | ||||
| @@ -63,7 +76,7 @@ | ||||
|                         <% end %> | ||||
|                     </div> | ||||
|                 <% end %> | ||||
|                 <p><%= item.title %></p> | ||||
|                 <p><a href="/watch?v=<%= item.id %>"><%= item.title %></a></p> | ||||
|             </a> | ||||
|             <p> | ||||
|                 <b> | ||||
| @@ -103,6 +116,17 @@ | ||||
|                                     </a> | ||||
|                                 </p> | ||||
|                             </form> | ||||
|                         <% elsif plid = env.get? "add_playlist_items" %> | ||||
|                             <form onsubmit="return false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                                 <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                                 <p class="watched"> | ||||
|                                     <a onclick="add_playlist_item(this)" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)"> | ||||
|                                         <button type="submit" style="all:unset"> | ||||
|                                             <i class="icon ion-md-add"></i> | ||||
|                                         </button> | ||||
|                                     </a> | ||||
|                                 </p> | ||||
|                             </form> | ||||
|                         <% end %> | ||||
|  | ||||
|                         <% if item.responds_to?(:live_now) && item.live_now %> | ||||
|   | ||||
							
								
								
									
										39
									
								
								src/invidious/views/create_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/invidious/views/create_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= translate(locale, "Create playlist") %> - Invidious</title> | ||||
| <% end %> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5"></div> | ||||
|     <div class="pure-u-1 pure-u-lg-3-5"> | ||||
|         <div class="h-box"> | ||||
|             <form class="pure-form pure-form-aligned" action="/create_playlist?referer=<%= URI.encode_www_form(referer) %>" method="post"> | ||||
|                 <fieldset> | ||||
|                     <legend><%= translate(locale, "Create playlist") %></legend> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         <label for="title"><%= translate(locale, "Title") %> :</label> | ||||
|                         <input required name="title" type="text" placeholder="<%= translate(locale, "Title") %>"> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         <label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label> | ||||
|                         <select name="privacy" id="privacy"> | ||||
|                             <% PlaylistPrivacy.names.each do |option| %> | ||||
|                                 <option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= translate(locale,  option) %></option> | ||||
|                             <% end %> | ||||
|                         </select> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-controls"> | ||||
|                         <button type="submit" name="action" value="create_playlist" class="pure-button pure-button-primary"> | ||||
|                             <%= translate(locale, "Create playlist") %> | ||||
|                         </button> | ||||
|                     </div> | ||||
|  | ||||
|                     <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> | ||||
|                 </fieldset> | ||||
|             </form> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5"></div> | ||||
| </div> | ||||
							
								
								
									
										24
									
								
								src/invidious/views/delete_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/invidious/views/delete_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= translate(locale, "Delete playlist") %> - Invidious</title> | ||||
| <% end %> | ||||
|  | ||||
| <div class="h-box"> | ||||
|     <form class="pure-form pure-form-aligned" action="/delete_playlist?list=<%= plid %>&referer=<%= URI.encode_www_form(referer) %>" method="post"> | ||||
|         <legend><%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %></legend> | ||||
|  | ||||
|         <div class="pure-g"> | ||||
|             <div class="pure-u-1-2"> | ||||
|                 <button type="submit" name="submit" value="delete_playlist" class="pure-button pure-button-primary"> | ||||
|                     <%= translate(locale, "Yes") %> | ||||
|                 </button> | ||||
|             </div> | ||||
|             <div class="pure-u-1-2"> | ||||
|                 <a class="pure-button" href="/playlist?list=<%= plid %>"> | ||||
|                     <%= translate(locale, "No") %> | ||||
|                 </a> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> | ||||
|     </form> | ||||
| </div> | ||||
							
								
								
									
										81
									
								
								src/invidious/views/edit_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/invidious/views/edit_playlist.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= playlist.title %> - Invidious</title> | ||||
| <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> | ||||
| <% end %> | ||||
|  | ||||
| <form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post"> | ||||
|     <div class="pure-g h-box"> | ||||
|         <div class="pure-u-2-3"> | ||||
|             <h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= playlist.title %>"></h3> | ||||
|             <b> | ||||
|                 <%= playlist.author %> | | ||||
|                 <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | | ||||
|                 <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | | ||||
|                 <i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i> | ||||
|                 <select name="privacy"> | ||||
|                 <% {"Public", "Unlisted", "Private"}.each do |option| %> | ||||
|                     <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option> | ||||
|                 <% end %> | ||||
|                 </select> | ||||
|             </b> | ||||
|         </div> | ||||
|         <div class="pure-u-1-3" style="text-align:right"> | ||||
|             <h3> | ||||
|                 <div class="pure-g user-field"> | ||||
|                     <div class="pure-u-1-3"> | ||||
|                         <a href="javascript:void(0)"> | ||||
|                             <button type="submit" style="all:unset"> | ||||
|                                 <i class="icon ion-md-save"></i> | ||||
|                             </button> | ||||
|                         </a> | ||||
|                     </div> | ||||
|                     <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> | ||||
|                     <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div> | ||||
|                 </div> | ||||
|             </h3> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="h-box"> | ||||
|         <textarea maxlength="5000" name="description" style="margin-top:10px;max-width:100%;height:20vh" class="pure-input-1"><%= playlist.description %></textarea> | ||||
|     </div> | ||||
|     <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> | ||||
| </form> | ||||
|  | ||||
| <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> | ||||
| <div class="h-box" style="text-align:right"> | ||||
|     <h3> | ||||
|         <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a> | ||||
|     </h3> | ||||
| </div> | ||||
| <% end %> | ||||
|  | ||||
| <div class="h-box"> | ||||
|     <hr> | ||||
| </div> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <% videos.each_slice(4) do |slice| %> | ||||
|         <% slice.each do |item| %> | ||||
|             <%= rendered "components/item" %> | ||||
|         <% end %> | ||||
|     <% end %> | ||||
| </div> | ||||
|  | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5"> | ||||
|         <% if page > 1 %> | ||||
|             <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>"> | ||||
|                 <%= translate(locale, "Previous page") %> | ||||
|             </a> | ||||
|         <% end %> | ||||
|     </div> | ||||
|     <div class="pure-u-1 pure-u-lg-3-5"></div> | ||||
|     <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> | ||||
|         <% if videos.size == 100 %> | ||||
|             <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>"> | ||||
|                 <%= translate(locale, "Next page") %> | ||||
|             </a> | ||||
|         <% end %> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -29,6 +29,7 @@ | ||||
| <script> | ||||
| var video_data = { | ||||
|     id: '<%= video.id %>', | ||||
|     index: '<%= continuation %>', | ||||
|     plid: '<%= plid %>', | ||||
|     length_seconds: '<%= video.length_seconds.to_f %>', | ||||
|     video_series: <%= video_series.to_json %>, | ||||
|   | ||||
| @@ -6,36 +6,77 @@ | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-2-3"> | ||||
|         <h3><%= playlist.title %></h3> | ||||
|         <% if playlist.is_a? InvidiousPlaylist %> | ||||
|             <b> | ||||
|                 <% if playlist.author == user.try &.email %> | ||||
|                 <a href="/view_all_playlists"><%= playlist.author %></a> | | ||||
|                 <% else %> | ||||
|                 <%= playlist.author %> | | ||||
|                 <% end %> | ||||
|                 <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | | ||||
|                 <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | | ||||
|                 <% case playlist.as(InvidiousPlaylist).privacy when %> | ||||
|                 <% when PlaylistPrivacy::Public %> | ||||
|                     <i class="icon ion-md-globe"></i> <%= translate(locale, "Public") %> | ||||
|                 <% when PlaylistPrivacy::Unlisted %> | ||||
|                     <i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %> | ||||
|                 <% when PlaylistPrivacy::Private %> | ||||
|                     <i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %> | ||||
|                 <% end %> | ||||
|             </b> | ||||
|         <% else %> | ||||
|             <b> | ||||
|                 <a href="/channel/<%= playlist.ucid %>"><%= playlist.author %></a> | | ||||
|                 <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | | ||||
|                 <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | ||||
|             </b> | ||||
|         <% end %> | ||||
|         <% if !playlist.is_a? InvidiousPlaylist %> | ||||
|             <div class="pure-u-2-3"> | ||||
|                     <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>"> | ||||
|                         <%= translate(locale, "View playlist on YouTube") %> | ||||
|                     </a> | ||||
|             </div> | ||||
|         <% end %> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:right"> | ||||
|         <h3> | ||||
|             <a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a> | ||||
|             <div class="pure-g user-field"> | ||||
|                 <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> | ||||
|                     <div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div> | ||||
|                     <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> | ||||
|                 <% end %> | ||||
|                 <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div> | ||||
|             </div> | ||||
|         </h3> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-1-3"> | ||||
|         <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>"> | ||||
|             <%= translate(locale, "View playlist on YouTube") %> | ||||
|         </a> | ||||
|         <div class="pure-u-1 pure-md-1-3"> | ||||
|             <a href="/channel/<%= playlist.ucid %>"> | ||||
|                 <b><%= playlist.author %></b> | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="pure-u-1-2"></div> | ||||
| </div> | ||||
|  | ||||
| <div class="h-box"> | ||||
|     <p><%= playlist.description_html %></p> | ||||
| </div> | ||||
|  | ||||
| <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> | ||||
| <div class="h-box" style="text-align:right"> | ||||
|     <h3> | ||||
|         <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a> | ||||
|     </h3> | ||||
| </div> | ||||
| <% end %> | ||||
|  | ||||
| <div class="h-box"> | ||||
|     <hr> | ||||
| </div> | ||||
|  | ||||
| <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> | ||||
| <script> | ||||
| var playlist_data = { | ||||
|     csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', | ||||
| } | ||||
| </script> | ||||
| <script src="/js/playlist_widget.js"></script> | ||||
| <% end %> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <% videos.each_slice(4) do |slice| %> | ||||
|         <% slice.each do |item| %> | ||||
|   | ||||
| @@ -261,6 +261,10 @@ function update_value(element) { | ||||
|                     <a href="/token_manager"><%= translate(locale, "Manage tokens") %></a> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="/view_all_playlists"><%= translate(locale, "View all playlists") %></a> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="/feed/history"><%= translate(locale, "Watch history") %></a> | ||||
|                 </div> | ||||
|   | ||||
							
								
								
									
										22
									
								
								src/invidious/views/view_all_playlists.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/invidious/views/view_all_playlists.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= translate(locale, "Playlists") %> - Invidious</title> | ||||
| <% end %> | ||||
|  | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-2-3"> | ||||
|         <h3><%= translate(locale, "`x` playlists", %(<span id="count">#{items.size}</span>)) %></h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:right"> | ||||
|         <h3> | ||||
|             <a href="/create_playlist?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Create playlist") %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="pure-g"> | ||||
|     <% items.each_slice(4) do |slice| %> | ||||
|         <% slice.each do |item| %> | ||||
|             <%= rendered "components/item" %> | ||||
|         <% end %> | ||||
|     <% end %> | ||||
| </div> | ||||
| @@ -29,6 +29,7 @@ | ||||
| <script> | ||||
| var video_data = { | ||||
|     id: '<%= video.id %>', | ||||
|     index: '<%= continuation %>', | ||||
|     plid: '<%= plid %>', | ||||
|     length_seconds: <%= video.length_seconds.to_f %>, | ||||
|     play_next: <%= !rvs.empty? && !plid && params.continue %>, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Omar Roth
					Omar Roth