Add support for custom playlists
This commit is contained in:
parent
1e34a61911
commit
be055d9dcb
40 changed files with 1802 additions and 245 deletions
|
@ -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": "عرض الملاحظات فى الفيديو",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "显示注释",
|
||||
|
|
882
src/invidious.cr
882
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
|
||||
|
||||
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
|
||||
index ||= 0
|
||||
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
|
||||
index = (page - 1) * 100
|
||||
end
|
||||
|
||||
if video_count > 100
|
||||
url = produce_playlist_url(plid, index)
|
||||
|
||||
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, 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
|
||||
end
|
||||
JSON.build do |json|
|
||||
to_json(offset, locale, config, kemal_config, json, continuation: continuation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return videos
|
||||
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>
|
||||
</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>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1-3">
|
||||
<% 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 class="pure-u-1 pure-md-1-3">
|
||||
<a href="/channel/<%= playlist.ucid %>">
|
||||
<b><%= playlist.author %></b>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="pure-u-1-3" style="text-align:right">
|
||||
<h3>
|
||||
<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 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 %>,
|
||||
|
|
Loading…
Reference in a new issue