コミットを比較

...

26 コミット

作成者 SHA1 メッセージ 日付
テクニカル諏訪子 d35108ec98 Merge branch 'master' of github.com:iv-org/invidious 2022-02-17 01:55:24 +09:00
Samantaz Fox b24a89f820
Merge pull request #2903 from iv-org/SamantazFox-patch-1
comments: don't error out when video has no comments
2022-02-15 02:24:06 +01:00
Samantaz Fox 7112f35793
comments: don't error out when video has no comments
continuationItems is nil when video has no comments
2022-02-14 21:54:26 +01:00
Samantaz Fox 85ba04b715
Merge pull request #2871 from SamantazFox/user-code-cleaning
User code cleaning & fixing
2022-02-14 15:38:05 +01:00
Samantaz Fox 8af202e86b
Merge pull request #2892 from matthewmcgarvey/video-playability
Raise error if video not playable, also handle missing related videos
2022-02-14 00:59:07 +01:00
Samantaz Fox 57353fe0c6
Fix Freetube subscriptions import 2022-02-13 22:40:51 +01:00
Samantaz Fox 6c116e34c4
Merge pull request #2894 from weblate/weblate-invidious-translations
Translations update from Hosted Weblate
2022-02-13 01:19:39 +01:00
Hosted Weblate 9ff26ea4d4
Update Chinese (Traditional) translation
Co-authored-by: Jeff Huang <s8321414@gmail.com>
2022-02-13 01:14:58 +01:00
Hosted Weblate 2194bd2812
Update Chinese (Simplified) translation
Co-authored-by: Eric <alchemillatruth@purelymail.com>
2022-02-13 01:14:58 +01:00
Hosted Weblate 1e6cd0b18f
Update Turkish translation
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
2022-02-13 01:14:57 +01:00
Hosted Weblate 85d178ff8b
Update French translation
Co-authored-by: Samantaz Fox <translator-weblate@samantaz.fr>
2022-02-13 01:14:57 +01:00
Hosted Weblate e0d09c3cda
Update Albanian translation
Co-authored-by: Besnik Bleta <besnik@programeshqip.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2022-02-13 01:14:57 +01:00
Hosted Weblate 47f529dc09
Update Russian translation
Co-authored-by: AHOHNMYC <lqwh2h2cwa@protonmail.com>
2022-02-13 01:14:57 +01:00
Hosted Weblate dde850a2e9
Update Arabic translation
Co-authored-by: Mohammed Anas <triallax@tutanota.com>
2022-02-13 01:14:57 +01:00
Hosted Weblate 34e1a465be
Update Norwegian Bokmål translation
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
2022-02-13 01:14:56 +01:00
Hosted Weblate 7d756209b1
Update Spanish translation
Co-authored-by: Jorge Maldonado Ventura <jorgesumle@freakspot.net>
2022-02-13 01:14:56 +01:00
Samantaz Fox 60e870b277
Fix OPML import 2022-02-12 17:32:20 +01:00
matthewmcgarvey ddf1e84f7c Raise exception if playability not ok, also handle missing related videos 2022-02-10 23:43:14 -06:00
Samantaz Fox 99d770be64
Move user pages (ECR files) to subfolder 2022-02-07 17:39:15 +01:00
Samantaz Fox 71a8867a4a
Move user cookies to their own module 2022-02-07 17:39:15 +01:00
Samantaz Fox 2bbd424fce
Move import logic to its own module 2022-02-07 17:38:50 +01:00
Samantaz Fox ef8dc7272b
Put CSV import function under its own module 2022-02-07 17:15:22 +01:00
Samantaz Fox ad4a06fca5
Move user captcha code to its own module 2022-02-07 17:15:22 +01:00
Samantaz Fox c04f45d5e3
Move user struct to own file, under Invidious namespace 2022-02-07 17:15:22 +01:00
Samantaz Fox fb36155022
Move user routes definitions to a macro in routing.cr 2022-02-07 17:15:21 +01:00
Samantaz Fox 7ace3fc989
Move remaining user-related routes out of main file 2022-02-07 17:15:21 +01:00
35個のファイルの変更1391行の追加741行の削除

ファイルの表示

@ -1,7 +1,7 @@
{
"LIVE": "مُباشِر",
"Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`",
"Unsubscribe": "إلغاء الإشتراك",
"Unsubscribe": "إلغاء الاشتراك",
"Subscribe": "الإشتراك",
"View channel on YouTube": "زيارة القناة على موقع يوتيوب",
"View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب",
@ -13,7 +13,7 @@
"Previous page": "الصفحة السابقة",
"Clear watch history?": "هل تريد محو سجل المشاهدة؟",
"New password": "كلمة مرور جديدة",
"New passwords must match": "يَجبُ أن تكون كلمتي المرور متطابقتان",
"New passwords must match": "يَجبُ أن تكون كلمتا المرور متطابقتين",
"Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل",
"Authorize token?": "رمز التفويض؟",
"Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟",
@ -27,8 +27,8 @@
"Import NewPipe subscriptions (.json)": "استيراد اشتراكات نيو بايب (.json)",
"Import NewPipe data (.zip)": "استيراد بيانات نيو بايب (.zip)",
"Export": "تصدير",
"Export subscriptions as OPML": "تصدير الاشتراكات كَ OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "تصدير الاشتراكات كَ OPML (لِنيو بايب و فريتيوب)",
"Export subscriptions as OPML": "تصدير الاشتراكات كـOPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "تصدير الاشتراكات كـOPML (لِنيو بايب و فريتيوب)",
"Export data as JSON": "تصدير البيانات بتنسيق JSON",
"Delete account?": "حذف الحساب؟",
"History": "السِّجل",
@ -47,18 +47,18 @@
"Register": "التسجيل",
"E-mail": "البريد الإلكتروني",
"Google verification code": "رمز تحقق جوجل",
"Preferences": "التفضيلات",
"preferences_category_player": "التفضيلات المُشغِّل",
"Preferences": "الإعدادات",
"preferences_category_player": "إعدادات المُشغِّل",
"preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ",
"preferences_autoplay_label": "تشغيل تلقائي: ",
"preferences_continue_label": "شغل المقطع التالي تلقائيًا: ",
"preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ",
"preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ",
"preferences_local_label": "بروكسي المقاطع المرئيّة؟ ",
"preferences_speed_label": "السرعة الإفتراضية: ",
"preferences_speed_label": "السرعة الافتراضية: ",
"preferences_quality_label": "الجودة المفضلة للمقاطع: ",
"preferences_volume_label": "صوت المشغل: ",
"preferences_comments_label": "التعليقات الإفتراضية: ",
"preferences_comments_label": "التعليقات الافتراضية: ",
"youtube": "يوتيوب",
"reddit": "ريديت",
"preferences_captions_label": "التسميات التوضيحية الإفتراضية: ",
@ -69,41 +69,41 @@
"preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ",
"preferences_category_visual": "التفضيلات المرئية",
"preferences_player_style_label": "شكل مشغل الفيديوهات: ",
"Dark mode: ": "الوضع الليلى: ",
"Dark mode: ": "الوضع الليلي: ",
"preferences_dark_mode_label": "المظهر: ",
"dark": "غامق (اسود)",
"light": "فاتح (ابيض)",
"preferences_thin_mode_label": "الوضع الخفيف: ",
"preferences_category_misc": "تفضيلات متنوعة",
"preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ",
"preferences_category_subscription": "تفضيلات الإشتراك",
"preferences_category_subscription": "تفضيلات الاشتراك",
"preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
"preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
"preferences_sort_label": "ترتيب الفيديو ب: ",
"published": "احدث فيديو",
"published - reverse": "احدث فيديو - عكسى",
"alphabetically": "ترتيب ابجدى",
"alphabetically - reverse": "ابجدى - عكسى",
"channel name": إسم القناة",
"channel name - reverse": إسم القناة - عكسى",
"Only show latest video from channel: ": "فقط إظهر اخر فيديو من القناة: ",
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
"preferences_unseen_only_label": "فقط اظهر الذى لم يتم رؤيتة: ",
"preferences_sort_label": "ترتيب الفيديوهات بـ: ",
"published": "أحدث فيديو",
"published - reverse": "أحدث فيديو - عكسي",
"alphabetically": "ترتيب أبجدي",
"alphabetically - reverse": "أبجدي - عكسي",
"channel name": اسم القناة",
"channel name - reverse": اسم القناة - عكسى",
"Only show latest video from channel: ": "فقط أظهر آخر فيديو من القناة: ",
"Only show latest unwatched video from channel: ": "فقط أظهر آخر فيديو لم يتم رؤيته من القناة: ",
"preferences_unseen_only_label": "فقط أظهر الذي لم يتم رؤيته: ",
"preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
"Enable web notifications": "تفعيل إشعارات المتصفح",
"`x` uploaded a video": "`x` رفع فيديو",
"`x` is live": "`x` فى بث مباشر",
"`x` is live": "`x` في بث مباشر",
"preferences_category_data": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة",
"Import/export data": "إضافة\\إستخراج البيانات",
"Change password": "غير الرقم السرى",
"Manage subscriptions": "إدارة المشتركين",
"Import/export data": "إضافة\\استخراج البيانات",
"Change password": "غير كلمة السر",
"Manage subscriptions": "إدارة الاشتراكات",
"Manage tokens": "إدارة الرموز",
"Watch history": "سجل المشاهدة",
"Delete account": "حذف الحساب",
"preferences_category_admin": "إعدادات المدير",
"preferences_default_home_label": "الصفحة الرئيسية الافتراضية ",
"preferences_default_home_label": "الصفحة الرئيسية الافتراضية: ",
"preferences_feed_menu_label": "قائمة التدفقات: ",
"preferences_show_nick_label": "إظهار اللقب في الأعلى: ",
"Top enabled: ": "تفعيل 'الأفضل' ؟ ",
@ -111,14 +111,14 @@
"Login enabled: ": "تفعيل الولوج: ",
"Registration enabled: ": "تفعيل التسجيل: ",
"Report statistics: ": "الإبلاغ عن الإحصائيات: ",
"Save preferences": "حفظ التفضيلات",
"Subscription manager": "مدير الإشتراكات",
"Save preferences": "حفظ الإعدادات",
"Subscription manager": "مدير الاشتراكات",
"Token manager": "إداره الرمز",
"Token": "الرمز",
"Import/export": "إضافة\\إستخراج",
"unsubscribe": "إلغاء الإشتراك",
"Import/export": "استيراد/تصدير",
"unsubscribe": "إلغاء الاشتراك",
"revoke": "مسح",
"Subscriptions": "الإشتراكات",
"Subscriptions": "الاشتراكات",
"search": "بحث",
"Log out": "تسجيل الخروج",
"Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.",
@ -131,30 +131,30 @@
"Private": "خاص",
"View all playlists": "عرض جميع قوائم التشغيل",
"Updated `x` ago": "تم تحديثه منذ `x`",
"Delete playlist `x`?": "حذف قائمه التشغيل `x` ?",
"Delete playlist": "حذف قائمه التغشيل",
"Create playlist": "إنشاء قائمه تشغيل",
"Delete playlist `x`?": "حذف قائمة التشغيل `x`؟",
"Delete playlist": "حذف قائمة التغشيل",
"Create playlist": "إنشاء قائمة تشغيل",
"Title": "العنوان",
"Playlist privacy": "إعدادات الخصوصيه",
"Editing playlist `x`": "تعديل قائمه التشفيل `x`",
"Show more": "أظهر المزيد",
"Playlist privacy": "إعدادات الخصوصية",
"Editing playlist `x`": "تعديل قائمة التشغيل `x`",
"Show more": "إظهار المزيد",
"Show less": "عرض اقل",
"Watch on YouTube": "مشاهدة الفيديو على اليوتيوب",
"Switch Invidious Instance": "تبديل المثيل Invidious",
"Broken? Try another Invidious Instance": "معطل؟ جرب مثيل Invidious آخر",
"Hide annotations": "إخفاء الملاحظات فى الفيديو",
"Show annotations": "عرض الملاحظات فى الفيديو",
"Hide annotations": "إخفاء الملاحظات في الفيديو",
"Show annotations": "عرض الملاحظات في الفيديو",
"Genre: ": "النوع: ",
"License: ": "التراخيص: ",
"Family friendly? ": "محتوى عائلى? ",
"Family friendly? ": "محتوى عائلي؟ ",
"Wilson score: ": "درجة ويلسون: ",
"Engagement: ": "نسبة المشاركة (عدد المشاهدات\\عدد الإعجابات): ",
"Engagement: ": "نسبة المشاركة: ",
"Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ",
"Blacklisted regions: ": "الدول الحظور فيها هذا الفيديو: ",
"Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ",
"Shared `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.": "أهلًا! يبدو ان جافاسكريبت معطلة لديك. اضغط هنا لعرض التعليقات، وضع فى اعتبارك أنها ستأخذ وقت أطول للعرض.",
"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 more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
"View `x` comments": {
@ -368,7 +368,7 @@
"adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة",
"footer_documentation": "التوثيق",
"footer_donate_page": "تبرّع",
"preferences_region_label": "بلد المحتوى:. ",
"preferences_region_label": "بلد المحتوى: ",
"preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ",
"preferences_quality_option_dash": "DASH (جودة تكييفية)",
"preferences_quality_option_hd720": "HD720",
@ -417,5 +417,17 @@
"generic_views_count_2": "مشاهدتان",
"generic_views_count_3": "{{count}} مشاهدات",
"generic_views_count_4": "{{count}} مشاهدة",
"generic_views_count_5": "{{count}} مشاهدة"
"generic_views_count_5": "{{count}} مشاهدة",
"generic_subscriptions_count_0": "لا اشتراكات",
"generic_subscriptions_count_1": "اشتراك واحد",
"generic_subscriptions_count_2": "اشتراكان",
"generic_subscriptions_count_3": "{{count}} اشتراكات",
"generic_subscriptions_count_4": "{{count}} اشتراك",
"generic_subscriptions_count_5": "{{count}} اشتراك",
"generic_playlists_count_0": "لا قوائم تشغيل",
"generic_playlists_count_1": "قائمة تشغيل واحدة",
"generic_playlists_count_2": "قائمتا تشغيل",
"generic_playlists_count_3": "{{count}} قوائم تشغيل",
"generic_playlists_count_4": "{{count}} قائمة تشغيل",
"generic_playlists_count_5": "{{count}} قائمة تشغيل"
}

ファイルの表示

@ -21,15 +21,15 @@
"No": "No",
"Import and Export Data": "Importación y exportación de datos",
"Import": "Importar",
"Import Invidious data": "Importar datos de Invidious",
"Import YouTube subscriptions": "Importar suscripciones de YouTube",
"Import Invidious data": "Importar datos JSON de Invidious",
"Import YouTube subscriptions": "Importar suscripciones de YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)",
"Export": "Exportar",
"Export subscriptions as OPML": "Exportar suscripciones como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar suscripciones como OPML (para NewPipe y FreeTube)",
"Export data as JSON": "Exportar datos como JSON",
"Export data as JSON": "Exportar datos de Invidious como JSON",
"Delete account?": "¿Quiere borrar la cuenta?",
"History": "Historial",
"An alternative front-end to YouTube": "Una interfaz alternativa para YouTube",

ファイルの表示

@ -456,7 +456,7 @@
"English (United Kingdom)": "Anglais (Royaume-Uni)",
"Chinese (Taiwan)": "Chinois (Taiwan)",
"Cantonese (Hong Kong)": "Cantonais (Hong Kong)",
"Interlingue": "Interlingua",
"Interlingue": "Occidental",
"Italian (auto-generated)": "Italien (auto-généré)",
"Vietnamese (auto-generated)": "Vietnamien (auto-généré)",
"Russian (auto-generated)": "Russe (auto-généré)",

ファイルの表示

@ -21,8 +21,8 @@
"No": "Nei",
"Import and Export Data": "Importer- og eksporter data",
"Import": "Importer",
"Import Invidious data": "Importer Invidious-data",
"Import YouTube subscriptions": "Importer YouTube-abonnementer",
"Import Invidious data": "Importer Invidious-JSON-data",
"Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",

ファイルの表示

@ -21,15 +21,15 @@
"No": "Нет",
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать данные Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube",
"Import Invidious data": "Импортировать JSON с данными Invidious",
"Import YouTube subscriptions": "Импортировать подписки из YouTube/OPML",
"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",
"Export data as JSON": "Экспортировать данные Invidious в формате JSON",
"Delete account?": "Удалить аккаунт?",
"History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
@ -66,7 +66,7 @@
"preferences_related_videos_label": "Показывать похожие видео? ",
"preferences_annotations_label": "Всегда показывать аннотации? ",
"preferences_extend_desc_label": "Автоматически раскрывать описание видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ",
"preferences_category_visual": "Настройки сайта",
"preferences_player_style_label": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ",
@ -75,7 +75,7 @@
"light": "светлая",
"preferences_thin_mode_label": "Облегчённое оформление: ",
"preferences_category_misc": "Прочие предпочтения",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ",
"preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (резервный вариант redirect.invidious.io): ",
"preferences_category_subscription": "Настройки подписок",
"preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
@ -361,5 +361,120 @@
"next_steps_error_message_refresh": "Обновить",
"next_steps_error_message_go_to_youtube": "Перейти на YouTube",
"short": "Короткие (< 4 минут)",
"long": "Длинные (> 20 минут)"
"long": "Длинные (> 20 минут)",
"preferences_quality_dash_option_best": "Наилучшее",
"generic_count_weeks_0": "{{count}} неделя",
"generic_count_weeks_1": "{{count}} недели",
"generic_count_weeks_2": "{{count}} недель",
"English (United Kingdom)": "Английский (Великобритания)",
"English (United States)": "Английский (США)",
"Cantonese (Hong Kong)": "Кантонский (Гонконг)",
"Chinese (Taiwan)": "Китайский (Тайвань)",
"Dutch (auto-generated)": "Голландский (автоматический)",
"German (auto-generated)": "Немецкий (автоматический)",
"Indonesian (auto-generated)": "Индонезийский (автоматический)",
"Italian (auto-generated)": "Итальянский (автоматический)",
"Interlingue": "Интерлингва",
"Russian (auto-generated)": "Русский (автоматический)",
"Spanish (auto-generated)": "Испанский (автоматический)",
"Spanish (Spain)": "Испанский (Испания)",
"Turkish (auto-generated)": "Турецкий (автоматический)",
"Vietnamese (auto-generated)": "Вьетнамский (автоматический)",
"footer_documentation": "Документация",
"adminprefs_modified_source_code_url_label": "Ссылка на нашу ветку репозитория",
"none": "ничего",
"videoinfo_watch_on_youTube": "Смотреть на YouTube",
"videoinfo_youTube_embed_link": "Встраиваемый элемент",
"videoinfo_invidious_embed_link": "Встраиваемая ссылка",
"download_subtitles": "Субтитры - `x` (.vtt)",
"user_created_playlists": "`x` созданных плейлистов",
"crash_page_you_found_a_bug": "Похоже вы нашли баг в Invidious!",
"crash_page_before_reporting": "Прежде чем сообщать об ошибке, убедитесь, что вы:",
"crash_page_refresh": "пробовали <a href=\"`x`\"> перезагрузить страницу</a>",
"crash_page_report_issue": "Если ни один вариант не помог, пожалуйста <a href=\"`x`\">откройте новую проблему на GitHub</a> (желательно на английском) и приложите следующий текст к вашему сообщению (НЕ переводите его):",
"generic_videos_count_0": "{{count}} видео",
"generic_videos_count_1": "{{count}} видео",
"generic_videos_count_2": "{{count}} видео",
"generic_playlists_count_0": "{{count}} плейлист",
"generic_playlists_count_1": "{{count}} плейлиста",
"generic_playlists_count_2": "{{count}} плейлистов",
"tokens_count_0": "{{count}} токен",
"tokens_count_1": "{{count}} токена",
"tokens_count_2": "{{count}} токенов",
"subscriptions_unseen_notifs_count_0": "{{count}} новое уведомление",
"subscriptions_unseen_notifs_count_1": "{{count}} новых уведомления",
"subscriptions_unseen_notifs_count_2": "{{count}} новых уведомлений",
"comments_view_x_replies_0": "{{count}} ответ",
"comments_view_x_replies_1": "{{count}} ответа",
"comments_view_x_replies_2": "{{count}} ответов",
"generic_count_years_0": "{{count}} год",
"generic_count_years_1": "{{count}} года",
"generic_count_years_2": "{{count}} лет",
"generic_count_minutes_0": "{{count}} минута",
"generic_count_minutes_1": "{{count}} минуты",
"generic_count_minutes_2": "{{count}} минут",
"generic_subscribers_count_0": "{{count}} подписчик",
"generic_subscribers_count_1": "{{count}} подписчика",
"generic_subscribers_count_2": "{{count}} подписчиков",
"generic_views_count_0": "{{count}} просмотр",
"generic_views_count_1": "{{count}} просмотра",
"generic_views_count_2": "{{count}} просмотров",
"French (auto-generated)": "Французский (автоматический)",
"Portuguese (auto-generated)": "Португальский (автоматический)",
"generic_count_days_0": "{{count}} день",
"generic_count_days_1": "{{count}} дня",
"generic_count_days_2": "{{count}} дней",
"preferences_quality_dash_option_auto": "Автоматическое",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"generic_subscriptions_count_0": "{{count}} подписка",
"generic_subscriptions_count_1": "{{count}} подписки",
"generic_subscriptions_count_2": "{{count}} подписок",
"preferences_quality_option_small": "Низкое",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"generic_count_seconds_0": "{{count}} секунда",
"generic_count_seconds_1": "{{count}} секунды",
"generic_count_seconds_2": "{{count}} секунд",
"purchased": "Приобретено",
"videoinfo_started_streaming_x_ago": "Трансляция началась `x` назад",
"crash_page_switch_instance": "пробовали <a href=\"`x`\">использовать другое зеркало</a>",
"crash_page_read_the_faq": "прочли <a href=\"`x`\">Частые Вопросы (ЧаВо)</a>",
"Chinese": "Китайский",
"Chinese (Hong Kong)": "Китайский (Гонконг)",
"Japanese (auto-generated)": "Японский (автоматический)",
"Chinese (China)": "Китайский (Китай)",
"Korean (auto-generated)": "Корейский (автоматический)",
"generic_count_months_0": "{{count}} месяц",
"generic_count_months_1": "{{count}} месяца",
"generic_count_months_2": "{{count}} месяцев",
"generic_count_hours_0": "{{count}} час",
"generic_count_hours_1": "{{count}} часа",
"generic_count_hours_2": "{{count}} часов",
"Portuguese (Brazil)": "Португальский (Бразилия)",
"footer_source_code": "Исходный код",
"footer_original_source_code": "Оригинальный исходный код",
"footer_modfied_source_code": "Изменённый исходный код",
"user_saved_playlists": "`x` сохранённых плейлистов",
"crash_page_search_issue": "искали <a href=\"`x`\">похожую проблему на Github</a>",
"comments_points_count_0": "{{count}} плюс",
"comments_points_count_1": "{{count}} плюса",
"comments_points_count_2": "{{count}} плюсов",
"Spanish (Mexico)": "Испанский (Мексика)",
"footer_donate_page": "Поддержать проект",
"preferences_quality_option_dash": "DASH (автоматическое качество)",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Среднее",
"preferences_quality_dash_label": "Предпочтительное автоматическое качество видео: ",
"preferences_quality_dash_option_worst": "Очень низкое",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"360": "360°",
"Video unavailable": "Видео недоступно",
"preferences_save_player_pos_label": "Запоминать позицию: ",
"preferences_region_label": "Страна: "
}

ファイルの表示

@ -1 +1,383 @@
{}
{
"Albanian": "Shqip",
"Amharic": "Amharike",
"Arabic": "Arabisht",
"Armenian": "Armenisht",
"Gujarati": "Gujaratase",
"Haitian Creole": "Kreolase Haiti",
"Hausa": "Hausisht",
"Hawaiian": "Havajane",
"Hebrew": "Hebraisht",
"Hindi": "Indiane",
"Hungarian": "Hungarisht",
"Icelandic": "Islandisht",
"Igbo": "Igboisht",
"Irish": "Irlandisht",
"Javanese": "Xhavanisht",
"Kazakh": "Kazake",
"Khmer": "Khmere",
"Korean": "Koreane",
"Kurdish": "Kurdisht",
"Kyrgyz": "Kirgizisht",
"Sundanese": "Sundaneze",
"Swahili": "Suahilisht",
"Swedish": "Suedisht",
"Tajik": "Taxhike",
"Tamil": "Tamilisht",
"Telugu": "Telugu",
"Vietnamese": "Vietnamisht",
"creative_commons": "Creative Commons",
"3d": "3D",
"live": "Drejtpërsëdrejti",
"4k": "4K",
"location": "Vendndodhja",
"videoinfo_watch_on_youTube": "Shiheni në YouTube",
"videoinfo_youTube_embed_link": "Trupëzojeni",
"videoinfo_invidious_embed_link": "Lidhje Trupëzimi",
"oldest": "më të vjetrat",
"Cannot change password for Google accounts": "Smund të ndryshojë fjalëkalimin për llogari Google",
"New passwords must match": "Fjalëkalimet e rinj duhet të përputhen me njëri-tjetrin",
"Authorize token?": "Të autorizohet token-i?",
"Authorize token for `x`?": "Të autorizohet token-i për `x`?",
"Log in/register": "Hyni/regjistrohuni",
"Log in with Google": "Hyni me Google",
"User ID": "ID Përdoruesi",
"Password": "Fjalëkalim",
"Time (h:mm:ss):": "Kohë (h:mm:ss):",
"Text CAPTCHA": "CAPTCHA Tekst",
"Image CAPTCHA": "CAPTCHA Figurë",
"Sign In": "Hyni",
"Register": "Regjistrohuni",
"E-mail": "Email",
"Preferences": "Parapëlqime",
"preferences_category_player": "Parapëlqime Lojtësi",
"preferences_autoplay_label": "Vetëluaje: ",
"preferences_continue_label": "Luaj pasuesen, si parazgjedhje: ",
"preferences_continue_autoplay_label": "Vetëluaj videon pasuese: ",
"preferences_listen_label": "Si parazgjedhje, dëgjojeni me: ",
"preferences_speed_label": "Shpejtësi parazgjedhje: ",
"preferences_quality_label": "Cilësi e parapëlqyer për videot: ",
"preferences_quality_option_dash": "DASH (cilësi që përshtatet)",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "Mesatare",
"preferences_quality_option_small": "E ulët",
"preferences_quality_dash_label": "Cilësi DASH e parapëlqyer për videot: ",
"preferences_quality_dash_option_auto": "Auto",
"preferences_quality_dash_option_best": "Më e mira",
"preferences_quality_dash_option_worst": "Më e keqja",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"preferences_volume_label": "Volum lojtësi: ",
"preferences_comments_label": "Komente parazgjedhje: ",
"youtube": "YouTube",
"reddit": "Reddit",
"invidious": "Invidious",
"preferences_captions_label": "Titra parazgjedhje: ",
"preferences_extend_desc_label": "Zgjero automatikisht përshkrimin e videos: ",
"preferences_player_style_label": "Silt lojtësi: ",
"Dark mode: ": "Mënyra e errët: ",
"preferences_dark_mode_label": "Temë: ",
"dark": "e errët",
"light": "e çelët",
"preferences_thin_mode_label": "Mënyrë e hollë: ",
"preferences_category_misc": "Parapëlqime të ndryshme",
"preferences_automatic_instance_redirect_label": "Ridrejtim i automatizuar i instancës (si parazgjedhje, te redirect.invidious.io): ",
"preferences_category_subscription": "Parapëlqime pajtimesh",
"preferences_annotations_subscribed_label": "Të shfaqen, si parazgjedhje, shënime për kanalet e pajtuar? ",
"Redirect homepage to feed: ": "Ridrejtoje faqen hyrëse te prurje: ",
"preferences_max_results_label": "Numër videosh të shfaqura në prurje: ",
"preferences_sort_label": "Renditi videot sipas: ",
"published": "e publikuar",
"alphabetically": "alfabetikisht",
"alphabetically - reverse": "alfabetikisht - së prapthi",
"channel name": "emër kanali",
"Only show latest video from channel: ": "Shfaq vetëm videot më të reja nga kanali: ",
"Only show latest unwatched video from channel: ": "Shfaq vetëm videot më të reja të papara në kanal: ",
"preferences_unseen_only_label": "Shfaq vetëm të paparat: ",
"preferences_notifications_only_label": "Shfaq vetëm njoftime (nëse ka të tilla): ",
"Enable web notifications": "Aktivizoni njoftime web",
"`x` uploaded a video": "`x` ngarkoi një video",
"`x` is live": "`x` funksionon",
"preferences_category_data": "Parapëlqime për të dhënat",
"Clear watch history": "Spastro historik parjesh",
"Import/export data": "Importoni/eksportoni të dhëna",
"Change password": "Ndryshoni fjalëkalimin",
"Manage subscriptions": "Administroni pajtimet",
"Manage tokens": "Administroni token-ë",
"Watch history": "Shihni historikun",
"Delete account": "Fshije llogarinë",
"preferences_category_admin": "Parapëlqime përgjegjësi",
"preferences_default_home_label": "Faqe hyrëse parazgjedhje: ",
"preferences_feed_menu_label": "Menu prurjesh: ",
"Registration enabled: ": "Regjistrim i aktivizuar: ",
"Save preferences": "Ruaji parapëlqimet",
"Token": "Token",
"Subscription manager": "Përgjegjës pajtimesh",
"Token manager": "Përgjegjës token-ësh",
"Import/export": "Importim/eksportim",
"unsubscribe": "shpajtohuni",
"revoke": "shfuqizoje",
"Subscriptions": "Pajtime",
"search": "kërko",
"Log out": "Dilni",
"Released under the AGPLv3 on Github.": "Hedhur në qarkullim në Github sipas licencës AGPLv3.",
"Source available here.": "Burimi i passhëm që këtu.",
"View JavaScript license information.": "Shihni hollësi licence JavaScript.",
"View privacy policy.": "Shihni rregulla privatësie.",
"Trending": "Në modë",
"Public": "Publike",
"Unlisted": "Jo në listë",
"Private": "Private",
"View all playlists": "Shihni krejt luajlistat",
"Updated `x` ago": "Përditësuar `x` më parë",
"Delete playlist": "Fshije luajlistën",
"Delete playlist `x`?": "Të fshihet luajlista `x`?",
"Create playlist": "Krijoni luajlistë",
"Title": "Titull",
"Playlist privacy": "Privatësi luajliste",
"Editing playlist `x`": "Po përpunohet luajlista `x`",
"Show more": "Shfaq më tepër",
"Show less": "Shfaq më pak",
"Watch on YouTube": "Shiheni në YouTube",
"Switch Invidious Instance": "Ndërroni Instancë Invidious",
"Broken? Try another Invidious Instance": "E prishur? Provoni një tjetër Instancë Invidious",
"Hide annotations": "Fshihi shënimet",
"Show annotations": "Shfaq shënime",
"License: ": "Licencë: ",
"Family friendly? ": "E përshtatshme për familje? ",
"Wilson score: ": "Klasifikim Wilson: ",
"Engagement: ": "Angazhim: ",
"Whitelisted regions: ": "Rajone të lejuara: ",
"Premieres `x`": "Premiera `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.": "Njatjeta! Duket sikur keni JavaScript-in të çaktivizuar. Klikoni këtu që të shihni komentet, mbani parasysh se mund të duhet pak më tepër kohë që të ngarkohen.",
"Quota exceeded, try again in a few hours": "Janë tejkaluar kuotat, riprovoni pas pak orësh",
"Blacklisted regions: ": "Rajone të palejuara: ",
"Premieres in `x`": "Premiera në `x`",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sarrihet të bëhet hyrja, sigurohuni se mirëfilltësimi dyfaktorësh (me Mirëfilltësues apo SMS) është i aktivizuar.",
"Wrong answer": "Përgjigje e gabuar",
"Invalid TFA code": "Kod MDF i pavlefshëm",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Dështoi hyrja. Kjo mund të vijë ngaqë për llogarinë tuaj sështë aktivizuar mirëfilltësimi dyfaktorësh.",
"Erroneous CAPTCHA": "CAPTCHA e gabuar",
"CAPTCHA is a required field": "CAPTCHA është fushë e domosdoshme",
"User ID is a required field": "ID-ja e përdoruesit është fushë e domosdoshme",
"Password is a required field": "Fusha e fjalëkalimit është e domosdoshme",
"Wrong username or password": "Emër përdoruesi ose fjalëkalim i gabuar",
"Please sign in using 'Log in with Google'": "Ju lutemi, bëni hyrjen duke përdorur “Bëni hyrjen me Google”",
"Password cannot be empty": "Fjalëkalimi smund të jetë i zbrazët",
"Password cannot be longer than 55 characters": "Fjalëkalimi smund të jetë më i gjatë se 55 shenja",
"Please log in": "Ju lutemi, bëni hyrjen",
"Invidious Private Feed for `x`": "Prurje Private Invidious për `x`",
"channel:`x`": "kanal:`x`",
"Deleted or invalid channel": "Kanal i fshirë ose i pavlefshëm",
"This channel does not exist.": "Ky kanal sekziston.",
"Could not get channel info.": "Su morën dot hollësi kanali.",
"Could not fetch comments": "Su sollën dot komente",
"`x` ago": "`x` më parë",
"Load more": "Ngarko më tepër",
"Empty playlist": "Luajlistë e zbrazët",
"Not a playlist.": "Sështë luajlistë.",
"Playlist does not exist.": "Luajlista sekziston.",
"Hidden field \"challenge\" is a required field": "Fusha e fshehur “challenge” është fushë e domosdoshme",
"Hidden field \"token\" is a required field": "Fusha e fshehur “token” është fushë e domosdoshme",
"Erroneous token": "Token i gabuar",
"No such user": "Ska përdorues të tillë",
"Token is expired, please try again": "Token-i ka skaduar, ju lutemi, riprovoni",
"English": "Anglisht",
"English (auto-generated)": "Anglisht (të vetë-prodhuara)",
"Afrikaans": "Afrikaans",
"Azerbaijani": "Azerbajxhanase",
"Bangla": "Bangla",
"Basque": "Baske",
"Burmese": "Burmanisht",
"Catalan": "Katalane",
"Belarusian": "Bjellorusisht",
"Bosnian": "Boshnjake",
"Bulgarian": "Bullgarisht",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Kineze (E thjeshtuar)",
"Chinese (Traditional)": "Kineze (Tradicionale)",
"Corsican": "Korsikanisht",
"Croatian": "Kroatisht",
"Czech": "Çekisht",
"Danish": "Danisht",
"Dutch": "Holandisht",
"Esperanto": "Esperanto",
"Estonian": "Estonisht",
"Filipino": "Filipineze",
"Finnish": "Finlandisht",
"French": "Frëngjisht",
"Galician": "Galicisht",
"Georgian": "Gjeorgjisht",
"German": "Gjermanisht",
"Greek": "Greqisht",
"Indonesian": "Indonezisht",
"Italian": "Italisht",
"Japanese": "Japonisht",
"Lao": "Laosisht",
"Lithuanian": "Lituanisht",
"Luxembourgish": "Luksemburgisht",
"Latin": "Latinisht",
"Latvian": "Letonisht",
"Macedonian": "Maqedonisht",
"Nyanja": "Nianja",
"Pashto": "Pashtune",
"Persian": "Perisht",
"Polish": "Polonisht",
"Portuguese": "Portugalisht",
"Punjabi": "Panxhabe",
"Romanian": "Rumanisht",
"Russian": "Rusisht",
"Samoan": "Samoanisht",
"Scottish Gaelic": "Galike Skoceze",
"Serbian": "Serbisht",
"Shona": "Shonisht",
"Sindhi": "Sindi",
"Sinhala": "Sinhaleze",
"Slovak": "Slovakisht",
"Slovenian": "Sllovenisht",
"Somali": "Somalisht",
"Southern Sotho": "Sotoishte Jugore",
"Spanish": "Spanjisht",
"Spanish (Latin America)": "Spanjisht (Amerikë Latine)",
"Thai": "Tajlandeze",
"Turkish": "Turqisht",
"Ukrainian": "Ukrainase",
"Urdu": "Urdisht",
"Uzbek": "Uzbeke",
"Welsh": "Uellase",
"Western Frisian": "Frizishte Perëndimore",
"Xhosa": "Xhosa",
"Yiddish": "Jidisht",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(u përpunua)",
"YouTube comment permalink": "Permalidhje komenti YouTube",
"Audio mode": "Mënyrë për audion",
"Playlists": "Luajlista",
"Community": "Bashkësi",
"relevance": "Rëndësi",
"Video mode": "Mënyrë video",
"Videos": "Video",
"rating": "Vlerësim",
"date": "Datë ngarkimi",
"views": "Numër parjesh",
"content_type": "Lloj",
"duration": "Kohëzgjatje",
"features": "Veçori",
"sort": "Renditi Sipas",
"hour": "Orën e Fundit",
"today": "Sot",
"long": "E gjatë (> 20 minuta)",
"hd": "HD",
"subtitles": "Titra/CC",
"hdr": "HDR",
"week": "Këtë javë",
"month": "Këtë muaj",
"year": "Këtë vit",
"video": "Video",
"channel": "Kanal",
"playlist": "Luajlistë",
"movie": "Film",
"show": "Shfaqe",
"short": "E shkurtër (< 4 minuta)",
"purchased": "Të blera",
"footer_modfied_source_code": "Kod Burim i ndryshuar",
"adminprefs_modified_source_code_url_label": "URL e depos së ndryshuar të kodit burim",
"none": "asnjë",
"videoinfo_started_streaming_x_ago": "Filloi transmetimin `x` më parë",
"LIVE": "DREJTPËRSËDREJTI",
"Shared `x` ago": "Ndarë me të tjerë `x` më parë",
"Unsubscribe": "Shpajtohuni",
"Subscribe": "Pajtomë",
"View channel on YouTube": "Shihni kanalin në YouTube",
"View playlist on YouTube": "Shihni luajlistën në YouTube",
"newest": "më të rejat",
"popular": "popullore",
"last": "e fundit",
"Next page": "Faqja pasuese",
"Previous page": "Faqja e mëparshme",
"Clear watch history?": "Të spastrohet historiku i parjeve?",
"New password": "Fjalëkalim i ri",
"Google verification code": "Kod verifikimi Google",
"preferences_related_videos_label": "Shfaq video të afërta: ",
"preferences_annotations_label": "Si parazgjedhje, shfaqi shënimet: ",
"preferences_show_nick_label": "Shfaqe nofkën në krye: ",
"CAPTCHA enabled: ": "Me CAPTCHA të aktivizuar: ",
"Login enabled: ": "Me hyrjen të aktivizuar: ",
"Genre: ": "Zhanër: ",
"Could not create mix.": "Su krijua dot përzierja.",
"Yoruba": "Jorubaisht",
"Zulu": "Zulu",
"Popular": "Popullore",
"Search": "Kërko",
"About": "Mbi",
"Rating: ": "Vlerësim: ",
"preferences_locale_label": "Gjuhë: ",
"View as playlist": "Shiheni si luajlistë",
"Default": "Parazgjedhje",
"Music": "Muzikë",
"Gaming": "Lojëra",
"News": "Lajme",
"Movies": "Filma",
"Download": "Shkarkoje",
"Download as: ": "Shkarkoje si: ",
"permalink": "permalidhje",
"`x` marked it with a ❤": "`x` i është vënë një ❤",
"download_subtitles": "Titra - `x` (.vtt)",
"user_created_playlists": "`x` krijoi luajlista",
"user_saved_playlists": "`x` ruajti luajlista",
"Video unavailable": "Video jo e passhme",
"Yes": "Po",
"No": "Jo",
"Import and Export Data": "Importoni dhe Eksportoni të Dhëna",
"Import": "Importo",
"Import FreeTube subscriptions (.db)": "Importoni pajtime FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importoni pajtime NewPipe (.json)",
"Import NewPipe data (.zip)": "Importoni të dhëna NewPipe (.zip)",
"Export": "Eksporto",
"Export subscriptions as OPML": "Eksportoni pajtime si OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportoji pajtimet si OPML (për NewPipe & FreeTube)",
"Delete account?": "Të fshihet llogaria?",
"History": "Historik",
"An alternative front-end to YouTube": "Një front-end alternativ për YouTube-in",
"JavaScript license information": "Hollësi licence JavaScript",
"source": "burim",
"Log in": "Hyni",
"preferences_category_visual": "Parapëlqime pamore",
"preferences_region_label": "Vend lënde: ",
"View YouTube comments": "Shihni komente Youtube",
"View more comments on Reddit": "Shihni më tepër komente në Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` komente",
"": "Shihni `x` komente"
},
"View Reddit comments": "Shihni komente Reddit",
"Hide replies": "Fshihi përgjigjet",
"Show replies": "Shfaq përgjigje",
"Incorrect password": "Fjalëkalim i pasaktë",
"Malagasy": "Malagashe",
"Malay": "Malajase",
"Malayalam": "Malajalamase",
"Maltese": "Maltisht",
"Maori": "Maori",
"Marathi": "Marati",
"Mongolian": "Mongolisht",
"Nepali": "Nepaleze",
"Norwegian Bokmål": "Norvegjishte Bokmål",
"360": "360°",
"filter": "Filtroji",
"Current version: ": "Versioni i tanishëm: ",
"next_steps_error_message": "Pas të cilës duhet të provoni të: ",
"next_steps_error_message_refresh": "Rifreskoje",
"next_steps_error_message_go_to_youtube": "Kaloni në Youtube",
"footer_donate_page": "Dhuroni",
"footer_documentation": "Dokumentim",
"footer_source_code": "Kod burim",
"footer_original_source_code": "Kodim burim origjinal"
}

ファイルの表示

@ -21,15 +21,15 @@
"No": "Hayır",
"Import and Export Data": "Verileri İçe ve Dışa Aktar",
"Import": "İçe aktar",
"Import Invidious data": "İnvidious verilerini içe aktar",
"Import YouTube subscriptions": "YouTube aboneliklerini içe aktar",
"Import Invidious data": "İnvidious JSON verilerini içe aktar",
"Import YouTube subscriptions": "YouTube/OPML aboneliklerini içe aktar",
"Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)",
"Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)",
"Export": "Dışa aktar",
"Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)",
"Export data as JSON": "Verileri JSON olarak dışa aktar",
"Export data as JSON": "Invidious verilerini JSON olarak dışa aktar",
"Delete account?": "Hesap silinsin mi?",
"History": "Geçmiş",
"An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz",

ファイルの表示

@ -26,15 +26,15 @@
"No": "否",
"Import and Export Data": "导入与导出数据",
"Import": "导入",
"Import Invidious data": "导入 Invidious 数据",
"Import YouTube subscriptions": "导入 YouTube 订阅",
"Import Invidious data": "导入 Invidious JSON 数据",
"Import YouTube subscriptions": "导入 YouTube/OPML 订阅",
"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 格式",
"Export data as JSON": "导出 Invidious 数据为 JSON 格式",
"Delete account?": "删除账户?",
"History": "历史",
"An alternative front-end to YouTube": "另一个 YouTube 前端",

ファイルの表示

@ -26,15 +26,15 @@
"No": "否",
"Import and Export Data": "匯入與匯出資料",
"Import": "匯入",
"Import Invidious data": "匯入 Invidious 資料",
"Import YouTube subscriptions": "匯入 YouTube 訂閱",
"Import Invidious data": "匯入 Invidious JSON 資料",
"Import YouTube subscriptions": "匯入 YouTube/OPML 訂閱",
"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",
"Export data as JSON": "將 Invidious 資料匯出為 JSON",
"Delete account?": "刪除帳號?",
"History": "歷史",
"An alternative front-end to YouTube": "一個 YouTube 的替代前端",

ファイルの表示

@ -25,9 +25,9 @@ def csv_sample
CSV
end
Spectator.describe "Invidious::User::Imports" do
Spectator.describe Invidious::User::Import do
it "imports CSV" do
subscriptions = parse_subscription_export_csv(csv_sample)
subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample)
expect(subscriptions).to be_an(Array(String))
expect(subscriptions.size).to eq(13)

ファイルの表示

@ -38,14 +38,13 @@ require "./invidious/jobs/**"
CONFIG = Config.load
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
PG_DB = DB.open CONFIG.database_url
ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
YT_URL = URI.parse("https://www.youtube.com")
HOST_URL = make_host_url(Kemal.config)
PG_DB = DB.open CONFIG.database_url
ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com")
HOST_URL = make_host_url(Kemal.config)
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@ -366,15 +365,8 @@ end
Invidious::Routing.get "/results", Invidious::Routes::Search, :results
Invidious::Routing.get "/search", Invidious::Routes::Search, :search
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
Invidious::Routing.post "/login", Invidious::Routes::Login, :login
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
# User routes
define_user_routes()
# Feeds
Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
@ -414,325 +406,6 @@ define_v1_api_routes()
define_api_manifest_routes()
define_video_playback_routes()
get "/change_password" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
templated "change_password"
end
post "/change_password" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
# We don't store passwords for Google accounts
if !user.password
next error_template(400, "Cannot change password for Google accounts")
end
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
password = env.params.body["password"]?
if !password
next error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
next error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
next error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
next error_template(400, "Password cannot be longer than 55 characters")
end
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
next error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
Invidious::Database::Users.update_password(user, new_password.to_s)
env.redirect referer
end
get "/delete_account" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "delete_account"
end
post "/delete_account" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
env.redirect referer
end
get "/clear_watch_history" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "clear_watch_history"
end
post "/clear_watch_history" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
env.redirect referer
end
get "/authorize_token" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
callback_url = env.params.query["callback_url"]?
if callback_url
callback_url = URI.parse(callback_url)
end
expire = env.params.query["expire"]?.try &.to_i?
templated "authorize_token"
end
post "/authorize_token" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
next env.redirect referer
end
user = env.get("user").as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = access_token
url.query = query.to_s
env.redirect url.to_s
else
csrf_token = ""
env.set "access_token", access_token
templated "authorize_token"
end
end
get "/token_manager" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, "/subscription_manager")
if !user
next env.redirect referer
end
user = user.as(User)
tokens = Invidious::Database::SessionIDs.select_all(user.email)
templated "token_manager"
end
post "/token_ajax" do |env|
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
redirect = env.params.query["redirect"]?
redirect ||= "true"
redirect = redirect == "true"
if !user
if redirect
next env.redirect referer
else
next error_json(403, "No such user")
end
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
next error_template(400, ex)
else
next error_json(400, ex)
end
end
if env.params.query["action_revoke_token"]?
action = "action_revoke_token"
else
next env.redirect referer
end
session = env.params.query["session"]?
session ||= ""
case action
when .starts_with? "action_revoke_token"
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
next error_json(400, "Unsupported action #{action}")
end
if redirect
env.redirect referer
else
env.response.content_type = "application/json"
"{}"
end
end
# Channels
{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route|
@ -876,7 +549,7 @@ add_handler AuthHandler.new
add_handler DenyFrame.new
add_context_storage_type(Array(String))
add_context_storage_type(Preferences)
add_context_storage_type(User)
add_context_storage_type(Invidious::User)
Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding

ファイルの表示

@ -78,7 +78,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
when "RELOAD_CONTINUATION_SLOT_HEADER"
header = item["reloadContinuationItemsCommand"]["continuationItems"][0]
when "RELOAD_CONTINUATION_SLOT_BODY"
contents = item["reloadContinuationItemsCommand"]["continuationItems"]
# continuationItems is nil when video has no comments
contents = item["reloadContinuationItemsCommand"]["continuationItems"]?
end
elsif item["appendContinuationItemsAction"]?
contents = item["appendContinuationItemsAction"]["continuationItems"]

358
src/invidious/routes/account.cr ノーマルファイル
ファイルの表示

@ -0,0 +1,358 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::Account
extend self
# -------------------
# Password update
# -------------------
# Show the password change interface (GET request)
def get_change_password(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
templated "user/change_password"
end
# Handle the password change (POST request)
def post_change_password(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
# We don't store passwords for Google accounts
if !user.password
return error_template(400, "Cannot change password for Google accounts")
end
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
password = env.params.body["password"]?
if !password
return error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
return error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
return error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
return error_template(400, "Password cannot be longer than 55 characters")
end
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
return error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
Invidious::Database::Users.update_password(user, new_password.to_s)
env.redirect referer
end
# -------------------
# Account deletion
# -------------------
# Show the account deletion confirmation prompt (GET request)
def get_delete(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "user/delete_account"
end
# Handle the account deletion (POST request)
def post_delete(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
env.redirect referer
end
# -------------------
# Clear history
# -------------------
# Show the watch history deletion confirmation prompt (GET request)
def get_clear_history(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "user/clear_watch_history"
end
# Handle the watch history clearing (POST request)
def post_clear_history(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
env.redirect referer
end
# -------------------
# Authorize tokens
# -------------------
# Show the "authorize token?" confirmation prompt (GET request)
def get_authorize_token(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
callback_url = env.params.query["callback_url"]?
if callback_url
callback_url = URI.parse(callback_url)
end
expire = env.params.query["expire"]?.try &.to_i?
templated "user/authorize_token"
end
# Handle token authorization (POST request)
def post_authorize_token(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = env.get("user").as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
url = URI.parse(callback_url)
if url.query
query = HTTP::Params.parse(url.query.not_nil!)
else
query = HTTP::Params.new
end
query["token"] = access_token
url.query = query.to_s
env.redirect url.to_s
else
csrf_token = ""
env.set "access_token", access_token
templated "user/authorize_token"
end
end
# -------------------
# Manage tokens
# -------------------
# Show the token manager page (GET request)
def token_manager(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, "/subscription_manager")
if !user
return env.redirect referer
end
user = user.as(User)
tokens = Invidious::Database::SessionIDs.select_all(user.email)
templated "user/token_manager"
end
# -------------------
# AJAX for tokens
# -------------------
# Handle internal (non-API) token actions (POST request)
def token_ajax(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
redirect = env.params.query["redirect"]?
redirect ||= "true"
redirect = redirect == "true"
if !user
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
end
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
else
return error_json(400, ex)
end
end
if env.params.query["action_revoke_token"]?
action = "action_revoke_token"
else
return env.redirect referer
end
session = env.params.query["session"]?
session ||= ""
case action
when .starts_with? "action_revoke_token"
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
return error_json(400, "Unsupported action #{action}")
end
if redirect
return env.redirect referer
else
env.response.content_type = "application/json"
return "{}"
end
end
end

ファイルの表示

@ -343,7 +343,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "text/html"
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
return templated "authorize_token"
return templated "user/authorize_token"
else
env.response.content_type = "application/json"

ファイルの表示

@ -27,7 +27,7 @@ module Invidious::Routes::Login
tfa = env.params.query["tfa"]?
prompt = nil
templated "login"
templated "user/login"
end
def self.login(env)
@ -133,7 +133,7 @@ module Invidious::Routes::Login
tfa = tfa_code
captcha = {tokens: [token], question: ""}
return templated "login"
return templated "user/login"
end
if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
@ -190,7 +190,7 @@ module Invidious::Routes::Login
tfa = nil
captcha = nil
return templated "login"
return templated "user/login"
end
tl = challenge_results[1][2]
@ -282,18 +282,8 @@ module Invidious::Routes::Login
host = URI.parse(env.request.headers["Host"]).host
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
cookies.each do |cookie|
if Kemal.config.ssl || CONFIG.https_only
cookie.secure = secure
else
cookie.secure = secure
end
cookie.secure = Invidious::User::Cookies::SECURE
if cookie.extension
cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
@ -338,19 +328,7 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
else
return error_template(401, "Wrong username or password")
end
@ -393,12 +371,12 @@ module Invidious::Routes::Login
prompt = ""
if captcha_type == "image"
captcha = generate_captcha(HMAC_KEY)
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
else
captcha = generate_text_captcha(HMAC_KEY)
captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
end
return templated "login"
return templated "user/login"
end
tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
@ -455,19 +433,7 @@ module Invidious::Routes::Login
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)

ファイルの表示

@ -8,7 +8,7 @@ module Invidious::Routes::PreferencesRoute
preferences = env.get("preferences").as(Preferences)
templated "preferences"
templated "user/preferences"
end
def self.update(env)
@ -214,19 +214,7 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml)
end
else
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
env.redirect referer
@ -261,21 +249,7 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark"
end
preferences = preferences.to_json
if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end
if CONFIG.domain
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
if redirect
@ -298,7 +272,7 @@ module Invidious::Routes::PreferencesRoute
user = user.as(User)
templated "data_control"
templated "user/data_control"
end
def self.update_data_control(env)
@ -321,149 +295,27 @@ module Invidious::Routes::PreferencesRoute
# TODO: Unify into single import based on content-type
case part.name
when "import_invidious"
body = JSON.parse(body)
if body["subscriptions"]?
user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
if body["watch_history"]?
user.watched += body["watch_history"].as_a.map(&.as_s)
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
end
if body["preferences"]?
user.preferences = Preferences.from_json(body["preferences"].to_json)
Invidious::Database::Users.update_preferences(user)
end
if playlists = body["playlists"]?.try &.as_a?
playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
next if !title
next if !description
next if !privacy
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
video_id = video_id.try &.as_s?
next if !video_id
begin
video = get_video(video_id)
rescue ex
next
end
playlist_video = PlaylistVideo.new({
title: video.title,
id: video.id,
author: video.author,
ucid: video.ucid,
length_seconds: video.length_seconds,
published: video.published,
plid: playlist.id,
live_now: video.live_now,
index: Random::Secure.rand(0_i64..Int64::MAX),
})
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
end
end
end
Invidious::User::Import.from_invidious(user, body)
when "import_youtube"
filename = part.filename || ""
extension = filename.split(".").last
success = Invidious::User::Import.from_youtube(user, body, filename, type)
if extension == "xml" || type == "application/xml" || type == "text/xml"
subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)
user.subscriptions += subscriptions.as_a.compact_map do |entry|
entry["snippet"]["resourceId"]["channelId"].as_s
end
elsif extension == "csv" || type == "text/csv"
subscriptions = parse_subscription_export_csv(body)
user.subscriptions += subscriptions
else
if !success
haltf(env, status_code: 415,
response: error_template(415, "Invalid subscription file uploaded")
)
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
when "import_freetube"
user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
md["channel_id"]
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
Invidious::User::Import.from_freetube(user, body)
when "import_newpipe_subscriptions"
body = JSON.parse(body)
user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
next match["channel"]
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next ucid if ucid
end
nil
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
Invidious::User::Import.from_newpipe_subs(user, body)
when "import_newpipe"
Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
file.each_entry do |entry|
if entry.filename == "newpipe.db"
tempfile = File.tempfile(".db")
File.write(tempfile.path, entry.io.gets_to_end)
db = DB.open("sqlite3://" + tempfile.path)
success = Invidious::User::Import.from_newpipe(user, body)
user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
db.close
tempfile.delete
end
end
if !success
haltf(env, status_code: 415,
response: error_template(415, "Uploaded file is too large")
)
end
else nil # Ignore
end

ファイルの表示

@ -163,6 +163,6 @@ module Invidious::Routes::Subscriptions
end
end
templated "subscription_manager"
templated "user/subscription_manager"
end
end

ファイルの表示

@ -10,6 +10,32 @@ module Invidious::Routing
{% end %}
end
macro define_user_routes
# User login/out
Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
Invidious::Routing.post "/login", Invidious::Routes::Login, :login
Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
# User preferences
Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
# User account management
Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password
Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password
Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete
Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete
Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history
Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history
Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token
Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token
Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager
Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax
end
macro define_v1_api_routes
{{namespace = Invidious::Routes::API::V1}}
# Videos

ファイルの表示

@ -176,7 +176,7 @@ end
def process_search_query(query, page, user, region)
if user
user = user.as(User)
user = user.as(Invidious::User)
view_name = "subscriptions_#{sha256(user.email)}"
end

78
src/invidious/user/captcha.cr ノーマルファイル
ファイルの表示

@ -0,0 +1,78 @@
require "openssl/hmac"
struct Invidious::User
module Captcha
extend self
private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
def generate_image(key)
second = Random::Secure.rand(12)
second_angle = second * 30
second = second * 5
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px" height="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
image = "data:image/png;base64,"
image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
) do |proc|
Base64.strict_encode(proc.output.gets_to_end)
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
return {
question: image,
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
def generate_text(key)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end
end
end

37
src/invidious/user/cookies.cr ノーマルファイル
ファイルの表示

@ -0,0 +1,37 @@
require "http/cookie"
struct Invidious::User
module Cookies
extend self
# Note: we use ternary operator because the two variables
# used in here are not booleans.
SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false
# Session ID (SID) cookie
# Parameter "domain" comes from the global config
def sid(domain : String?, sid) : HTTP::Cookie
return HTTP::Cookie.new(
name: "SID",
domain: domain,
value: sid,
expires: Time.utc + 2.years,
secure: SECURE,
http_only: true
)
end
# Preferences (PREFS) cookie
# Parameter "domain" comes from the global config
def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie
return HTTP::Cookie.new(
name: "PREFS",
domain: domain,
value: URI.encode_www_form(preferences.to_json),
expires: Time.utc + 2.years,
secure: SECURE,
http_only: true
)
end
end
end

ファイルの表示

@ -1,27 +1,242 @@
require "csv"
def parse_subscription_export_csv(csv_content : String)
rows = CSV.new(csv_content, headers: true)
subscriptions = Array(String).new
struct Invidious::User
module Import
extend self
# Counter to limit the amount of imports.
# This is intended to prevent DoS.
row_counter = 0
# Parse a youtube CSV subscription file
def parse_subscription_export_csv(csv_content : String)
rows = CSV.new(csv_content, headers: true)
subscriptions = Array(String).new
rows.each do |row|
# Limit to 1200
row_counter += 1
break if row_counter > 1_200
# Counter to limit the amount of imports.
# This is intended to prevent DoS.
row_counter = 0
# Channel ID is the first column in the csv export we can't use the header
# name, because the header name is localized depending on the
# language the user has set on their account
channel_id = row[0].strip
rows.each do |row|
# Limit to 1200
row_counter += 1
break if row_counter > 1_200
next if channel_id.empty?
# Channel ID is the first column in the csv export we can't use the header
# name, because the header name is localized depending on the
# language the user has set on their account
channel_id = row[0].strip
subscriptions << channel_id
end
next if channel_id.empty?
subscriptions << channel_id
end
return subscriptions
return subscriptions
end
# -------------------
# Invidious
# -------------------
# Import from another invidious account
def from_invidious(user : User, body : String)
data = JSON.parse(body)
if data["subscriptions"]?
user.subscriptions += data["subscriptions"].as_a.map(&.as_s)
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
if data["watch_history"]?
user.watched += data["watch_history"].as_a.map(&.as_s)
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
end
if data["preferences"]?
user.preferences = Preferences.from_json(data["preferences"].to_json)
Invidious::Database::Users.update_preferences(user)
end
if playlists = data["playlists"]?.try &.as_a?
playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
next if !title
next if !description
next if !privacy
playlist = create_playlist(title, privacy, user)
Invidious::Database::Playlists.update_description(playlist.id, description)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
video_id = video_id.try &.as_s?
next if !video_id
begin
video = get_video(video_id)
rescue ex
next
end
playlist_video = PlaylistVideo.new({
title: video.title,
id: video.id,
author: video.author,
ucid: video.ucid,
length_seconds: video.length_seconds,
published: video.published,
plid: playlist.id,
live_now: video.live_now,
index: Random::Secure.rand(0_i64..Int64::MAX),
})
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
end
end
end
end
# -------------------
# Youtube
# -------------------
private def is_opml?(mimetype : String, extension : String)
opml_mimetypes = [
"application/xml",
"text/xml",
"text/x-opml",
"text/x-opml+xml",
]
opml_extensions = ["xml", "opml"]
return opml_mimetypes.any?(&.== mimetype) || opml_extensions.any?(&.== extension)
end
# Import subscribed channels from Youtube
# Returns success status
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last
if is_opml?(type, extension)
subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)
user.subscriptions += subscriptions.as_a.compact_map do |entry|
entry["snippet"]["resourceId"]["channelId"].as_s
end
elsif extension == "csv" || type == "text/csv"
subscriptions = parse_subscription_export_csv(body)
user.subscriptions += subscriptions
else
return false
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
return true
end
# -------------------
# Freetube
# -------------------
def from_freetube(user : User, body : String)
# Legacy import?
matches = body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/)
subs = matches.map(&.["channel_id"])
if subs.empty?
data = JSON.parse(body)["subscriptions"]
subs = data.as_a.map(&.["id"].as_s)
end
user.subscriptions += subs
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
# -------------------
# Newpipe
# -------------------
def from_newpipe_subs(user : User, body : String)
data = JSON.parse(body)
user.subscriptions += data["subscriptions"].as_a.compact_map do |channel|
if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
next match["channel"]
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
# Resolve URL using the API
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com/user/#{match["user"]}")
ucid = resolved_url.dig?("endpoint", "browseEndpoint", "browseId")
next ucid.as_s if ucid
end
nil
end
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
def from_newpipe(user : User, body : String) : Bool
io = IO::Memory.new(body)
Compress::Zip::File.open(io) do |file|
file.entries.each do |entry|
entry.open do |file_io|
# Ensure max size of 4MB
io_sized = IO::Sized.new(file_io, 0x400000)
next if entry.filename != "newpipe.db"
tempfile = File.tempfile(".db")
begin
File.write(tempfile.path, io_sized.gets_to_end)
rescue
return false
end
db = DB.open("sqlite3://" + tempfile.path)
user.watched += db.query_all("SELECT url FROM streams", as: String)
.map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
.map(&.lchop("https://www.youtube.com/channel/"))
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
db.close
tempfile.delete
end
end
end
# Success!
return true
end
end # module
end

27
src/invidious/user/user.cr ノーマルファイル
ファイルの表示

@ -0,0 +1,27 @@
require "db"
struct Invidious::User
include DB::Serializable
property updated : Time
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
@[DB::Field(converter: Invidious::User::PreferencesConverter)]
property preferences : Preferences
property password : String?
property token : String
property watched : Array(String)
property feed_needs_update : Bool?
module PreferencesConverter
def self.from_rs(rs)
begin
Preferences.from_json(rs.read(String))
rescue ex
Preferences.from_json("{}")
end
end
end
end

ファイルの表示

@ -3,32 +3,6 @@ require "crypto/bcrypt/password"
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
struct User
include DB::Serializable
property updated : Time
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
@[DB::Field(converter: User::PreferencesConverter)]
property preferences : Preferences
property password : String?
property token : String
property watched : Array(String)
property feed_needs_update : Bool?
module PreferencesConverter
def self.from_rs(rs)
begin
Preferences.from_json(rs.read(String))
rescue ex
Preferences.from_json("{}")
end
end
end
end
def get_user(sid, headers, refresh = true)
if email = Invidious::Database::SessionIDs.select_email(sid)
user = Invidious::Database::Users.select!(email: email)
@ -84,7 +58,7 @@ def fetch_user(sid, headers)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new({
user = Invidious::User.new({
updated: Time.utc,
notifications: [] of String,
subscriptions: channels,
@ -102,7 +76,7 @@ def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new({
user = Invidious::User.new({
updated: Time.utc,
notifications: [] of String,
subscriptions: [] of String,
@ -117,75 +91,6 @@ def create_user(sid, email, password)
return user, sid
end
def generate_captcha(key)
second = Random::Secure.rand(12)
second_angle = second * 30
second = second * 5
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px" height="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
image = "data:image/png;base64,"
image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
) do |proc|
Base64.strict_encode(proc.output.gets_to_end)
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
return {
question: image,
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
def generate_text_captcha(key)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end
def subscribe_ajax(channel_id, action, env_headers)
headers = HTTP::Headers.new
headers["Cookie"] = env_headers["Cookie"]

ファイルの表示

@ -881,11 +881,13 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
if player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK"
reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s|
s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("")
} || player_response["playabilityStatus"]["reason"].as_s
if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
params["reason"] = JSON::Any.new(reason)
return params
end
params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
@ -928,11 +930,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
primary_results = main_results.dig?("results", "results", "contents")
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
raise BrokenTubeException.new("results") if !primary_results
raise BrokenTubeException.new("secondaryResults") if !secondary_results
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
@ -952,7 +951,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
secondary_results.as_a.each do |element|
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
@ -1119,7 +1120,9 @@ def fetch_video(id, region)
info = embed_info if !embed_info["reason"]?
end
raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]?
if reason = info["reason"]?
raise InfoException.new(reason.as_s || "")
end
video = Video.new({
id: id,

ファイルの表示

@ -53,7 +53,7 @@
</div>
<div class="pure-u-1-4">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(User).notifications.size %>
<% notification_count = env.get("user").as(Invidious::User).notifications.size %>
<% if notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
<% else %>
@ -68,7 +68,7 @@
</div>
<% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4">
<span id="user_name"><%= env.get("user").as(User).email %></span>
<span id="user_name"><%= env.get("user").as(Invidious::User).email %></span>
</div>
<% end %>
<div class="pure-u-1-4">

ファイルの表示

@ -252,7 +252,7 @@
<% end %>
<% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
<legend><%= translate(locale, "preferences_category_admin") %></legend>
<div class="pure-control-group">