Merge branch 'master' into homepage

This commit is contained in:
girst 2019-10-21 09:47:26 +02:00
commit 8c0222a27a
59 changed files with 2650 additions and 610 deletions

View File

@ -3,18 +3,24 @@ dist: bionic
jobs: jobs:
include: include:
- stage: build - stage: build
# TODO: Shallowly clone again once the .git folder is no longer required for building
git:
depth: false
language: crystal language: crystal
crystal: latest crystal: latest
before_install: before_install:
- shards update - shards update
- shards install - shards install
install: install:
- crystal build --error-on-warnings src/invidious.cr - crystal build --warnings all --error-on-warnings src/invidious.cr
script: script:
- crystal tool format --check - crystal tool format --check
- crystal spec - crystal spec
- stage: build_docker - stage: build_docker
# TODO: Shallowly clone again once the .git folder is no longer required for building
git:
depth: false
language: minimal language: minimal
services: services:
- docker - docker

View File

@ -25,7 +25,6 @@
- Developer [API](https://github.com/omarroth/invidious/wiki/API) - Developer [API](https://github.com/omarroth/invidious/wiki/API)
Liberapay: https://liberapay.com/omarroth Liberapay: https://liberapay.com/omarroth
Patreon: https://patreon.com/omarroth
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk

View File

@ -21,10 +21,9 @@ body {
color: #f0f0f0; color: #f0f0f0;
} }
.pure-form > fieldset > input, input,
.pure-control-group > input, select,
.pure-form > fieldset > select, textarea {
.pure-control-group > select {
color: rgba(35, 35, 35, 1); color: rgba(35, 35, 35, 1);
} }

View File

@ -168,6 +168,7 @@ img.thumbnail {
.navbar .index-link { .navbar .index-link {
font-weight: bold; font-weight: bold;
display: inline;
} }
.navbar > .searchbar .pure-form input[type="search"] { .navbar > .searchbar .pure-form input[type="search"] {
@ -319,6 +320,10 @@ input[type="search"]::-webkit-search-cancel-button {
} }
} }
ul.vjs-menu-content::-webkit-scrollbar {
display: none;
}
.vjs-user-inactive { .vjs-user-inactive {
cursor: none; cursor: none;
} }
@ -367,6 +372,11 @@ input[type="search"]::-webkit-search-cancel-button {
.vjs-control-bar { .vjs-control-bar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
scrollbar-width: none;
}
.vjs-control-bar::-webkit-scrollbar {
display: none;
} }
.video-js .vjs-icon-cog { .video-js .vjs-icon-cog {
@ -423,6 +433,7 @@ span > select {
/* ProgressBar marker */ /* ProgressBar marker */
.vjs-marker { .vjs-marker {
background-color: rgba(255, 255, 255, 1); background-color: rgba(255, 255, 255, 1);
z-index: 0;
} }
/* Big "Play" Button */ /* Big "Play" Button */

View File

@ -12,7 +12,8 @@ function get_playlist(plid, retries) {
'&format=html&hl=' + video_data.preferences.locale; '&format=html&hl=' + video_data.preferences.locale;
} else { } else {
var plid_url = '/api/v1/playlists/' + plid + 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; '&format=html&hl=' + video_data.preferences.locale;
} }
@ -45,6 +46,9 @@ function get_playlist(plid, retries) {
} }
url.searchParams.set('list', plid); url.searchParams.set('list', plid);
if (!plid.startsWith('RD')) {
url.searchParams.set('index', xhr.response.index);
}
location.assign(url.pathname + url.search); location.assign(url.pathname + url.search);
}); });
} }
@ -65,6 +69,7 @@ function get_playlist(plid, retries) {
xhr.send(); xhr.send();
} }
window.addEventListener('load', function (e) {
if (video_data.plid) { if (video_data.plid) {
get_playlist(video_data.plid); get_playlist(video_data.plid);
} else if (video_data.video_series) { } else if (video_data.video_series) {
@ -94,3 +99,4 @@ if (video_data.plid) {
location.assign(url.pathname + url.search); location.assign(url.pathname + url.search);
}); });
} }
});

View File

@ -151,6 +151,7 @@ player.vttThumbnails({
// Enable annotations // Enable annotations
if (!video_data.params.listen && video_data.params.annotations) { if (!video_data.params.listen && video_data.params.annotations) {
window.addEventListener('load', function (e) {
var video_container = document.getElementById('player'); var video_container = document.getElementById('player');
let xhr = new XMLHttpRequest(); let xhr = new XMLHttpRequest();
xhr.responseType = 'text'; xhr.responseType = 'text';
@ -190,6 +191,7 @@ if (!video_data.params.listen && video_data.params.annotations) {
}); });
xhr.send(); xhr.send();
});
} }
function increase_volume(delta) { function increase_volume(delta) {

View 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);
}

File diff suppressed because one or more lines are too long

View File

@ -133,7 +133,8 @@ function get_playlist(plid, retries) {
'&format=html&hl=' + video_data.preferences.locale; '&format=html&hl=' + video_data.preferences.locale;
} else { } else {
var plid_url = '/api/v1/playlists/' + plid + 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; '&format=html&hl=' + video_data.preferences.locale;
} }
@ -168,6 +169,9 @@ function get_playlist(plid, retries) {
} }
url.searchParams.set('list', plid); url.searchParams.set('list', plid);
if (!plid.startsWith('RD')) {
url.searchParams.set('index', xhr.response.index);
}
location.assign(url.pathname + url.search); location.assign(url.pathname + url.search);
}); });
} }
@ -435,6 +439,7 @@ if (video_data.play_next) {
}); });
} }
window.addEventListener('load', function (e) {
if (video_data.plid) { if (video_data.plid) {
get_playlist(video_data.plid); get_playlist(video_data.plid);
} }
@ -451,3 +456,4 @@ if (video_data.params.comments[0] === 'youtube') {
comments = document.getElementById('comments'); comments = document.getElementById('comments');
comments.innerHTML = ''; comments.innerHTML = '';
} }
});

View 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
View 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
View File

@ -0,0 +1,10 @@
-- Type: public.privacy
-- DROP TYPE public.privacy;
CREATE TYPE public.privacy AS ENUM
(
'Public',
'Unlisted',
'Private'
);

View File

@ -9,7 +9,7 @@ COPY ./src/ ./src/
# TODO: .git folder is required for building this is destructive. # TODO: .git folder is required for building this is destructive.
# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
COPY ./.git/ ./.git/ COPY ./.git/ ./.git/
RUN crystal build --static --release \ RUN crystal build --static --release --warnings all --error-on-warnings \
# TODO: Remove next line, see https://github.com/crystal-lang/crystal/issues/7946 # TODO: Remove next line, see https://github.com/crystal-lang/crystal/issues/7946
-Dmusl \ -Dmusl \
./src/invidious.cr ./src/invidious.cr

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` المشتركين", "`x` subscribers": "`x` المشتركين",
"`x` videos": "`x` الفيديوهات", "`x` videos": "`x` الفيديوهات",
"`x` playlists": "`x` قوائم التشغيل",
"LIVE": "مباشر", "LIVE": "مباشر",
"Shared `x` ago": "تم رفع الفيديو منذ `x`", "Shared `x` ago": "تم رفع الفيديو منذ `x`",
"Unsubscribe": "إلغاء الإشتراك", "Unsubscribe": "إلغاء الإشتراك",
@ -18,7 +19,7 @@
"New passwords must match": "الأرقام السرية يجب ان تكون متطابقة", "New passwords must match": "الأرقام السرية يجب ان تكون متطابقة",
"Cannot change password for Google accounts": "لا يستطيع تغيير الرقم السرى لحساب جوجل", "Cannot change password for Google accounts": "لا يستطيع تغيير الرقم السرى لحساب جوجل",
"Authorize token?": "رمز الإذن ؟", "Authorize token?": "رمز الإذن ؟",
"Authorize token for `x`?": "رمز الإذن لـ `x` ?", "Authorize token for `x`?": "تصريح الرمز لـ `x` ؟",
"Yes": "نعم", "Yes": "نعم",
"No": "لا", "No": "لا",
"Import and Export Data": "استخراج و إضافة البيانات", "Import and Export Data": "استخراج و إضافة البيانات",
@ -54,9 +55,9 @@
"Always loop: ": "كرر الفيديو دائما: ", "Always loop: ": "كرر الفيديو دائما: ",
"Autoplay: ": "تشغيل تلقائى: ", "Autoplay: ": "تشغيل تلقائى: ",
"Play next by default: ": "شغل الفيديو التالي تلقائيا: ", "Play next by default: ": "شغل الفيديو التالي تلقائيا: ",
"Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)", "Autoplay next video: ": "شغل الفيديو التالي تلقائيا (في قوائم التشغيل) ",
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ", "Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
"Proxy videos: ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟", "Proxy videos: ": "عرض الفيديوهات عن طريق البروكسي؟ ",
"Default speed: ": "السرعة الإفتراضية: ", "Default speed: ": "السرعة الإفتراضية: ",
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ", "Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
"Player volume: ": "صوت المشغل: ", "Player volume: ": "صوت المشغل: ",
@ -65,17 +66,17 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "الترجمات الإفتراضية: ", "Default captions: ": "الترجمات الإفتراضية: ",
"Fallback captions: ": "الترجمات المصاحبة: ", "Fallback captions: ": "الترجمات المصاحبة: ",
"Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟", "Show related videos: ": "اعرض الفيديوهات ذات الصلة: ",
"Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟", "Show annotations by default: ": "اعرض الملاحظات في الفيديو تلقائيا: ",
"Visual preferences": "التفضيلات المرئية", "Visual preferences": "التفضيلات المرئية",
"Player style: ": "شكل مشغل الفيديوهات", "Player style: ": "شكل مشغل الفيديوهات: ",
"Dark mode: ": "الوضع الليلى: ", "Dark mode: ": "الوضع الليلى: ",
"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: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
"Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
"Sort videos by: ": "ترتيب الفيديو بـ: ", "Sort videos by: ": "ترتيب الفيديو بـ: ",
@ -102,12 +103,12 @@
"Delete account": "حذف الحساب", "Delete account": "حذف الحساب",
"Administrator preferences": "إعدادات المدير", "Administrator preferences": "إعدادات المدير",
"Default homepage: ": "الصفحة الرئيسية الافتراضية ", "Default homepage: ": "الصفحة الرئيسية الافتراضية ",
"Feed menu: ": "قائمة التغذية", "Feed menu: ": "قائمة التدفقات: ",
"Top enabled: ": "تفعيل 'الأفضل' ؟ ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ",
"CAPTCHA enabled: ": "تفعيل الكابتشا ؟", "CAPTCHA enabled: ": "تفعيل الكابتشا: ",
"Login enabled: ": "تفعيل تسجيل الدخول ؟", "Login enabled: ": "تفعيل الولوج: ",
"Registration enabled: ": "تفعيل التسجيل ؟", "Registration enabled: ": "تفعيل التسجيل: ",
"Report statistics: ": "إبلاغ الإحصائيات", "Report statistics: ": "الإبلاغ عن الإحصائيات: ",
"Save preferences": "حفظ التفضيلات", "Save preferences": "حفظ التفضيلات",
"Subscription manager": "مدير الإشتراكات", "Subscription manager": "مدير الإشتراكات",
"Token manager": "إداره الرمز", "Token manager": "إداره الرمز",
@ -126,7 +127,17 @@
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
"View privacy policy.": "عرض سياسة الخصوصية.", "View privacy policy.": "عرض سياسة الخصوصية.",
"Trending": "الشائع", "Trending": "الشائع",
"Public": "عام",
"Unlisted": "غير مصنف", "Unlisted": "غير مصنف",
"Private": "خاص",
"View all playlists": "عرض جميع قوائم التشغيل",
"Updated `x` ago": "تم تحديثه منذ `x`",
"Delete playlist `x`?": "حذف قائمه التشغيل `x` ?",
"Delete playlist": "حذف قائمه التغشيل",
"Create playlist": "إنشاء قائمه تشغيل",
"Title": "العنوان",
"Playlist privacy": "إعدادات الخصوصيه",
"Editing playlist `x`": "تعديل قائمه التشفيل `x`",
"Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب",
"Hide annotations": "إخفاء الملاحظات فى الفيديو", "Hide annotations": "إخفاء الملاحظات فى الفيديو",
"Show annotations": "عرض الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات فى الفيديو",
@ -297,20 +308,20 @@
"`x` hours": "`x` ساعات", "`x` hours": "`x` ساعات",
"`x` minutes": "`x` دقائق", "`x` minutes": "`x` دقائق",
"`x` seconds": "`x` ثوانى", "`x` seconds": "`x` ثوانى",
"Fallback comments: ": "التعليقات المصاحبة", "Fallback comments: ": "التعليقات البديلة: ",
"Popular": "الأكثر شعبية", "Popular": "الأكثر شعبية",
"Top": "الأفضل", "Top": "الأفضل",
"About": "حول", "About": "حول",
"Rating: ": "التقييم", "Rating: ": "التقييم: ",
"Language: ": "اللغة", "Language: ": "اللغة: ",
"View as playlist": "عرض كا قائمة التشغيل", "View as playlist": "عرض كا قائمة التشغيل",
"Default": "الكل", "Default": "الكل",
"Music": "الاغانى", "Music": "الاغانى",
"Gaming": "الألعاب", "Gaming": "الألعاب",
"News": "الأخبار", "News": "الأخبار",
"Movies": "الأفلام", "Movies": "الأفلام",
"Download": "تحميل كـ", "Download": "نزّل",
"Download as: ": "تحميل", "Download as: ": "نزّله كـ: ",
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(تم تعديلة)", "(edited)": "(تم تعديلة)",
"YouTube comment permalink": "رابط التعليق على اليوتيوب", "YouTube comment permalink": "رابط التعليق على اليوتيوب",
@ -321,5 +332,5 @@
"Videos": "الفيديوهات", "Videos": "الفيديوهات",
"Playlists": "قوائم التشغيل", "Playlists": "قوائم التشغيل",
"Community": "المجتمع", "Community": "المجتمع",
"Current version: ": "الإصدار الحالى" "Current version: ": "الإصدار الحالي: "
} }

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` Abonnenten", "`x` subscribers": "`x` Abonnenten",
"`x` videos": "`x` Videos", "`x` videos": "`x` Videos",
"`x` playlists": "",
"LIVE": "LIVE", "LIVE": "LIVE",
"Shared `x` ago": "Vor `x` geteilt", "Shared `x` ago": "Vor `x` geteilt",
"Unsubscribe": "Abbestellen", "Unsubscribe": "Abbestellen",
@ -129,7 +130,17 @@
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "Datenschutzerklärung einsehen.", "View privacy policy.": "Datenschutzerklärung einsehen.",
"Trending": "Trending", "Trending": "Trending",
"Public": "",
"Unlisted": "Nicht aufgeführt", "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", "Watch on YouTube": "Video auf YouTube ansehen",
"Hide annotations": "Anmerkungen ausblenden", "Hide annotations": "Anmerkungen ausblenden",
"Show annotations": "Anmerkungen anzeigen", "Show annotations": "Anmerkungen anzeigen",

View File

@ -7,6 +7,7 @@
"([^0-9]|^)1([^,0-9]|$)": "`x` βίντεο", "([^0-9]|^)1([^,0-9]|$)": "`x` βίντεο",
"": "`x` βίντεο" "": "`x` βίντεο"
}, },
"`x` playlists": "",
"LIVE": "ΖΩΝΤΑΝΑ", "LIVE": "ΖΩΝΤΑΝΑ",
"Shared `x` ago": "Μοιράστηκε πριν `x`", "Shared `x` ago": "Μοιράστηκε πριν `x`",
"Unsubscribe": "Απεγγραφή", "Unsubscribe": "Απεγγραφή",
@ -141,7 +142,17 @@
"View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.",
"View privacy policy.": "Προβολή πολιτικής απορρήτου.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.",
"Trending": "Τάσεις", "Trending": "Τάσεις",
"Public": "",
"Unlisted": "Κρυφό", "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", "Watch on YouTube": "Προβολή στο YouTube",
"Hide annotations": "Απόκρυψη σημειώσεων", "Hide annotations": "Απόκρυψη σημειώσεων",
"Show annotations": "Προβολή σημειώσεων", "Show annotations": "Προβολή σημειώσεων",

View File

@ -7,6 +7,10 @@
"([^0-9]|^)1([^,0-9]|$)": "`x` video", "([^0-9]|^)1([^,0-9]|$)": "`x` video",
"": "`x` videos" "": "`x` videos"
}, },
"`x` playlists": {
"(\\D|^)1(\\D|$)": "`x` playlist",
"": "`x` playlists"
},
"LIVE": "LIVE", "LIVE": "LIVE",
"Shared `x` ago": "Shared `x` ago", "Shared `x` ago": "Shared `x` ago",
"Unsubscribe": "Unsubscribe", "Unsubscribe": "Unsubscribe",
@ -77,11 +81,11 @@
"Show related videos: ": "Show related videos: ", "Show related videos: ": "Show related videos: ",
"Show annotations by default: ": "Show annotations by default: ", "Show annotations by default: ": "Show annotations by default: ",
"Visual preferences": "Visual preferences", "Visual preferences": "Visual preferences",
"Player style: ": "", "Player style: ": "Player style: ",
"Dark mode: ": "Dark mode: ", "Dark mode: ": "Dark mode: ",
"Theme: ": "", "Theme: ": "Theme: ",
"dark": "", "dark": "dark",
"light": "", "light": "light",
"Thin mode: ": "Thin mode: ", "Thin mode: ": "Thin mode: ",
"Subscription preferences": "Subscription preferences", "Subscription preferences": "Subscription preferences",
"Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",
@ -114,9 +118,9 @@
"Feed menu: ": "Feed menu: ", "Feed menu: ": "Feed menu: ",
"Top enabled: ": "Top enabled: ", "Top enabled: ": "Top enabled: ",
"CAPTCHA enabled: ": "CAPTCHA enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ",
"Login enabled: ": "Login enabled? ", "Login enabled: ": "Login enabled: ",
"Registration enabled: ": "Registration enabled? ", "Registration enabled: ": "Registration enabled: ",
"Report statistics: ": "Report statistics? ", "Report statistics: ": "Report statistics: ",
"Save preferences": "Save preferences", "Save preferences": "Save preferences",
"Subscription manager": "Subscription manager", "Subscription manager": "Subscription manager",
"Token manager": "Token manager", "Token manager": "Token manager",
@ -144,7 +148,17 @@
"View JavaScript license information.": "View JavaScript license information.", "View JavaScript license information.": "View JavaScript license information.",
"View privacy policy.": "View privacy policy.", "View privacy policy.": "View privacy policy.",
"Trending": "Trending", "Trending": "Trending",
"Public": "Public",
"Unlisted": "Unlisted", "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", "Watch on YouTube": "Watch on YouTube",
"Hide annotations": "Hide annotations", "Hide annotations": "Hide annotations",
"Show annotations": "Show annotations", "Show annotations": "Show annotations",
@ -157,7 +171,7 @@
"Blacklisted regions: ": "Blacklisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ",
"Shared `x`": "Shared `x`", "Shared `x`": "Shared `x`",
"`x` views": { "`x` views": {
"([^0-9]|^)1([^,0-9]|$)": "`x` views", "([^0-9]|^)1([^,0-9]|$)": "`x` view",
"": "`x` views" "": "`x` views"
}, },
"Premieres in `x`": "Premieres in `x`", "Premieres in `x`": "Premieres in `x`",
@ -165,7 +179,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.", "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 YouTube comments": "View YouTube comments",
"View more comments on Reddit": "View more comments on Reddit", "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", "View Reddit comments": "View Reddit comments",
"Hide replies": "Hide replies", "Hide replies": "Hide replies",
"Show replies": "Show replies", "Show replies": "Show replies",
@ -362,7 +379,7 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(edited)", "(edited)": "(edited)",
"YouTube comment permalink": "YouTube comment permalink", "YouTube comment permalink": "YouTube comment permalink",
"permalink": "", "permalink": "permalink",
"`x` marked it with a ❤": "`x` marked it with a ❤", "`x` marked it with a ❤": "`x` marked it with a ❤",
"Audio mode": "Audio mode", "Audio mode": "Audio mode",
"Video mode": "Video mode", "Video mode": "Video mode",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` abonantoj", "`x` subscribers": "`x` abonantoj",
"`x` videos": "`x` videoj", "`x` videos": "`x` videoj",
"`x` playlists": "",
"LIVE": "NUNA", "LIVE": "NUNA",
"Shared `x` ago": "Konigita antaŭ `x`", "Shared `x` ago": "Konigita antaŭ `x`",
"Unsubscribe": "Malaboni", "Unsubscribe": "Malaboni",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.",
"View privacy policy.": "Vidi regularon pri privateco.", "View privacy policy.": "Vidi regularon pri privateco.",
"Trending": "Tendencoj", "Trending": "Tendencoj",
"Public": "",
"Unlisted": "Ne listigita", "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", "Watch on YouTube": "Vidi videon en Youtube",
"Hide annotations": "Kaŝi prinotojn", "Hide annotations": "Kaŝi prinotojn",
"Show annotations": "Montri prinotojn", "Show annotations": "Montri prinotojn",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` suscriptores", "`x` subscribers": "`x` suscriptores",
"`x` videos": "`x` vídeos", "`x` videos": "`x` vídeos",
"`x` playlists": "",
"LIVE": "DIRECTO", "LIVE": "DIRECTO",
"Shared `x` ago": "Compartido hace `x`", "Shared `x` ago": "Compartido hace `x`",
"Unsubscribe": "Desuscribirse", "Unsubscribe": "Desuscribirse",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Ver información de licencia de JavaScript.", "View JavaScript license information.": "Ver información de licencia de JavaScript.",
"View privacy policy.": "Ver la política de privacidad.", "View privacy policy.": "Ver la política de privacidad.",
"Trending": "Tendencias", "Trending": "Tendencias",
"Public": "",
"Unlisted": "No listado", "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", "Watch on YouTube": "Ver el vídeo en Youtube",
"Hide annotations": "Ocultar anotaciones", "Hide annotations": "Ocultar anotaciones",
"Show annotations": "Mostrar anotaciones", "Show annotations": "Mostrar anotaciones",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` harpidedun", "`x` subscribers": "`x` harpidedun",
"`x` videos": "`x` bideo", "`x` videos": "`x` bideo",
"`x` playlists": "",
"LIVE": "ZUZENEAN", "LIVE": "ZUZENEAN",
"Shared `x` ago": "Duela `x` partekatua", "Shared `x` ago": "Duela `x` partekatua",
"Unsubscribe": "Harpidetza kendu", "Unsubscribe": "Harpidetza kendu",
@ -126,7 +127,17 @@
"View JavaScript license information.": "", "View JavaScript license information.": "",
"View privacy policy.": "", "View privacy policy.": "",
"Trending": "", "Trending": "",
"Public": "",
"Unlisted": "", "Unlisted": "",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Watch on YouTube": "", "Watch on YouTube": "",
"Hide annotations": "", "Hide annotations": "",
"Show annotations": "", "Show annotations": "",
@ -140,6 +151,7 @@
"Shared `x`": "", "Shared `x`": "",
"`x` views": "", "`x` views": "",
"Premieres in `x`": "", "Premieres in `x`": "",
"Premieres `x`": "",
"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": "",
@ -317,5 +329,8 @@
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "",
"Audio mode": "", "Audio mode": "",
"Video mode": "", "Video mode": "",
"Videos": "" "Videos": "",
"Playlists": "",
"Community": "",
"Current version: ": ""
} }

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` abonnés", "`x` subscribers": "`x` abonnés",
"`x` videos": "`x` vidéos", "`x` videos": "`x` vidéos",
"`x` playlists": "`x` listes de lecture",
"LIVE": "EN DIRECT", "LIVE": "EN DIRECT",
"Shared `x` ago": "Ajoutée il y a `x`", "Shared `x` ago": "Ajoutée il y a `x`",
"Unsubscribe": "Se désabonner", "Unsubscribe": "Se désabonner",
@ -13,10 +14,10 @@
"last": "Dernières", "last": "Dernières",
"Next page": "Page suivante", "Next page": "Page suivante",
"Previous page": "Page précédente", "Previous page": "Page précédente",
"Clear watch history?": "Supprimer l'historique des vidéos regardées ?", "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
"New password": "Nouveau mot de passe", "New password": "Nouveau mot de passe",
"New passwords must match": "Les nouveaux mots de passe doivent être identiques", "New passwords must match": "Les champs \"Nouveau mot de passe\" doivent être identiques",
"Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé", "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé depuis Invidious",
"Authorize token?": "Autoriser le token ?", "Authorize token?": "Autoriser le token ?",
"Authorize token for `x`?": "Autoriser le token pour `x` ?", "Authorize token for `x`?": "Autoriser le token pour `x` ?",
"Yes": "Oui", "Yes": "Oui",
@ -29,8 +30,8 @@
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)", "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
"Export": "Exporter", "Export": "Exporter",
"Export subscriptions as OPML": "Exporter les abonnements en OPML", "Export subscriptions as OPML": "Exporter les abonnements au format OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON", "Export data as JSON": "Exporter les données au format JSON",
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?", "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
"History": "Historique", "History": "Historique",
@ -52,9 +53,9 @@
"Preferences": "Préférences", "Preferences": "Préférences",
"Player preferences": "Préférences du lecteur", "Player preferences": "Préférences du lecteur",
"Always loop: ": "Lire en boucle : ", "Always loop: ": "Lire en boucle : ",
"Autoplay: ": "Lire automatiquement : ", "Autoplay: ": "Lancer la lecture automatiquement : ",
"Play next by default: ": "Lire les vidéos suivantes par défaut (similaire a YouTube) : ", "Play next by default: ": "Lire les vidéos suivantes par défaut : ",
"Autoplay next video: ": "Lire automatiquement la vidéo en file d'attente : ", "Autoplay next video: ": "Lancer la lecture automatiquement pour la vidéo suivant la vidéo regardée : ",
"Listen by default: ": "Audio uniquement : ", "Listen by default: ": "Audio uniquement : ",
"Proxy videos: ": "Charger les vidéos à travers un proxy : ", "Proxy videos: ": "Charger les vidéos à travers un proxy : ",
"Default speed: ": "Vitesse par défaut : ", "Default speed: ": "Vitesse par défaut : ",
@ -66,16 +67,16 @@
"Default captions: ": "Sous-titres par défaut : ", "Default captions: ": "Sous-titres par défaut : ",
"Fallback captions: ": "Sous-titres alternatifs : ", "Fallback captions: ": "Sous-titres alternatifs : ",
"Show related videos: ": "Voir les vidéos liées : ", "Show related videos: ": "Voir les vidéos liées : ",
"Show annotations by default: ": "Voir les annotations par défaut : ", "Show annotations by default: ": "Afficher les annotations par défaut : ",
"Visual preferences": "Préférences du site", "Visual preferences": "Préférences du site",
"Player style: ": "Style du lecteur : ", "Player style: ": "Style du lecteur : ",
"Dark mode: ": "Mode Sombre : ", "Dark mode: ": "Mode sombre : ",
"Theme: ": "Thème : ", "Theme: ": "Thème : ",
"dark": "sombre", "dark": "sombre",
"light": "clair", "light": "clair",
"Thin mode: ": "Mode Simplifié : ", "Thin mode: ": "Mode ger : ",
"Subscription preferences": "Préférences de la page d'abonnements", "Subscription preferences": "Préférences de la page d'abonnements",
"Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies : ", "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ",
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
"Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ", "Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ",
"Sort videos by: ": "Trier les vidéos par : ", "Sort videos by: ": "Trier les vidéos par : ",
@ -85,12 +86,12 @@
"alphabetically - reverse": "alphabétiquement - inversé", "alphabetically - reverse": "alphabétiquement - inversé",
"channel name": "nom de la chaîne", "channel name": "nom de la chaîne",
"channel name - reverse": "nom de la chaîne - inversé", "channel name - reverse": "nom de la chaîne - inversé",
"Only show latest video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne : ", "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés : ",
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ", "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui n'a pas était regardée : ",
"Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ", "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas étaient regardées : ",
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "Activer les notifications web", "Enable web notifications": "Activer les notifications web",
"`x` uploaded a video": "`x` a partagé(e) une video", "`x` uploaded a video": "`x` a partagé(e) une vidéo",
"`x` is live": "`x` est en direct", "`x` is live": "`x` est en direct",
"Data preferences": "Préférences liées aux données", "Data preferences": "Préférences liées aux données",
"Clear watch history": "Supprimer l'historique des vidéos regardées", "Clear watch history": "Supprimer l'historique des vidéos regardées",
@ -100,9 +101,9 @@
"Manage tokens": "Gérer les tokens", "Manage tokens": "Gérer les tokens",
"Watch history": "Historique de visionnage", "Watch history": "Historique de visionnage",
"Delete account": "Supprimer votre compte", "Delete account": "Supprimer votre compte",
"Administrator preferences": "Préferences d'Administrateur", "Administrator preferences": "Préferences d'Administration",
"Default homepage: ": "Page d'accueil par défaut : ", "Default homepage: ": "Page d'accueil par défaut : ",
"Feed menu: ": "Menu des Flux : ", "Feed menu: ": "Préferences des abonnements : ",
"Top enabled: ": "Top activé : ", "Top enabled: ": "Top activé : ",
"CAPTCHA enabled: ": "CAPTCHA activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ",
"Login enabled: ": "Connexion activé : ", "Login enabled: ": "Connexion activé : ",
@ -122,19 +123,29 @@
"search": "rechercher", "search": "rechercher",
"Log out": "Déconnexion", "Log out": "Déconnexion",
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
"Source available here.": "Code Source disponible ici.", "Source available here.": "Code source disponible ici.",
"View JavaScript license information.": "Informations des licences JavaScript.", "View JavaScript license information.": "Informations des licences JavaScript.",
"View privacy policy.": "Politique de confidentialité.", "View privacy policy.": "Politique de confidentialité.",
"Trending": "Tendances", "Trending": "Tendances",
"Public": "Publique",
"Unlisted": "Non répertoriée", "Unlisted": "Non répertoriée",
"Private": "Privée",
"View all playlists": "Voir toutes vos playlists",
"Updated `x` ago": "Dernière mise à jour il y a `x`",
"Delete playlist `x`?": "Êtes-vous sûr de vouloir supprimer la liste de lecture ?",
"Delete playlist": "Supprimer la liste de lecture",
"Create playlist": "Créer une liste de lecture",
"Title": "Titre",
"Playlist privacy": "Paramètres de confidentialité de la liste de lecture",
"Editing playlist `x`": "Liste de lecture modifier le `x`",
"Watch on YouTube": "Voir la vidéo sur Youtube", "Watch on YouTube": "Voir la vidéo sur Youtube",
"Hide annotations": "Masquer les annotations", "Hide annotations": "Masquer les annotations",
"Show annotations": "Afficher les annotations", "Show annotations": "Afficher les annotations",
"Genre: ": "Genre : ", "Genre: ": "Genre : ",
"License: ": "Licence : ", "License: ": "Licence : ",
"Family friendly? ": "Tout Public ? ", "Family friendly? ": "Vidéo tout public ? ",
"Wilson score: ": "Score de Wilson : ", "Wilson score: ": "Score de Wilson : ",
"Engagement: ": "Poucentage de spectateur aillant Like ou Dislike la vidéo : ", "Engagement: ": "Pourcentage de spectateur aillant appuyé sur \"J'aime\" ou \"J'aime Pas\" : ",
"Whitelisted regions: ": "Régions sur liste blanche : ", "Whitelisted regions: ": "Régions sur liste blanche : ",
"Blacklisted regions: ": "Régions sur liste noire : ", "Blacklisted regions: ": "Régions sur liste noire : ",
"Shared `x`": "Ajoutée le `x`", "Shared `x`": "Ajoutée le `x`",
@ -149,8 +160,8 @@
"Hide replies": "Masquer les réponses", "Hide replies": "Masquer les réponses",
"Show replies": "Afficher les réponses", "Show replies": "Afficher les réponses",
"Incorrect password": "Mot de passe incorrect", "Incorrect password": "Mot de passe incorrect",
"Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassé, réessayez dans quelques heures", "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassée, réessayez dans quelques heures",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.",
"Invalid TFA code": "Code d'authentification à deux facteurs invalide", "Invalid TFA code": "Code d'authentification à deux facteurs invalide",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.",
"Wrong answer": "Réponse invalide", "Wrong answer": "Réponse invalide",
@ -171,17 +182,17 @@
"Could not fetch comments": "Impossible de charger les commentaires", "Could not fetch comments": "Impossible de charger les commentaires",
"View `x` replies": "Voir `x` réponses", "View `x` replies": "Voir `x` réponses",
"`x` ago": "il y a `x`", "`x` ago": "il y a `x`",
"Load more": "Charger plus", "Load more": "Voir plus",
"`x` points": "`x` points", "`x` points": "`x` points",
"Could not create mix.": "Impossible de charger cette liste de lecture.", "Could not create mix.": "Impossible de charger cette liste de lecture.",
"Empty playlist": "Liste de lecture vide", "Empty playlist": "La liste de lecture est vide",
"Not a playlist.": "Liste de lecture invalide.", "Not a playlist.": "La liste de lecture est invalide.",
"Playlist does not exist.": "La liste de lecture n'existe pas.", "Playlist does not exist.": "La liste de lecture n'existe pas.",
"Could not pull trending pages.": "Impossible de charger les pages de tendances.", "Could not pull trending pages.": "Impossible de charger les pages de tendances.",
"Hidden field \"challenge\" is a required field": "Le champ masqué « challenge » est un champ obligatoire", "Hidden field \"challenge\" is a required field": "Le champ masqué \"challenge\" est un champ obligatoire",
"Hidden field \"token\" is a required field": "Le champ caché \"token\" est requis", "Hidden field \"token\" is a required field": "Le champ caché \"token\" est requis",
"Erroneous challenge": "Challenge Erroné", "Erroneous challenge": "Challenge invalide",
"Erroneous token": "Token Erroné", "Erroneous token": "Token invalide",
"No such user": "Cet utilisateur n'existe pas", "No such user": "Cet utilisateur n'existe pas",
"Token is expired, please try again": "Le token est expiré, veuillez réessayer", "Token is expired, please try again": "Le token est expiré, veuillez réessayer",
"English": "Anglais", "English": "Anglais",
@ -314,12 +325,12 @@
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(modifié)", "(edited)": "(modifié)",
"YouTube comment permalink": "Lien permanent vers le commentaire sur YouTube", "YouTube comment permalink": "Lien permanent vers le commentaire sur YouTube",
"permalink": "permalien", "permalink": "Lien permanent",
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
"Audio mode": "Mode Audio", "Audio mode": "Mode audio",
"Video mode": "Mode Vidéo", "Video mode": "Mode vidéo",
"Videos": "Vidéos", "Videos": "Vidéos",
"Playlists": "Liste de lecture", "Playlists": "Listes de lecture",
"Community": "Communauté", "Community": "Communauté",
"Current version: ": "Version actuelle : " "Current version: ": "Version actuelle : "
} }

View File

@ -1,4 +1,7 @@
{ {
"`x` subscribers": "",
"`x` videos": "",
"`x` playlists": "",
"`x` subscribers.": "`x` áskrifandar.", "`x` subscribers.": "`x` áskrifandar.",
"`x` videos.": "`x` myndbönd.", "`x` videos.": "`x` myndbönd.",
"LIVE": "BEINT", "LIVE": "BEINT",
@ -110,10 +113,13 @@
"Report statistics: ": "Skrá talnagögn? ", "Report statistics: ": "Skrá talnagögn? ",
"Save preferences": "Vista stillingar", "Save preferences": "Vista stillingar",
"Subscription manager": "Áskriftarstjóri", "Subscription manager": "Áskriftarstjóri",
"`x` subscriptions": "",
"`x` tokens": "",
"Token manager": "Táknstjóri", "Token manager": "Táknstjóri",
"Token": "Tákn", "Token": "Tákn",
"`x` subscriptions.": "`x` áskriftir.", "`x` subscriptions.": "`x` áskriftir.",
"`x` tokens.": "`x` tákn.", "`x` tokens.": "`x` tákn.",
"`x` unseen notifications": "",
"Import/export": "Flytja inn/út", "Import/export": "Flytja inn/út",
"unsubscribe": "afskrá", "unsubscribe": "afskrá",
"revoke": "afturkalla", "revoke": "afturkalla",
@ -126,13 +132,24 @@
"View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.",
"View privacy policy.": "Skoða meðferð persónuupplýsinga.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.",
"Trending": "Vinsælt", "Trending": "Vinsælt",
"Public": "",
"Unlisted": "Óskráð", "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", "Watch on YouTube": "Horfa á YouTube",
"Hide annotations": "Fela glósur", "Hide annotations": "Fela glósur",
"Show annotations": "Sýna glósur", "Show annotations": "Sýna glósur",
"Genre: ": "Tegund: ", "Genre: ": "Tegund: ",
"License: ": "Notkunarleyfi: ", "License: ": "Notkunarleyfi: ",
"Family friendly? ": "Fjölskylduvænt? ", "Family friendly? ": "Fjölskylduvænt? ",
"`x` views": "",
"Wilson score: ": "Wilson stig: ", "Wilson score: ": "Wilson stig: ",
"Engagement: ": "Þátttöku: ", "Engagement: ": "Þátttöku: ",
"Whitelisted regions: ": "Svæði á hvítum lista: ", "Whitelisted regions: ": "Svæði á hvítum lista: ",
@ -163,8 +180,10 @@
"Password cannot be empty": "Lykilorð má ekki vera autt", "Password cannot be empty": "Lykilorð má ekki vera autt",
"Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir", "Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir",
"Please log in": "Vinsamlegast skráðu þig inn", "Please log in": "Vinsamlegast skráðu þig inn",
"View `x` replies": "",
"Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`", "Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`",
"channel:`x`": "rás:`x`", "channel:`x`": "rás:`x`",
"`x` points": "",
"Deleted or invalid channel": "Eytt eða ógild rás", "Deleted or invalid channel": "Eytt eða ógild rás",
"This channel does not exist.": "Þessi rás er ekki til.", "This channel does not exist.": "Þessi rás er ekki til.",
"Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.", "Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.",
@ -282,6 +301,13 @@
"Turkish": "Tyrkneska", "Turkish": "Tyrkneska",
"Ukrainian": "Úkraníska", "Ukrainian": "Úkraníska",
"Urdu": "Úrdú", "Urdu": "Úrdú",
"`x` years": "",
"`x` months": "",
"`x` weeks": "",
"`x` days": "",
"`x` hours": "",
"`x` minutes": "",
"`x` seconds": "",
"Uzbek": "Úsbekíska", "Uzbek": "Úsbekíska",
"Vietnamese": "Víetnamska", "Vietnamese": "Víetnamska",
"Welsh": "Velska", "Welsh": "Velska",
@ -299,11 +325,13 @@
"`x` seconds.": "`x` sekúndur.", "`x` seconds.": "`x` sekúndur.",
"Fallback comments: ": "Vara ummæli: ", "Fallback comments: ": "Vara ummæli: ",
"Popular": "Vinsælt", "Popular": "Vinsælt",
"permalink": "",
"Top": "Topp", "Top": "Topp",
"About": "Um", "About": "Um",
"Rating: ": "Einkunn: ", "Rating: ": "Einkunn: ",
"Language: ": "Tungumál: ", "Language: ": "Tungumál: ",
"View as playlist": "Skoða sem spilunarlista", "View as playlist": "Skoða sem spilunarlista",
"Community": "",
"Default": "Sjálfgefið", "Default": "Sjálfgefið",
"Music": "Tónlist", "Music": "Tónlist",
"Gaming": "Tólvuleikja", "Gaming": "Tólvuleikja",

View File

@ -7,6 +7,7 @@
"([^0-9]|^)1([^,0-9]|$)": "`x` video", "([^0-9]|^)1([^,0-9]|$)": "`x` video",
"": "`x` video" "": "`x` video"
}, },
"`x` playlists": "",
"LIVE": "IN DIRETTA", "LIVE": "IN DIRETTA",
"Shared `x` ago": "Condiviso `x` fa", "Shared `x` ago": "Condiviso `x` fa",
"Unsubscribe": "Disiscriviti", "Unsubscribe": "Disiscriviti",
@ -141,7 +142,17 @@
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
"View privacy policy.": "Vedi la politica sulla privacy", "View privacy policy.": "Vedi la politica sulla privacy",
"Trending": "Tendenze", "Trending": "Tendenze",
"Public": "",
"Unlisted": "Non elencati", "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", "Watch on YouTube": "Guarda su YouTube",
"Hide annotations": "Nascondi annotazioni", "Hide annotations": "Nascondi annotazioni",
"Show annotations": "Mostra annotazioni", "Show annotations": "Mostra annotazioni",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` abonnenter", "`x` subscribers": "`x` abonnenter",
"`x` videos": "`x` videoer", "`x` videos": "`x` videoer",
"`x` playlists": "",
"LIVE": "SANNTIDSVISNING", "LIVE": "SANNTIDSVISNING",
"Shared `x` ago": "Delt for `x` siden", "Shared `x` ago": "Delt for `x` siden",
"Unsubscribe": "Opphev abonnement", "Unsubscribe": "Opphev abonnement",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.",
"View privacy policy.": "Vis personvernspraksis.", "View privacy policy.": "Vis personvernspraksis.",
"Trending": "Trendsettende", "Trending": "Trendsettende",
"Public": "",
"Unlisted": "Ulistet", "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", "Watch on YouTube": "Vis video på YouTube",
"Hide annotations": "Skjul merknader", "Hide annotations": "Skjul merknader",
"Show annotations": "Vis merknader", "Show annotations": "Vis merknader",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` abonnees", "`x` subscribers": "`x` abonnees",
"`x` videos": "`x` video's", "`x` videos": "`x` video's",
"`x` playlists": "",
"LIVE": "LIVE", "LIVE": "LIVE",
"Shared `x` ago": "Gedeeld: `x` geleden", "Shared `x` ago": "Gedeeld: `x` geleden",
"Unsubscribe": "Deabonneren", "Unsubscribe": "Deabonneren",
@ -126,7 +127,17 @@
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
"View privacy policy.": "Privacybeleid tonen", "View privacy policy.": "Privacybeleid tonen",
"Trending": "Uitgelicht", "Trending": "Uitgelicht",
"Public": "",
"Unlisted": "Verborgen", "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", "Watch on YouTube": "Video bekijken op YouTube",
"Hide annotations": "Annotaties verbergen", "Hide annotations": "Annotaties verbergen",
"Show annotations": "Annotaties tonen", "Show annotations": "Annotaties tonen",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` subskrybcji", "`x` subscribers": "`x` subskrybcji",
"`x` videos": "`x` filmów", "`x` videos": "`x` filmów",
"`x` playlists": "",
"LIVE": "NA ŻYWO", "LIVE": "NA ŻYWO",
"Shared `x` ago": "Udostępniono `x` temu", "Shared `x` ago": "Udostępniono `x` temu",
"Unsubscribe": "Odsubskrybuj", "Unsubscribe": "Odsubskrybuj",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "Polityka prywatności.", "View privacy policy.": "Polityka prywatności.",
"Trending": "Na czasie", "Trending": "Na czasie",
"Public": "",
"Unlisted": "", "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", "Watch on YouTube": "Zobacz film na YouTube",
"Hide annotations": "", "Hide annotations": "",
"Show annotations": "", "Show annotations": "",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` подписчиков", "`x` subscribers": "`x` подписчиков",
"`x` videos": "`x` видео", "`x` videos": "`x` видео",
"`x` playlists": "",
"LIVE": "ПРЯМОЙ ЭФИР", "LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад", "Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться", "Unsubscribe": "Отписаться",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.",
"View privacy policy.": "Посмотреть политику конфиденциальности.", "View privacy policy.": "Посмотреть политику конфиденциальности.",
"Trending": "В тренде", "Trending": "В тренде",
"Public": "",
"Unlisted": "Нет в списке", "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", "Watch on YouTube": "Смотреть на YouTube",
"Hide annotations": "Скрыть аннотации", "Hide annotations": "Скрыть аннотации",
"Show annotations": "Показать аннотации", "Show annotations": "Показать аннотации",

View File

@ -1,4 +1,7 @@
{ {
"`x` subscribers": "",
"`x` videos": "",
"`x` playlists": "",
"`x` subscribers.": "`x` abone.", "`x` subscribers.": "`x` abone.",
"`x` videos.": "`x` video.", "`x` videos.": "`x` video.",
"LIVE": "CANLI", "LIVE": "CANLI",
@ -110,10 +113,13 @@
"Report statistics: ": "Rapor istatistikleri: ", "Report statistics: ": "Rapor istatistikleri: ",
"Save preferences": "Tercihleri kaydet", "Save preferences": "Tercihleri kaydet",
"Subscription manager": "Abonelik yöneticisi", "Subscription manager": "Abonelik yöneticisi",
"`x` subscriptions": "",
"`x` tokens": "",
"Token manager": "Jeton yöneticisi", "Token manager": "Jeton yöneticisi",
"Token": "Jeton", "Token": "Jeton",
"`x` subscriptions.": "`x` abonelik.", "`x` subscriptions.": "`x` abonelik.",
"`x` tokens.": "`x` jeton.", "`x` tokens.": "`x` jeton.",
"`x` unseen notifications": "",
"Import/export": "İçe/dışa aktar", "Import/export": "İçe/dışa aktar",
"unsubscribe": "abonelikten çık", "unsubscribe": "abonelikten çık",
"revoke": "geri al", "revoke": "geri al",
@ -121,7 +127,17 @@
"`x` unseen notifications.": "`x` okunmamış bildirim.", "`x` unseen notifications.": "`x` okunmamış bildirim.",
"search": "ara", "search": "ara",
"Log out": ıkış yap", "Log out": ıkış yap",
"Public": "",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Source available here.": "Kaynak kodu burada mevcut.", "Source available here.": "Kaynak kodu burada mevcut.",
"View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.",
"View privacy policy.": "Gizlilik politikasını görüntüle.", "View privacy policy.": "Gizlilik politikasını görüntüle.",
@ -133,6 +149,7 @@
"Genre: ": "Tür: ", "Genre: ": "Tür: ",
"License: ": "Lisans: ", "License: ": "Lisans: ",
"Family friendly? ": "Aile için uygun? ", "Family friendly? ": "Aile için uygun? ",
"`x` views": "",
"Wilson score: ": "Wilson puanı: ", "Wilson score: ": "Wilson puanı: ",
"Engagement: ": "İzleyenlerin oy verme oranı: ", "Engagement: ": "İzleyenlerin oy verme oranı: ",
"Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ",
@ -163,8 +180,10 @@
"Password cannot be empty": "Parola boş olamaz", "Password cannot be empty": "Parola boş olamaz",
"Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz", "Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz",
"Please log in": "Lütfen oturum açın", "Please log in": "Lütfen oturum açın",
"View `x` replies": "",
"Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı",
"channel:`x`": "kanal:`x`", "channel:`x`": "kanal:`x`",
"`x` points": "",
"Deleted or invalid channel": "Silinmiş ya da geçersiz kanal", "Deleted or invalid channel": "Silinmiş ya da geçersiz kanal",
"This channel does not exist.": "Bu kanal mevcut değil.", "This channel does not exist.": "Bu kanal mevcut değil.",
"Could not get channel info.": "Kanal bilgisi alınamadı.", "Could not get channel info.": "Kanal bilgisi alınamadı.",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` підписників", "`x` subscribers": "`x` підписників",
"`x` videos": "`x` відео", "`x` videos": "`x` відео",
"`x` playlists": "",
"LIVE": "ПРЯМИЙ ЕФІР", "LIVE": "ПРЯМИЙ ЕФІР",
"Shared `x` ago": "Розміщено `x` назад", "Shared `x` ago": "Розміщено `x` назад",
"Unsubscribe": "Відписатися", "Unsubscribe": "Відписатися",
@ -126,7 +127,17 @@
"View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.",
"View privacy policy.": "Переглянути політику приватності.", "View privacy policy.": "Переглянути політику приватності.",
"Trending": "У тренді", "Trending": "У тренді",
"Public": "",
"Unlisted": "Немає в списку", "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", "Watch on YouTube": "Дивитися на YouTube",
"Hide annotations": "Приховати анотації", "Hide annotations": "Приховати анотації",
"Show annotations": "Показати анотації", "Show annotations": "Показати анотації",

View File

@ -1,6 +1,7 @@
{ {
"`x` subscribers": "`x` 订阅者", "`x` subscribers": "`x` 订阅者",
"`x` videos": "`x` 视频", "`x` videos": "`x` 视频",
"`x` playlists": "",
"LIVE": "直播", "LIVE": "直播",
"Shared `x` ago": "`x` 前分享", "Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消订阅", "Unsubscribe": "取消订阅",
@ -126,7 +127,17 @@
"View JavaScript license information.": "查看 JavaScript 协议信息。", "View JavaScript license information.": "查看 JavaScript 协议信息。",
"View privacy policy.": "查看隐私政策。", "View privacy policy.": "查看隐私政策。",
"Trending": "时下流行", "Trending": "时下流行",
"Public": "",
"Unlisted": "不公开", "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 观看", "Watch on YouTube": "在 YouTube 观看",
"Hide annotations": "隐藏注释", "Hide annotations": "隐藏注释",
"Show annotations": "显示注释", "Show annotations": "显示注释",

381
locales/zh-TW.json Normal file
View File

@ -0,0 +1,381 @@
{
"`x` subscribers": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 個訂閱者",
"": "`x` 個訂閱者"
},
"`x` videos": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 部影片",
"": "`x` 部影片"
},
"`x` playlists": "",
"LIVE": "直播",
"Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消訂閱",
"Subscribe": "訂閱",
"View channel on YouTube": "在 YouTube 上檢視頻道",
"View playlist on YouTube": "在 YouTube 上檢視播放清單",
"newest": "最新",
"oldest": "最舊",
"popular": "流行",
"last": "上一個",
"Next page": "下一頁",
"Previous page": "上一頁",
"Clear watch history?": "清除觀看歷史?",
"New password": "新密碼",
"New passwords must match": "新密碼必須符合",
"Cannot change password for Google accounts": "無法變更 Google 帳號的密碼",
"Authorize token?": "授權 token",
"Authorize token for `x`?": "`x` 的授權 token",
"Yes": "是",
"No": "否",
"Import and Export Data": "匯入與匯出資料",
"Import": "匯入",
"Import Invidious data": "匯入 Invidious 資料",
"Import YouTube subscriptions": "匯入 YouTube 訂閱",
"Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)",
"Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)",
"Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)",
"Export": "匯出",
"Export subscriptions as OPML": "將訂閱匯出為 OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML供 NewPipe 與 FreeTube 使用)",
"Export data as JSON": "將 JSON 匯出為 JSON",
"Delete account?": "刪除帳號?",
"History": "歷史",
"An alternative front-end to YouTube": "一個 YouTube 的替代前端",
"JavaScript license information": "JavaScript 授權條款資訊",
"source": "來源",
"Log in": "登入",
"Log in/register": "登入/註冊",
"Log in with Google": "使用 Google 登入",
"User ID": "使用者 ID",
"Password": "密碼",
"Time (h:mm:ss):": "時間 (h:mm:ss):",
"Text CAPTCHA": "文字 CAPTCHA",
"Image CAPTCHA": "圖片 CAPTCHA",
"Sign In": "登入",
"Register": "註冊",
"E-mail": "電子郵件",
"Google verification code": "Google 驗證碼",
"Preferences": "偏好設定",
"Player preferences": "播放器偏好設定",
"Always loop: ": "總是循環播放:",
"Autoplay: ": "自動播放:",
"Play next by default: ": "預設播放下一部:",
"Autoplay next video: ": "自動播放下一部影片:",
"Listen by default: ": "預設聆聽:",
"Proxy videos: ": "代理影片:",
"Default speed: ": "預設速度:",
"Preferred video quality: ": "偏好的影片畫質:",
"Player volume: ": "播放器音量:",
"Default comments: ": "預設留言:",
"youtube": "youtube",
"reddit": "reddit",
"Default captions: ": "預設字幕:",
"Fallback captions: ": "汰退字幕:",
"Show related videos: ": "顯示相關的影片:",
"Show annotations by default: ": "預設顯示註釋:",
"Visual preferences": "視覺偏好設定",
"Player style: ": "播放器樣式",
"Dark mode: ": "深色模式:",
"Theme: ": "佈景主題",
"dark": "深色",
"light": "淺色",
"Thin mode: ": "精簡模式:",
"Subscription preferences": "訂閱偏好設定",
"Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋?",
"Redirect homepage to feed: ": "重新導向首頁至 feed",
"Number of videos shown in feed: ": "顯示在 feed 中的影片數量:",
"Sort videos by: ": "以此種方式排序影片:",
"published": "已發佈",
"published - reverse": "已發佈 - 反向",
"alphabetically": "字母",
"alphabetically - reverse": "字母 - 反向",
"channel name": "頻道名稱",
"channel name - reverse": "頻道名稱 - 反向",
"Only show latest video from channel: ": "僅顯示從頻道而來的最新影片:",
"Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片:",
"Only show unwatched: ": "僅顯示未觀看的:",
"Only show notifications (if there are any): ": "僅顯示通知(如果有的話):",
"Enable web notifications": "啟用網路通知",
"`x` uploaded a video": "`x` 上傳了一部影片",
"`x` is live": "`x` 正在直播",
"Data preferences": "資料偏好設定",
"Clear watch history": "清除觀看歷史",
"Import/export data": "匯入/匯出資料",
"Change password": "變更密碼",
"Manage subscriptions": "管理訂閱",
"Manage tokens": "管理 tokens",
"Watch history": "觀看歷史",
"Delete account": "刪除帳號",
"Administrator preferences": "管理員偏好設定",
"Default homepage: ": "預設首頁:",
"Feed menu: ": "Feed 選單:",
"Top enabled: ": "頂部啟用:",
"CAPTCHA enabled: ": "CAPTCHA 啟用:",
"Login enabled: ": "啟用登入?",
"Registration enabled: ": "啟用註冊?",
"Report statistics: ": "回報統計?",
"Save preferences": "儲存偏好設定",
"Subscription manager": "訂閱管理員",
"Token manager": "Token 管理員",
"Token": "Token",
"`x` subscriptions": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 個訂閱",
"": "`x` 個訂閱"
},
"`x` tokens": {
"([^0-9]|^)1([^,0-9]|$)": "`x` token",
"": "`x` tokens"
},
"Import/export": "匯入/匯出",
"unsubscribe": "取消訂閱",
"revoke": "撤銷",
"Subscriptions": "訂閱",
"`x` unseen notifications": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 個未讀的通知",
"": "`x` 個未讀的通知"
},
"search": "搜尋",
"Log out": "登出",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。",
"Source available here.": "原始碼在此提供。",
"View JavaScript license information.": "檢視 JavaScript 授權條款資訊。",
"View privacy policy.": "檢視隱私權政策。",
"Trending": "趨勢",
"Public": "公開",
"Unlisted": "未列出",
"Private": "私人",
"View all playlists": "檢視所有播放清單",
"Updated `x` ago": "更新於 `x` 之前",
"Delete playlist `x`?": "刪除播放清單",
"Delete playlist": "刪除播放清單",
"Create playlist": "建立播放清單",
"Title": "標題",
"Playlist privacy": "播放清單隱私",
"Editing playlist `x`": "已編輯播放清單 `x`",
"Watch on YouTube": "在 YouTube 上觀看",
"Hide annotations": "隱藏註釋",
"Show annotations": "顯示註釋",
"Genre: ": "風格:",
"License: ": "授權條款:",
"Family friendly? ": "家庭友好?",
"Wilson score: ": "威爾遜分數:",
"Engagement: ": "參與度:",
"Whitelisted regions: ": "白名單區域:",
"Blacklisted regions: ": "黑名單區域:",
"Shared `x`": "`x` 發佈",
"`x` views": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 次檢視",
"": "`x` 次檢視"
},
"Premieres in `x`": "首映於 `x`",
"Premieres `x`": "首映於 `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "嗨!看來您將 JavaScript 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。",
"View YouTube comments": "檢視 YouTube 留言",
"View more comments on Reddit": "在 Reddit 上檢視更多留言",
"View `x` comments": "檢視 `x` 則留言",
"View Reddit comments": "檢視 Reddit 留言",
"Hide replies": "隱藏回覆",
"Show replies": "顯示回覆",
"Incorrect password": "不正確的密碼",
"Quota exceeded, try again in a few hours": "超過限額,請在幾個小時後再試一次",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "無法登入,請確定雙因素驗證(驗證器或簡訊)已開啟。",
"Invalid TFA code": "無效的 TFA 代碼",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "登入失敗。這可能是因為您的帳號未開啟雙因素驗證的關係。",
"Wrong answer": "錯誤的答案",
"Erroneous CAPTCHA": "錯誤的 CAPTCHA",
"CAPTCHA is a required field": "CAPTCHA 為必填欄位",
"User ID is a required field": "使用者 ID 為必填欄位",
"Password is a required field": "密碼為必填欄位",
"Wrong username or password": "錯誤的使用者名稱或密碼",
"Please sign in using 'Log in with Google'": "請使用「以 Google 登入」來登入",
"Password cannot be empty": "密碼不能為空",
"Password cannot be longer than 55 characters": "密碼不能長於55個字元",
"Please log in": "請登入",
"Invidious Private Feed for `x`": "`x` 的 Invidious 私密 feed",
"channel:`x`": "頻道:`x`",
"Deleted or invalid channel": "已刪除或無效的頻道",
"This channel does not exist.": "此頻道不存在。",
"Could not get channel info.": "無法取得頻道資訊。",
"Could not fetch comments": "無法擷取留言",
"View `x` replies": {
"([^0-9]|^)1([^,0-9]|$)": "檢視 `x` 則回覆",
"": "檢視 `x` 則回覆"
},
"`x` ago": "`x` 以前",
"Load more": "載入更多",
"`x` points": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 點",
"": "`x` 點"
},
"Could not create mix.": "無法建立混合。",
"Empty playlist": "空的播放清單",
"Not a playlist.": "不是播放清單。",
"Playlist does not exist.": "播放清單不存在。",
"Could not pull trending pages.": "無法拉取趨勢頁面。",
"Hidden field \"challenge\" is a required field": "隱藏的欄位 \"challenge\" 是必填欄位",
"Hidden field \"token\" is a required field": "隱藏的欄位 \"token\" 是必填欄位",
"Erroneous challenge": "錯誤的 challenge",
"Erroneous token": "錯誤的 token",
"No such user": "無此使用者",
"Token is expired, please try again": "Token 已過期,請再試一次",
"English": "英文",
"English (auto-generated)": "英文(自動生成)",
"Afrikaans": "南非語",
"Albanian": "阿爾巴尼亞語",
"Amharic": "阿姆哈拉語",
"Arabic": "阿拉伯語",
"Armenian": "亞美尼亞語",
"Azerbaijani": "亞塞拜然語",
"Bangla": "孟加拉文",
"Basque": "巴斯克語",
"Belarusian": "白俄羅斯語",
"Bosnian": "波士尼亞語",
"Bulgarian": "保加利亞語",
"Burmese": "緬甸語",
"Catalan": "加泰隆尼亞語",
"Cebuano": "宿霧語",
"Chinese (Simplified)": "簡體中文",
"Chinese (Traditional)": "繁體中文",
"Corsican": "科西嘉語",
"Croatian": "克羅埃西亞語",
"Czech": "捷克語",
"Danish": "丹麥語",
"Dutch": "荷蘭語",
"Esperanto": "世界語",
"Estonian": "愛沙尼亞語",
"Filipino": "菲律賓語",
"Finnish": "芬蘭語",
"French": "法語",
"Galician": "加利西亞語",
"Georgian": "喬治亞語",
"German": "德語",
"Greek": "希臘語",
"Gujarati": "古吉拉特語",
"Haitian Creole": "海地克里奧爾語",
"Hausa": "豪薩語",
"Hawaiian": "夏威夷語",
"Hebrew": "希伯來語",
"Hindi": "印地語",
"Hmong": "苗文",
"Hungarian": "匈牙利語",
"Icelandic": "冰島語",
"Igbo": "伊博語",
"Indonesian": "印尼語",
"Irish": "愛爾蘭語",
"Italian": "義大利語",
"Japanese": "日語",
"Javanese": "爪哇語",
"Kannada": "康納達語",
"Kazakh": "哈薩克語",
"Khmer": "高棉文",
"Korean": "韓語",
"Kurdish": "庫德語",
"Kyrgyz": "吉爾吉斯語",
"Lao": "寮語",
"Latin": "拉丁語",
"Latvian": "拉脫維亞語",
"Lithuanian": "立陶宛語",
"Luxembourgish": "盧森堡語",
"Macedonian": "馬其頓語",
"Malagasy": "馬拉加斯語",
"Malay": "馬來語",
"Malayalam": "馬拉雅拉姆語",
"Maltese": "馬爾他語",
"Maori": "毛利語",
"Marathi": "馬拉提語",
"Mongolian": "蒙古語",
"Nepali": "尼泊爾語",
"Norwegian Bokmål": "書面挪威語",
"Nyanja": "尼揚賈語",
"Pashto": "普什圖語",
"Persian": "波斯語",
"Polish": "波蘭人",
"Portuguese": "葡萄牙語",
"Punjabi": "旁遮普語",
"Romanian": "羅馬尼亞語",
"Russian": "俄語",
"Samoan": "薩摩亞語",
"Scottish Gaelic": "蘇格蘭蓋爾語",
"Serbian": "塞爾維亞語",
"Shona": "修納語",
"Sindhi": "信德語",
"Sinhala": "僧伽羅語",
"Slovak": "斯洛伐克語",
"Slovenian": "斯洛維尼亞語",
"Somali": "索馬利亞語",
"Southern Sotho": "南塞索托語",
"Spanish": "西班牙語",
"Spanish (Latin America)": "西班牙語(拉丁美洲)",
"Sundanese": "巽他語",
"Swahili": "斯瓦希里語",
"Swedish": "瑞典語",
"Tajik": "塔吉克語",
"Tamil": "坦米爾語",
"Telugu": "泰盧固語",
"Thai": "泰語",
"Turkish": "土耳其語",
"Ukrainian": "烏克蘭語",
"Urdu": "烏爾都語",
"Uzbek": "烏茲別克語",
"Vietnamese": "越南語",
"Welsh": "威爾斯語",
"Western Frisian": "西菲士蘭語",
"Xhosa": "科薩語",
"Yiddish": "意第緒語",
"Yoruba": "約魯巴語",
"Zulu": "祖魯語",
"`x` years": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 年",
"": "`x` 年"
},
"`x` months": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 月",
"": "`x` 月"
},
"`x` weeks": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 週",
"": "`x` 週"
},
"`x` days": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 天",
"": "`x` 天"
},
"`x` hours": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 小時",
"": "`x` 小時"
},
"`x` minutes": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 天",
"": "`x` 天"
},
"`x` seconds": {
"([^0-9]|^)1([^,0-9]|$)": "`x` 秒",
"": "`x` 秒"
},
"Fallback comments: ": "汰退留言:",
"Popular": "熱門頻道",
"Top": "熱門影片",
"About": "關於",
"Rating: ": "評分:",
"Language: ": "語言:",
"View as playlist": "以播放清單檢視",
"Default": "預設值",
"Music": "音樂",
"Gaming": "遊戲",
"News": "新聞",
"Movies": "電影",
"Download": "下載",
"Download as: ": "下載為:",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(已編輯)",
"YouTube comment permalink": "YouTube 留言永久連結",
"permalink": "",
"`x` marked it with a ❤": "`x` 為此標記 ❤",
"Audio mode": "音訊模式",
"Video mode": "視訊模式",
"Videos": "影片",
"Playlists": "播放清單",
"Community": "社群",
"Current version: ": "目前版本:"
}

View File

@ -19,6 +19,6 @@ dependencies:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 0.26.0 version: ~> 0.26.0
crystal: 0.31.0 crystal: 0.31.1
license: AGPLv3 license: AGPLv3

File diff suppressed because it is too large Load Diff

View File

@ -41,13 +41,15 @@ struct ChannelVideo
end end
end end
def to_xml(locale, host_url, xml : XML::Builder) def to_xml(locale, host_url, query_params, xml : XML::Builder)
query_params["v"] = self.id
xml.element("entry") do xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("id") { xml.text "yt:video:#{self.id}" }
xml.element("yt:videoId") { xml.text self.id } xml.element("yt:videoId") { xml.text self.id }
xml.element("yt:channelId") { xml.text self.ucid } xml.element("yt:channelId") { xml.text self.ucid }
xml.element("title") { xml.text self.title } xml.element("title") { xml.text self.title }
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text self.author } xml.element("name") { xml.text self.author }
@ -56,7 +58,7 @@ struct ChannelVideo
xml.element("content", type: "xhtml") do xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/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("a", href: "#{host_url}/watch?#{query_params}") do
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
end end
end end

View File

@ -129,7 +129,7 @@ class AuthHandler < Kemal::Handler
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
env.response.status_code = 403 env.response.status_code = 403
env.response.puts error_message env.response.print error_message
end end
end end
end end
@ -159,7 +159,8 @@ class APIHandler < Kemal::Handler
env.response.output.rewind 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) response = JSON.parse(env.response.output)
if fields_text = env.params.query["fields"]? if fields_text = env.params.query["fields"]?
@ -194,7 +195,7 @@ class APIHandler < Kemal::Handler
end end
ensure ensure
env.response.output = output env.response.output = output
env.response.puts response env.response.print response
env.response.flush env.response.flush
end end
@ -237,16 +238,3 @@ class HTTP::Client
response response
end end
end end
struct Crystal::ThreadLocalValue(T)
@values = Hash(Thread, T).new
def get(&block : -> T)
th = Thread.current
if !@values[th]?
@values[th] = yield
else
@values[th]
end
end
end

View File

@ -94,9 +94,7 @@ struct ConfigPreferences
result result
end end
rescue ex rescue ex
result = value.read_bool if value.read_bool
if result
"dark" "dark"
else else
"light" "light"
@ -110,7 +108,7 @@ struct ConfigPreferences
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
unless node.is_a?(YAML::Nodes::Scalar) unless node.is_a?(YAML::Nodes::Scalar)
node.raise "Expected sequence, not #{node.class}" node.raise "Expected scalar, not #{node.class}"
end end
case node.value case node.value
@ -134,8 +132,8 @@ struct ConfigPreferences
comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray},
continue: {type: Bool, default: false}, continue: {type: Bool, default: false},
continue_autoplay: {type: Bool, default: true}, continue_autoplay: {type: Bool, default: true},
dark_mode: {type: String, default: "", converter: BoolToString},
dismissals: {type: String, default: ""}, dismissals: {type: String, default: ""},
dark_mode: {type: String, default: "light", converter: BoolToString},
latest_only: {type: Bool, default: false}, latest_only: {type: Bool, default: false},
listen: {type: Bool, default: false}, listen: {type: Bool, default: false},
local: {type: Bool, default: false}, local: {type: Bool, default: false},
@ -144,7 +142,8 @@ struct ConfigPreferences
notifications_only: {type: Bool, default: false}, notifications_only: {type: Bool, default: false},
player_style: {type: String, default: "invidious"}, player_style: {type: String, default: "invidious"},
quality: {type: String, default: "hd720"}, quality: {type: String, default: "hd720"},
redirect_feed: {type: Bool, default: false}, default_home: {type: String, default: "Popular"},
feed_menu: {type: Array(String), default: ["Popular", "Trending", "Subscriptions", "Playlists"]},
related_videos: {type: Bool, default: true}, related_videos: {type: Bool, default: true},
sort: {type: String, default: "published"}, sort: {type: String, default: "published"},
speed: {type: Float32, default: 1.0_f32}, speed: {type: Float32, default: 1.0_f32},
@ -599,7 +598,17 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
return items return items
end 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 # Create table if it doesn't exist
begin begin
db.exec("SELECT * FROM #{table_name} LIMIT 0") db.exec("SELECT * FROM #{table_name} LIMIT 0")

View File

@ -86,6 +86,16 @@ class HTTPClient < HTTP::Client
return opts return opts
end end
def exec(request)
if self.host == "www.youtube.com"
request.headers["x-youtube-client-name"] ||= "1"
request.headers["x-youtube-client-version"] ||= "1.20180719"
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36"
end
super
end
end end
def get_proxies(country_code = "US") def get_proxies(country_code = "US")

View File

@ -20,7 +20,7 @@ end
def make_client(url : URI, region = nil) def make_client(url : URI, region = nil)
client = HTTPClient.new(url) client = HTTPClient.new(url)
client.family = CONFIG.force_resolve client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
client.read_timeout = 15.seconds client.read_timeout = 15.seconds
client.connect_timeout = 15.seconds client.connect_timeout = 15.seconds

View File

@ -1,5 +1,51 @@
struct PlaylistVideo 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.object do
json.field "title", self.title json.field "title", self.title
json.field "videoId", self.id json.field "videoId", self.id
@ -12,17 +58,23 @@ struct PlaylistVideo
generate_thumbnails(json, self.id, config, kemal_config) generate_thumbnails(json, self.id, config, kemal_config)
end end
if index
json.field "index", index
json.field "indexId", self.index.to_u64.to_s(16).upcase
else
json.field "index", self.index json.field "index", self.index
end
json.field "lengthSeconds", self.length_seconds json.field "lengthSeconds", self.length_seconds
end end
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 if json
to_json(locale, config, kemal_config, json) to_json(locale, config, kemal_config, json, index: index)
else else
JSON.build do |json| JSON.build do |json|
to_json(locale, config, kemal_config, json) to_json(locale, config, kemal_config, json, index: index)
end end
end end
end end
@ -35,12 +87,66 @@ struct PlaylistVideo
length_seconds: Int32, length_seconds: Int32,
published: Time, published: Time,
plid: String, plid: String,
index: Int32, index: Int64,
live_now: Bool, live_now: Bool,
}) })
end end
struct Playlist 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({ db_mapping({
title: String, title: String,
id: String, id: String,
@ -53,57 +159,122 @@ struct Playlist
updated: Time, updated: Time,
thumbnail: String?, thumbnail: String?,
}) })
def privacy
PlaylistPrivacy::Public
end
end end
def fetch_playlist_videos(plid, page, video_count, continuation = nil, locale = nil) enum PlaylistPrivacy
client = make_client(YT_URL) Public = 0
Unlisted = 1
if continuation Private = 2
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?
if index
index -= 1
end end
index ||= 0
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
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", nil
json.field "authorThumbnails", [] of String
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
json.field "videoCount", self.video_count
json.field "viewCount", self.views
json.field "updated", self.updated.to_unix
json.field "isListed", self.privacy.public?
json.field "videos" do
json.array do
videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
videos.each_with_index do |video, index|
video.to_json(locale, config, Kemal.config, json, offset + index)
end
end
end
end
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 else
index = (page - 1) * 100 JSON.build do |json|
end to_json(offset, locale, config, kemal_config, json, continuation: continuation)
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
end end
end 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 end
def extract_playlist(plid, nodeset, index) def extract_playlist(plid, nodeset, index)
@ -144,7 +315,7 @@ def extract_playlist(plid, nodeset, index)
length_seconds: length_seconds, length_seconds: length_seconds,
published: Time.utc, published: Time.utc,
plid: plid, plid: plid,
index: index + offset, index: (index + offset).to_i64,
live_now: live_now live_now: live_now
) )
end end
@ -200,6 +371,18 @@ def produce_playlist_url(id, index)
return url return url
end 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) def fetch_playlist(plid, locale)
client = make_client(YT_URL) client = make_client(YT_URL)
@ -261,6 +444,59 @@ def fetch_playlist(plid, locale)
return playlist return playlist
end 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) def template_playlist(playlist)
html = <<-END_HTML html = <<-END_HTML
<h3> <h3>

View File

@ -1,11 +1,13 @@
struct SearchVideo struct SearchVideo
def to_xml(host_url, auto_generated, xml : XML::Builder) def to_xml(host_url, auto_generated, query_params, xml : XML::Builder)
query_params["v"] = self.id
xml.element("entry") do xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("id") { xml.text "yt:video:#{self.id}" }
xml.element("yt:videoId") { xml.text self.id } xml.element("yt:videoId") { xml.text self.id }
xml.element("yt:channelId") { xml.text self.ucid } xml.element("yt:channelId") { xml.text self.ucid }
xml.element("title") { xml.text self.title } xml.element("title") { xml.text self.title }
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}")
xml.element("author") do xml.element("author") do
if auto_generated if auto_generated
@ -19,9 +21,11 @@ struct SearchVideo
xml.element("content", type: "xhtml") do xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/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("a", href: "#{host_url}/watch?#{query_params}") do
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
end end
xml.element("p", style: "white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
end end
end end
@ -40,12 +44,12 @@ struct SearchVideo
end end
end end
def to_xml(host_url, auto_generated, xml : XML::Builder | Nil = nil) def to_xml(host_url, auto_generated, query_params, xml : XML::Builder | Nil = nil)
if xml if xml
to_xml(host_url, auto_generated, xml) to_xml(host_url, auto_generated, query_params, xml)
else else
XML.build do |json| XML.build do |json|
to_xml(host_url, auto_generated, xml) to_xml(host_url, auto_generated, query_params, xml)
end end
end end
end end
@ -427,3 +431,69 @@ def produce_channel_search_url(ucid, query, page)
return url return url
end 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

View File

@ -85,7 +85,8 @@ struct Preferences
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only},
player_style: {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString}, player_style: {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString},
quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString}, quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString},
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, default_home: {type: String, default: CONFIG.default_user_preferences.default_home},
feed_menu: {type: Array(String), default: CONFIG.default_user_preferences.feed_menu},
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},
sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString}, sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString},
speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, speed: {type: Float32, default: CONFIG.default_user_preferences.speed},
@ -283,6 +284,49 @@ def subscribe_ajax(channel_id, action, env_headers)
end end
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) def get_subscription_feed(db, user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit offset = (page - 1) * limit

View File

@ -715,13 +715,14 @@ struct Video
storyboards = player_response["storyboards"]? storyboards = player_response["storyboards"]?
.try &.as_h .try &.as_h
.try &.["playerStoryboardSpecRenderer"]? .try &.["playerStoryboardSpecRenderer"]?
.try &.["spec"]?
.try &.as_s.split("|")
if !storyboards if !storyboards
storyboards = player_response["storyboards"]? if storyboard = player_response["storyboards"]?
.try &.as_h .try &.as_h
.try &.["playerLiveStoryboardSpecRenderer"]? .try &.["playerLiveStoryboardSpecRenderer"]?
.try &.["spec"]?
if storyboard = storyboards.try &.["spec"]?
.try &.as_s .try &.as_s
return [{ return [{
url: storyboard.split("#")[0], url: storyboard.split("#")[0],
@ -736,9 +737,6 @@ struct Video
end end
end end
storyboards = storyboards.try &.["spec"]?
.try &.as_s.split("|")
items = [] of NamedTuple( items = [] of NamedTuple(
url: String, url: String,
width: Int32, width: Int32,
@ -767,6 +765,7 @@ struct Video
interval = interval.to_i interval = interval.to_i
storyboard_width = storyboard_width.to_i storyboard_width = storyboard_width.to_i
storyboard_height = storyboard_height.to_i storyboard_height = storyboard_height.to_i
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
items << { items << {
url: url.to_s.sub("$L", i).sub("$N", "M$M"), url: url.to_s.sub("$L", i).sub("$N", "M$M"),
@ -776,7 +775,7 @@ struct Video
interval: interval, interval: interval,
storyboard_width: storyboard_width, storyboard_width: storyboard_width,
storyboard_height: storyboard_height, storyboard_height: storyboard_height,
storyboard_count: (count.to_f / (storyboard_width.to_f * storyboard_height.to_f)).ceil.to_i, storyboard_count: storyboard_count,
} }
end end
@ -1229,7 +1228,7 @@ def fetch_video(id, region)
avg_rating = avg_rating.nan? ? 0.0 : avg_rating avg_rating = avg_rating.nan? ? 0.0 : avg_rating
info["avg_rating"] = "#{avg_rating}" info["avg_rating"] = "#{avg_rating}"
description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || "" description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || "<p></p>"
wilson_score = ci_lower_bound(likes, likes + dislikes) wilson_score = ci_lower_bound(likes, likes + dislikes)
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"] published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"]
@ -1275,21 +1274,35 @@ def itag_to_metadata?(itag : String)
return VIDEO_FORMATS[itag]? return VIDEO_FORMATS[itag]?
end 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) def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i? annotations = query["iv_load_policy"]?.try &.to_i?
autoplay = query["autoplay"]?.try &.to_i? autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
comments = query["comments"]?.try &.split(",").map { |a| a.downcase } comments = query["comments"]?.try &.split(",").map { |a| a.downcase }
continue = query["continue"]?.try &.to_i? continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
continue_autoplay = query["continue_autoplay"]?.try &.to_i? continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe }
local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe }
player_style = query["player_style"]? player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase } preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]? quality = query["quality"]?
region = query["region"]? region = query["region"]?
related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
speed = query["speed"]?.try &.rchop("x").to_f? speed = query["speed"]?.try &.rchop("x").to_f?
video_loop = query["loop"]?.try &.to_i? video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe }
volume = query["volume"]?.try &.to_i? volume = query["volume"]?.try &.to_i?
if preferences if preferences
@ -1342,17 +1355,10 @@ def process_video_params(query, preferences)
local = false local = false
end end
if query["t"]? if start = query["t"]? || query["time_continue"]? || query["start"]?
video_start = decode_time(query["t"]) video_start = decode_time(start)
end end
video_start ||= 0 video_start ||= 0
if query["time_continue"]?
video_start = decode_time(query["time_continue"])
end
video_start ||= 0
if query["start"]?
video_start = decode_time(query["start"])
end
if query["end"]? if query["end"]?
video_end = decode_time(query["end"]) video_end = decode_time(query["end"])

View 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 %>

View File

@ -6,9 +6,9 @@
<div class="pure-u-1 pure-u-md-1-4"></div> <div class="pure-u-1 pure-u-md-1-4"></div>
<div class="pure-u-1 pure-u-md-1-2"> <div class="pure-u-1 pure-u-md-1-2">
<div class="pure-g"> <div class="pure-g">
<% feed_menu = config.feed_menu.dup %> <% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %>
<% if !env.get?("user") %> <% if !env.get?("user") %>
<% feed_menu.reject! {|feed| feed == "Subscriptions"} %> <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
<% end %> <% end %>
<% feed_menu.each do |feed| %> <% feed_menu.each do |feed| %>
<div class="pure-u-1-2 pure-u-md-1-<%= feed_menu.size %>"> <div class="pure-u-1-2 pure-u-md-1-<%= feed_menu.size %>">

View File

@ -13,7 +13,7 @@
<p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p> <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 %> <% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %>
<h5><%= item.description_html %></h5> <h5><%= item.description_html %></h5>
<% when SearchPlaylist %> <% when SearchPlaylist, InvidiousPlaylist %>
<% if item.id.starts_with? "RD" %> <% if item.id.starts_with? "RD" %>
<% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").full_path.split("/")[2]}" %> <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").full_path.split("/")[2]}" %>
<% else %> <% else %>
@ -56,6 +56,19 @@
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <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 %> <% if item.responds_to?(:live_now) && item.live_now %>
<p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
<% elsif item.length_seconds != 0 %> <% elsif item.length_seconds != 0 %>
@ -63,7 +76,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<p><%= item.title %></p> <p><a href="/watch?v=<%= item.id %>"><%= item.title %></a></p>
</a> </a>
<p> <p>
<b> <b>
@ -103,6 +116,17 @@
</a> </a>
</p> </p>
</form> </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 %> <% end %>
<% if item.responds_to?(:live_now) && item.live_now %> <% if item.responds_to?(:live_now) && item.live_now %>

View 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>

View 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>

View 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>

View File

@ -29,6 +29,7 @@
<script> <script>
var video_data = { var video_data = {
id: '<%= video.id %>', id: '<%= video.id %>',
index: '<%= continuation %>',
plid: '<%= plid %>', plid: '<%= plid %>',
length_seconds: '<%= video.length_seconds.to_f %>', length_seconds: '<%= video.length_seconds.to_f %>',
video_series: <%= video_series.to_json %>, video_series: <%= video_series.to_json %>,

View File

@ -0,0 +1,8 @@
<% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title>
Invidious
</title>
<% end %>
<%= rendered "components/feed_menu" %>

View File

@ -6,36 +6,77 @@
<div class="pure-g h-box"> <div class="pure-g h-box">
<div class="pure-u-2-3"> <div class="pure-u-2-3">
<h3><%= playlist.title %></h3> <h3><%= playlist.title %></h3>
</div> <% if playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-1-3" style="text-align:right"> <b>
<h3> <% if playlist.author == user.try &.email %>
<a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a> <a href="/view_all_playlists"><%= playlist.author %></a> |
</h3> <% else %>
</div> <%= playlist.author %> |
</div> <% end %>
<%= translate(locale, "`x` videos", "#{playlist.video_count}") %> |
<div class="pure-g h-box"> <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<div class="pure-u-1-3"> <% 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 %>"> <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %> <%= translate(locale, "View playlist on YouTube") %>
</a> </a>
<div class="pure-u-1 pure-md-1-3">
<a href="/channel/<%= playlist.ucid %>">
<b><%= playlist.author %></b>
</a>
</div> </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>
<div class="pure-u-1-2"></div>
</div> </div>
<div class="h-box"> <div class="h-box">
<p><%= playlist.description_html %></p> <p><%= playlist.description_html %></p>
</div> </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"> <div class="h-box">
<hr> <hr>
</div> </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"> <div class="pure-g">
<% videos.each_slice(4) do |slice| %> <% videos.each_slice(4) do |slice| %>
<% slice.each do |item| %> <% slice.each do |item| %>

View File

@ -1,7 +1,7 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title> <title>
<% if config.default_home != "Popular" %> <% if env.get("preferences").as(Preferences).default_home != "Popular" %>
<%= translate(locale, "Popular") %> - Invidious <%= translate(locale, "Popular") %> - Invidious
<% else %> <% else %>
Invidious Invidious

View File

@ -135,6 +135,32 @@ function update_value(element) {
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>> <input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
</div> </div>
<% if env.get?("user") %>
<% feed_options = {"", "Popular", "Top", "Trending", "Subscriptions", "Playlists"} %>
<% else %>
<% feed_options = {"", "Popular", "Top", "Trending"} %>
<% end %>
<div class="pure-control-group">
<label for="default_home"><%= translate(locale, "Default homepage: ") %></label>
<select name="default_home" id="default_home">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
<% end %>
</div>
<% if env.get? "user" %> <% if env.get? "user" %>
<legend><%= translate(locale, "Subscription preferences") %></legend> <legend><%= translate(locale, "Subscription preferences") %></legend>
@ -143,11 +169,6 @@ function update_value(element) {
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>> <input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
</div> </div>
<div class="pure-control-group">
<label for="redirect_feed"><%= translate(locale, "Redirect homepage to feed: ") %></label>
<input name="redirect_feed" id="redirect_feed" type="checkbox" <% if preferences.redirect_feed %>checked<% end %>>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="max_results"><%= translate(locale, "Number of videos shown in feed: ") %></label> <label for="max_results"><%= translate(locale, "Number of videos shown in feed: ") %></label>
<input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>"> <input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>">
@ -193,20 +214,20 @@ function update_value(element) {
<legend><%= translate(locale, "Administrator preferences") %></legend> <legend><%= translate(locale, "Administrator preferences") %></legend>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="default_home"><%= translate(locale, "Default homepage: ") %></label> <label for="admin_default_home"><%= translate(locale, "Default homepage: ") %></label>
<select name="default_home" id="default_home"> <select name="admin_default_home" id="admin_default_home">
<% {"Popular", "Top", "Trending", "Subscriptions"}.each do |option| %> <% feed_options.each do |option| %>
<option value="<%= option %>" <% if config.default_home == option %> selected <% end %>><%= translate(locale, option) %></option> <option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select> </select>
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label> <label for="admin_feed_menu"><%= translate(locale, "Feed menu: ") %></label>
<% 4.times do |index| %> <% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]"> <select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]">
<% {"", "Popular", "Top", "Trending", "Subscriptions"}.each do |option| %> <% feed_options.each do |option| %>
<option value="<%= option %>" <% if config.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option> <option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %> <% end %>
</select> </select>
<% end %> <% end %>
@ -261,6 +282,10 @@ function update_value(element) {
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a> <a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
</div> </div>
<div class="pure-control-group">
<a href="/view_all_playlists"><%= translate(locale, "View all playlists") %></a>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a> <a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div> </div>

View File

@ -20,7 +20,6 @@
<li>a randomly generated token for providing an RSS feed of a user's subscriptions</li> <li>a randomly generated token for providing an RSS feed of a user's subscriptions</li>
<li>a list of video IDs identifying watched videos</li> <li>a list of video IDs identifying watched videos</li>
</ul> </ul>
<p>The above list reflects <a href="https://github.com/omarroth/invidious/blob/master/src/invidious/users.cr#L14-L51">this code</a>.</p>
<p>Users can clear their watch history using the <a href="/clear_watch_history">clear watch history</a> page.</p> <p>Users can clear their watch history using the <a href="/clear_watch_history">clear watch history</a> page.</p>
<p>If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.</p> <p>If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.</p>

View File

@ -117,17 +117,15 @@
</div> </div>
<div class="pure-u-1 pure-u-md-1-3"> <div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-bitcoin"></i> <i class="icon ion-logo-bitcoin"></i>
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY BTC: <a href="bitcoin:356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY">356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY</a>
</div> </div>
<div class="pure-u-1 pure-u-md-1-3"> <div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-bitcoin"></i> <i class="icon ion-logo-bitcoin"></i>
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk BCH: <a href="bitcoincash:qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk">qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk</a>
</div> </div>
<div class="pure-u-1 pure-u-md-1-3"> <div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-usd"></i> <i class="icon ion-logo-usd"></i>
<a href="https://liberapay.com/omarroth">Liberapay</a> <a href="https://liberapay.com/omarroth">Liberapay</a>
/
<a href="https://patreon.com/omarroth">Patreon</a>
</div> </div>
<div class="pure-u-1 pure-u-md-1-3"> <div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-javascript"></i> <i class="icon ion-logo-javascript"></i>

View File

@ -1,7 +1,7 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title> <title>
<% if config.default_home != "Top" %> <% if env.get("preferences").as(Preferences).default_home != "Top" %>
<%= translate(locale, "Top") %> - Invidious <%= translate(locale, "Top") %> - Invidious
<% else %> <% else %>
Invidious Invidious

View File

@ -1,7 +1,7 @@
<% content_for "header" do %> <% content_for "header" do %>
<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
<title> <title>
<% if config.default_home != "Trending" %> <% if env.get("preferences").as(Preferences).default_home != "Trending" %>
<%= translate(locale, "Trending") %> - Invidious <%= translate(locale, "Trending") %> - Invidious
<% else %> <% else %>
Invidious Invidious

View File

@ -0,0 +1,24 @@
<% content_for "header" do %>
<title><%= translate(locale, "Playlists") %> - Invidious</title>
<% end %>
<%= rendered "components/feed_menu" %>
<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>

View File

@ -29,6 +29,7 @@
<script> <script>
var video_data = { var video_data = {
id: '<%= video.id %>', id: '<%= video.id %>',
index: '<%= continuation %>',
plid: '<%= plid %>', plid: '<%= plid %>',
length_seconds: <%= video.length_seconds.to_f %>, length_seconds: <%= video.length_seconds.to_f %>,
play_next: <%= !rvs.empty? && !plid && params.continue %>, play_next: <%= !rvs.empty? && !plid && params.continue %>,