Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Wes van der Vleuten 2023-01-21 23:35:39 +01:00
commit 420e12bb8b
113 changed files with 3023 additions and 2338 deletions

View File

@ -41,7 +41,7 @@ jobs:
- 1.3.2 - 1.3.2
- 1.4.1 - 1.4.1
- 1.5.1 - 1.5.1
- 1.6.1 - 1.6.2
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false

View File

@ -527,6 +527,10 @@ hr {
margin-top: 20px; margin-top: 20px;
} }
label[for="descexpansionbutton"]:hover {
cursor: pointer;
}
/* Bidi (bidirectional text) support */ /* Bidi (bidirectional text) support */
h1, h1,
h2, h2,

View File

@ -295,6 +295,17 @@ https_only: false
## ##
#admins: [""] #admins: [""]
##
## Enable/Disable the user notifications for all users
##
## Note: On large instances, it is recommended to set this option to 'false'
## in order to reduce the amount of data written to the database, and hence
## improve the overall performance of the instance.
##
## Accepted values: true, false
## Default: true
##
#enable_user_notifications: true
# ----------------------------- # -----------------------------
# Background jobs # Background jobs
@ -613,10 +624,10 @@ default_user_preferences:
## ##
## Enable/Disable dark mode. ## Enable/Disable dark mode.
## ##
## Accepted values: true, false ## Accepted values: "dark", "light", "auto"
## Default: <none> ## Default: "auto"
## ##
#dark_mode: #dark_mode: "auto"
## ##
## Enable/Disable thin mode (no video thumbnails). ## Enable/Disable thin mode (no video thumbnails).

View File

@ -43,7 +43,7 @@ RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \
FROM alpine:3.16 FROM alpine:3.16
RUN apk add --no-cache librsvg ttf-opensans RUN apk add --no-cache librsvg ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious
@ -58,4 +58,5 @@ RUN chmod o+rX -R ./assets ./config ./locales
EXPOSE 3000 EXPOSE 3000
USER invidious USER invidious
ENTRYPOINT ["/sbin/tini", "--"]
CMD [ "/invidious/invidious" ] CMD [ "/invidious/invidious" ]

View File

@ -42,7 +42,7 @@ RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \
fi fi
FROM alpine:3.16 FROM alpine:3.16
RUN apk add --no-cache librsvg ttf-opensans RUN apk add --no-cache librsvg ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious
@ -57,4 +57,5 @@ RUN chmod o+rX -R ./assets ./config ./locales
EXPOSE 3000 EXPOSE 3000
USER invidious USER invidious
ENTRYPOINT ["/sbin/tini", "--"]
CMD [ "/invidious/invidious" ] CMD [ "/invidious/invidious" ]

View File

@ -1,6 +1,6 @@
dependencies: dependencies:
- name: postgresql - name: postgresql
repository: https://charts.bitnami.com/bitnami/ repository: https://charts.bitnami.com/bitnami/
version: 11.1.3 version: 12.1.9
digest: sha256:79061645472b6fb342d45e8e5b3aacd018ef5067193e46a060bccdc99fe7f6e1 digest: sha256:71ff342a6c0a98bece3d7fe199983afb2113f8db65a3e3819de875af2c45add7
generated: "2022-03-02T05:57:20.081432389+13:00" generated: "2023-01-20T20:42:32.757707004Z"

View File

@ -17,6 +17,6 @@ maintainers:
email: mail@leonklingele.de email: mail@leonklingele.de
dependencies: dependencies:
- name: postgresql - name: postgresql
version: ~11.1.3 version: ~12.1.6
repository: "https://charts.bitnami.com/bitnami/" repository: "https://charts.bitnami.com/bitnami/"
engine: gotpl engine: gotpl

View File

@ -34,6 +34,8 @@ securityContext:
# See https://github.com/bitnami/charts/tree/master/bitnami/postgresql # See https://github.com/bitnami/charts/tree/master/bitnami/postgresql
postgresql: postgresql:
image:
tag: 13
auth: auth:
username: kemal username: kemal
password: kemal password: kemal

View File

@ -1,11 +1,11 @@
{ {
"LIVE": "مُباشِر", "LIVE": "مُباشِر",
"Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`", "Shared `x` ago": "تمَّ الرفع مُنذ `x`",
"Unsubscribe": "إلغاء الاشتراك", "Unsubscribe": "إلغاء الاشتراك",
"Subscribe": "الإشتراك", "Subscribe": "الاشتراك",
"View channel on YouTube": "زيارة القناة على موقع يوتيوب", "View channel on YouTube": "زيارة القناة على يوتيوب",
"View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب", "View playlist on YouTube": "عرض قائمة التشغيل على يوتيوب",
"newest": "الأجدد", "newest": "الأحدث",
"oldest": "الأقدم", "oldest": "الأقدم",
"popular": "الأكثر شعبية", "popular": "الأكثر شعبية",
"last": "الأخيرة", "last": "الأخيرة",
@ -96,8 +96,8 @@
"`x` is live": "`x` في بث مباشر", "`x` is live": "`x` في بث مباشر",
"preferences_category_data": "إعدادات التفضيلات", "preferences_category_data": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة", "Clear watch history": "حذف سجل المشاهدة",
"Import/export data": ضافة\\استخراج البيانات", "Import/export data": ستيراد و تصدير البيانات",
"Change password": "غير كلمة السر", "Change password": "تغير كلمة السر",
"Manage subscriptions": "إدارة الاشتراكات", "Manage subscriptions": "إدارة الاشتراكات",
"Manage tokens": "إدارة الرموز", "Manage tokens": "إدارة الرموز",
"Watch history": "سجل المشاهدة", "Watch history": "سجل المشاهدة",
@ -137,7 +137,7 @@
"Title": "العنوان", "Title": "العنوان",
"Playlist privacy": "إعدادات الخصوصية", "Playlist privacy": "إعدادات الخصوصية",
"Editing playlist `x`": "تعديل قائمة التشغيل `x`", "Editing playlist `x`": "تعديل قائمة التشغيل `x`",
"Show more": "إظهار المزيد", "Show more": "عرض المزيد",
"Show less": "عرض اقل", "Show less": "عرض اقل",
"Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب",
"Switch Invidious Instance": "تبديل المثيل Invidious", "Switch Invidious Instance": "تبديل المثيل Invidious",
@ -147,20 +147,20 @@
"License: ": "التراخيص: ", "License: ": "التراخيص: ",
"Family friendly? ": "محتوى عائلي؟ ", "Family friendly? ": "محتوى عائلي؟ ",
"Wilson score: ": "درجة ويلسون: ", "Wilson score: ": "درجة ويلسون: ",
"Engagement: ": "نسبة المشاركة: ", "Engagement: ": "نسبة التفاعل: ",
"Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ", "Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ",
"Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ", "Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ",
"Shared `x`": "شارك منذ `x`", "Shared `x`": "تمت المشاركة في `x`",
"Premieres in `x`": "يعرض فى `x`", "Premieres in `x`": "يعرض فى `x`",
"Premieres `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 YouTube comments": "عرض تعليقات اليوتيوب",
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت",
"View `x` comments": { "View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات", "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات",
"": "عرض `x` تعليقات" "": "عرض `x` تعليقات"
}, },
"View Reddit comments": "عرض تعليقات ريدإت Reddit", "View Reddit comments": "عرض تعليقات ريديت",
"Hide replies": "إخفاء الردود", "Hide replies": "إخفاء الردود",
"Show replies": "عرض الردود", "Show replies": "عرض الردود",
"Incorrect password": "كلمة السر غير صحيحة", "Incorrect password": "كلمة السر غير صحيحة",
@ -182,20 +182,20 @@
"channel:`x`": "قناة:`x`", "channel:`x`": "قناة:`x`",
"Deleted or invalid channel": "قناة ممسوحة او غير صالحة", "Deleted or invalid channel": "قناة ممسوحة او غير صالحة",
"This channel does not exist.": "هذه القناة غير موجودة.", "This channel does not exist.": "هذه القناة غير موجودة.",
"Could not get channel info.": "لم يستطع الحصول على معلومات القناة.", "Could not get channel info.": "لم يتمكن الحصول على معلومات القناة.",
"Could not fetch comments": م يتمكن من إحضار التعليقات", "Could not fetch comments": ا يتمكن إحضار التعليقات",
"`x` ago": "`x` منذ", "`x` ago": "`x` منذ",
"Load more": "عرض المزيد", "Load more": "تحميل المزيد",
"Could not create mix.": "تعذر إنشاء مزيج.", "Could not create mix.": "تعذر إنشاء مزيج.",
"Empty playlist": "قائمة التشغيل فارغة", "Empty playlist": "قائمة التشغيل فارغة",
"Not a playlist.": "قائمة التشغيل غير صالحة.", "Not a playlist.": "قائمة التشغيل غير صالحة.",
"Playlist does not exist.": "قائمة التشغيل غير موجودة.", "Playlist does not exist.": "قائمة التشغيل غير موجودة.",
"Could not pull trending pages.": م يستطع عرض الصفحات الراجئة.", "Could not pull trending pages.": ا يتمكن عرض الصفحات الراجئة.",
"Hidden field \"challenge\" is a required field": "مكان مخفي \"تحدي\" مكان مطلوب", "Hidden field \"challenge\" is a required field": "الحقل المخفي \"تحدي\" حقل مطلوب",
"Hidden field \"token\" is a required field": "مكان مخفي \"رمز\" مكان مطلوب", "Hidden field \"token\" is a required field": "الحقل المخفي \"رمز\" حقل مطلوب",
"Erroneous challenge": "تحدي غير صالح", "Erroneous challenge": "تحدي خاطئ",
"Erroneous token": "رمز مميز خاطئ", "Erroneous token": "رمز مميز خاطئ",
"No such user": "مستخدم غير صالح", "No such user": "مستخدم غير موجود",
"Token is expired, please try again": "الرمز منتهى الصلاحية، الرجاء المحاولة مرة اخرى", "Token is expired, please try again": "الرمز منتهى الصلاحية، الرجاء المحاولة مرة اخرى",
"English": "إنجليزي", "English": "إنجليزي",
"English (auto-generated)": "إنجليزي (تم إنشائه تلقائيًا)", "English (auto-generated)": "إنجليزي (تم إنشائه تلقائيًا)",
@ -325,15 +325,15 @@
"`x` marked it with a ❤": "`x` أعجب بهذا", "`x` marked it with a ❤": "`x` أعجب بهذا",
"Audio mode": "الوضع الصوتي", "Audio mode": "الوضع الصوتي",
"Video mode": "وضع الفيديو", "Video mode": "وضع الفيديو",
"Videos": "الفيديوهات", "channel_tab_videos_label": "الفيديوهات",
"Playlists": "قوائم التشغيل", "Playlists": "قوائم التشغيل",
"Community": "المجتمع", "channel_tab_community_label": "المجتمع",
"search_filters_sort_option_relevance": "ملاؤم", "search_filters_sort_option_relevance": "ملائمة",
"search_filters_sort_option_rating": "تقييم", "search_filters_sort_option_rating": "تقييم",
"search_filters_sort_option_date": "التاريخ", "search_filters_sort_option_date": "التاريخ",
"search_filters_sort_option_views": "مشاهدات", "search_filters_sort_option_views": "مشاهدات",
"search_filters_type_label": "نوع المحتوى", "search_filters_type_label": "نوع المحتوى",
"search_filters_duration_label": "المدة الزمنية", "search_filters_duration_label": "المدة",
"search_filters_features_label": "الميزات", "search_filters_features_label": "الميزات",
"search_filters_sort_label": "فرز", "search_filters_sort_label": "فرز",
"search_filters_date_option_hour": "آخر ساعة", "search_filters_date_option_hour": "آخر ساعة",
@ -351,8 +351,8 @@
"search_filters_features_option_c_commons": "المشاع الإبداعي", "search_filters_features_option_c_commons": "المشاع الإبداعي",
"search_filters_features_option_three_d": "ثلاثي الأبعاد", "search_filters_features_option_three_d": "ثلاثي الأبعاد",
"search_filters_features_option_live": "مباشر", "search_filters_features_option_live": "مباشر",
"search_filters_features_option_four_k": "4k", "search_filters_features_option_four_k": "4K",
"search_filters_features_option_location": "الأماكن", "search_filters_features_option_location": "المكان",
"search_filters_features_option_hdr": "وضع التباين العالي", "search_filters_features_option_hdr": "وضع التباين العالي",
"Current version: ": "الإصدار الحالي: ", "Current version: ": "الإصدار الحالي: ",
"next_steps_error_message": "بعد ذلك يجب أن تحاول: ", "next_steps_error_message": "بعد ذلك يجب أن تحاول: ",
@ -360,10 +360,10 @@
"next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب", "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب",
"search_filters_duration_option_short": "قصير (< 4 دقائق)", "search_filters_duration_option_short": "قصير (< 4 دقائق)",
"search_filters_duration_option_long": "طويل (> 20 دقيقة)", "search_filters_duration_option_long": "طويل (> 20 دقيقة)",
"footer_source_code": "شفرة المصدر", "footer_source_code": "الكود المصدر",
"footer_original_source_code": "كود المصدر الأصلي", "footer_original_source_code": "الكود المصدر الأصلي",
"footer_modfied_source_code": "شفرة المصدر المعدلة", "footer_modfied_source_code": "الكود المصدر المعدل",
"adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة", "adminprefs_modified_source_code_url_label": "URL إلى مستودع الكود المصدر المعدل",
"footer_documentation": "التوثيق", "footer_documentation": "التوثيق",
"footer_donate_page": "تبرّع", "footer_donate_page": "تبرّع",
"preferences_region_label": "بلد المحتوى: ", "preferences_region_label": "بلد المحتوى: ",
@ -398,31 +398,31 @@
"invidious": "الخيالي", "invidious": "الخيالي",
"preferences_save_player_pos_label": "حفظ موضع التشغيل: ", "preferences_save_player_pos_label": "حفظ موضع التشغيل: ",
"crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!", "crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!",
"generic_videos_count_0": "لا فيديوهات", "generic_videos_count_0": "لا يوجد فيديوهات",
"generic_videos_count_1": "فيديو واحد", "generic_videos_count_1": "فيديو واحد",
"generic_videos_count_2": "فيديوهين", "generic_videos_count_2": "فيديوهين",
"generic_videos_count_3": "{{count}} فيديوهات", "generic_videos_count_3": "{{count}} فيديوهات",
"generic_videos_count_4": "{{count}} فيديو", "generic_videos_count_4": "{{count}} فيديو",
"generic_videos_count_5": "{{count}} فيديو", "generic_videos_count_5": "{{count}} فيديو",
"generic_subscribers_count_0": "لا مشتركين", "generic_subscribers_count_0": "لا يوجد مشترك",
"generic_subscribers_count_1": "مشترك واحد", "generic_subscribers_count_1": "مشترك واحد",
"generic_subscribers_count_2": "مشتركان", "generic_subscribers_count_2": "مشتركان",
"generic_subscribers_count_3": "{{count}} مشتركين", "generic_subscribers_count_3": "{{count}} مشتركين",
"generic_subscribers_count_4": "{{count}} مشترك", "generic_subscribers_count_4": "{{count}} مشترك",
"generic_subscribers_count_5": "{{count}} مشترك", "generic_subscribers_count_5": "{{count}} مشترك",
"generic_views_count_0": "لا مشاهدات", "generic_views_count_0": "لا يوجد مشاهدة",
"generic_views_count_1": "مشاهدة واحدة", "generic_views_count_1": "مشاهدة واحدة",
"generic_views_count_2": "مشاهدتان", "generic_views_count_2": "مشاهدتان",
"generic_views_count_3": "{{count}} مشاهدات", "generic_views_count_3": "{{count}} مشاهدات",
"generic_views_count_4": "{{count}} مشاهدة", "generic_views_count_4": "{{count}} مشاهدة",
"generic_views_count_5": "{{count}} مشاهدة", "generic_views_count_5": "{{count}} مشاهدة",
"generic_subscriptions_count_0": "لا اشتراكات", "generic_subscriptions_count_0": "لا يوجد اشتراك",
"generic_subscriptions_count_1": "اشتراك واحد", "generic_subscriptions_count_1": "اشتراك واحد",
"generic_subscriptions_count_2": "اشتراكان", "generic_subscriptions_count_2": "اشتراكان",
"generic_subscriptions_count_3": "{{count}} اشتراكات", "generic_subscriptions_count_3": "{{count}} اشتراكات",
"generic_subscriptions_count_4": "{{count}} اشتراك", "generic_subscriptions_count_4": "{{count}} اشتراك",
"generic_subscriptions_count_5": "{{count}} اشتراك", "generic_subscriptions_count_5": "{{count}} اشتراك",
"generic_playlists_count_0": "لا قوائم تشغيل", "generic_playlists_count_0": "لا يوجد قوائم تشغيل",
"generic_playlists_count_1": "قائمة تشغيل واحدة", "generic_playlists_count_1": "قائمة تشغيل واحدة",
"generic_playlists_count_2": "قائمتا تشغيل", "generic_playlists_count_2": "قائمتا تشغيل",
"generic_playlists_count_3": "{{count}} قوائم تشغيل", "generic_playlists_count_3": "{{count}} قوائم تشغيل",
@ -463,10 +463,10 @@
"search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.", "search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.",
"search_filters_date_label": "تاريخ الرفع", "search_filters_date_label": "تاريخ الرفع",
"generic_count_weeks_0": "{{count}} أسبوع", "generic_count_weeks_0": "{{count}} أسبوع",
"generic_count_weeks_1": "{{count}} أسبوع", "generic_count_weeks_1": "أسبوع واحد",
"generic_count_weeks_2": "{{count}} أسبوع", "generic_count_weeks_2": "أسبوعين",
"generic_count_weeks_3": "{{count}} أسبوع", "generic_count_weeks_3": "{{count}} أسابيع",
"generic_count_weeks_4": "{{count}} أسابيع", "generic_count_weeks_4": "{{count}} أسبوع",
"generic_count_weeks_5": "{{count}} أسبوع", "generic_count_weeks_5": "{{count}} أسبوع",
"Popular enabled: ": "تم تمكين الشعبية: ", "Popular enabled: ": "تم تمكين الشعبية: ",
"search_filters_duration_option_medium": "متوسط (4-20 دقيقة)", "search_filters_duration_option_medium": "متوسط (4-20 دقيقة)",
@ -474,16 +474,16 @@
"search_filters_type_option_all": "أي نوع", "search_filters_type_option_all": "أي نوع",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"generic_count_minutes_0": "{{count}} دقيقة", "generic_count_minutes_0": "{{count}} دقيقة",
"generic_count_minutes_1": "{{count}} دقيقة", "generic_count_minutes_1": "دقيقة واحدة",
"generic_count_minutes_2": "{{count}} دقيقة", "generic_count_minutes_2": "دقيقتين",
"generic_count_minutes_3": "{{count}} دقيقة", "generic_count_minutes_3": "{{count}} دقائق",
"generic_count_minutes_4": "{{count}} دقائق", "generic_count_minutes_4": "{{count}} دقيقة",
"generic_count_minutes_5": "{{count}} دقيقة", "generic_count_minutes_5": "{{count}} دقيقة",
"generic_count_hours_0": "{{count}} ساعة", "generic_count_hours_0": "{{count}} ساعة",
"generic_count_hours_1": "{{count}} ساعة", "generic_count_hours_1": "ساعة واحدة",
"generic_count_hours_2": "{{count}} ساعة", "generic_count_hours_2": "ساعتين",
"generic_count_hours_3": "{{count}} ساعة", "generic_count_hours_3": "{{count}} ساعات",
"generic_count_hours_4": "{{count}} ساعات", "generic_count_hours_4": "{{count}} ساعة",
"generic_count_hours_5": "{{count}} ساعة", "generic_count_hours_5": "{{count}} ساعة",
"comments_view_x_replies_0": "عرض رد {{count}}", "comments_view_x_replies_0": "عرض رد {{count}}",
"comments_view_x_replies_1": "عرض رد {{count}}", "comments_view_x_replies_1": "عرض رد {{count}}",
@ -493,10 +493,10 @@
"comments_view_x_replies_5": "عرض رد {{count}}", "comments_view_x_replies_5": "عرض رد {{count}}",
"search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.", "search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
"comments_points_count_0": "{{count}} نقطة", "comments_points_count_0": "{{count}} نقطة",
"comments_points_count_1": "{{count}} نقطة", "comments_points_count_1": "نقطة واحدة",
"comments_points_count_2": "{{count}} نقطة", "comments_points_count_2": "نقطتان",
"comments_points_count_3": "{{count}} نقطة", "comments_points_count_3": "{{count}} نقط",
"comments_points_count_4": "{{count}} نقاط", "comments_points_count_4": "{{count}} نقطة",
"comments_points_count_5": "{{count}} نقطة", "comments_points_count_5": "{{count}} نقطة",
"generic_count_years_0": "{{count}} السنة", "generic_count_years_0": "{{count}} السنة",
"generic_count_years_1": "{{count}} السنة", "generic_count_years_1": "{{count}} السنة",
@ -512,17 +512,17 @@
"tokens_count_5": "الرمز المميز {{count}}", "tokens_count_5": "الرمز المميز {{count}}",
"search_filters_apply_button": "تطبيق الفلاتر المحددة", "search_filters_apply_button": "تطبيق الفلاتر المحددة",
"search_filters_duration_option_none": "أي مدة", "search_filters_duration_option_none": "أي مدة",
"subscriptions_unseen_notifs_count_0": "{{count}} إشعار غير مرئي", "subscriptions_unseen_notifs_count_0": "{{count}} إشعار جديد",
"subscriptions_unseen_notifs_count_1": "{{count}} إشعار غير مرئي", "subscriptions_unseen_notifs_count_1": "إشعار واحد جديد",
"subscriptions_unseen_notifs_count_2": "{{count}} إشعار غير مرئي", "subscriptions_unseen_notifs_count_2": "إشعارين جديدين",
"subscriptions_unseen_notifs_count_3": "{{count}} إشعار غير مرئي", "subscriptions_unseen_notifs_count_3": "{{count}} إشعارات جديدة",
"subscriptions_unseen_notifs_count_4": "{{count}} إشعارات غير مرئية", "subscriptions_unseen_notifs_count_4": "{{count}} إشعارا جديد",
"subscriptions_unseen_notifs_count_5": "{{count}} إشعار غير مرئي", "subscriptions_unseen_notifs_count_5": "{{count}} إشعار جديد",
"generic_count_days_0": "{{count}} يوم", "generic_count_days_0": "{{count}} يوم",
"generic_count_days_1": "{{count}} يوم", "generic_count_days_1": "يوم واحد",
"generic_count_days_2": "{{count}} يوم", "generic_count_days_2": "يومين",
"generic_count_days_3": "{{count}} يوم", "generic_count_days_3": "{{count}} أيام",
"generic_count_days_4": "{{count}} أيام", "generic_count_days_4": "{{count}} يوم",
"generic_count_days_5": "{{count}} يوم", "generic_count_days_5": "{{count}} يوم",
"generic_count_months_0": "{{count}} شهر", "generic_count_months_0": "{{count}} شهر",
"generic_count_months_1": "{{count}} شهر", "generic_count_months_1": "{{count}} شهر",
@ -531,10 +531,10 @@
"generic_count_months_4": "{{count}} شهور", "generic_count_months_4": "{{count}} شهور",
"generic_count_months_5": "{{count}} شهر", "generic_count_months_5": "{{count}} شهر",
"generic_count_seconds_0": "{{count}} ثانية", "generic_count_seconds_0": "{{count}} ثانية",
"generic_count_seconds_1": "{{count}} ثانية", "generic_count_seconds_1": "ثانية واحدة",
"generic_count_seconds_2": "{{count}} ثانية", "generic_count_seconds_2": "ثانيتين",
"generic_count_seconds_3": "{{count}} ثانية", "generic_count_seconds_3": "{{count}} ثوانٍ",
"generic_count_seconds_4": "{{count}} ثوانٍ", "generic_count_seconds_4": "{{count}} ثانية",
"generic_count_seconds_5": "{{count}} ثانية", "generic_count_seconds_5": "{{count}} ثانية",
"error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. <a href=\"`x`\"> انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. </a>" "error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. <a href=\"`x`\"> انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. </a>"
} }

View File

@ -51,7 +51,7 @@
"Movies": "Películes", "Movies": "Películes",
"Download": "Descarrega", "Download": "Descarrega",
"Download as: ": "Descarrega com: ", "Download as: ": "Descarrega com: ",
"Videos": "Vídeos", "channel_tab_videos_label": "Vídeos",
"search_filters_type_label": "Tipus", "search_filters_type_label": "Tipus",
"search_filters_duration_label": "Duració", "search_filters_duration_label": "Duració",
"search_filters_sort_label": "Ordena per", "search_filters_sort_label": "Ordena per",

View File

@ -260,8 +260,8 @@
"`x` marked it with a ❤": "`x` to označil(a) se ❤", "`x` marked it with a ❤": "`x` to označil(a) se ❤",
"Audio mode": "Audiový režim", "Audio mode": "Audiový režim",
"Video mode": "Videový režim", "Video mode": "Videový režim",
"Videos": "Videa", "channel_tab_videos_label": "Videa",
"Community": "Komunita", "channel_tab_community_label": "Komunita",
"search_filters_sort_option_rating": "Hodnocení", "search_filters_sort_option_rating": "Hodnocení",
"search_filters_sort_option_date": "Datum nahrání", "search_filters_sort_option_date": "Datum nahrání",
"search_filters_sort_option_views": "Počet zhlédnutí", "search_filters_sort_option_views": "Počet zhlédnutí",

View File

@ -187,7 +187,7 @@
"Esperanto": "Esperanto", "Esperanto": "Esperanto",
"Czech": "Tjekkisk", "Czech": "Tjekkisk",
"Danish": "Dansk", "Danish": "Dansk",
"Community": "Samfund", "channel_tab_community_label": "Samfund",
"Afrikaans": "Afrikansk", "Afrikaans": "Afrikansk",
"Portuguese": "Portugisisk", "Portuguese": "Portugisisk",
"Ukrainian": "Ukrainsk", "Ukrainian": "Ukrainsk",
@ -267,7 +267,7 @@
"search_filters_sort_option_rating": "Bedømmelse", "search_filters_sort_option_rating": "Bedømmelse",
"Yoruba": "Yoruba", "Yoruba": "Yoruba",
"Erroneous token": "Fejlagtig token", "Erroneous token": "Fejlagtig token",
"Videos": "Videoer", "channel_tab_videos_label": "Videoer",
"search_filters_type_option_show": "Vis", "search_filters_type_option_show": "Vis",
"Luxembourgish": "Luxemboursk", "Luxembourgish": "Luxemboursk",
"Vietnamese": "Vietnamesisk", "Vietnamese": "Vietnamesisk",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "`x` markierte es mit einem ❤", "`x` marked it with a ❤": "`x` markierte es mit einem ❤",
"Audio mode": "Audiomodus", "Audio mode": "Audiomodus",
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Videos", "channel_tab_videos_label": "Videos",
"Playlists": "Wiedergabelisten", "Playlists": "Wiedergabelisten",
"Community": "Gemeinschaft", "channel_tab_community_label": "Gemeinschaft",
"search_filters_sort_option_relevance": "Relevanz", "search_filters_sort_option_relevance": "Relevanz",
"search_filters_sort_option_rating": "Bewertung", "search_filters_sort_option_rating": "Bewertung",
"search_filters_sort_option_date": "Datum", "search_filters_sort_option_date": "Datum",
@ -471,5 +471,6 @@
"search_filters_apply_button": "Ausgewählte Filter anwenden", "search_filters_apply_button": "Ausgewählte Filter anwenden",
"search_filters_duration_option_none": "Beliebige Länge", "search_filters_duration_option_none": "Beliebige Länge",
"search_filters_date_label": "Upload-Datum", "search_filters_date_label": "Upload-Datum",
"search_filters_date_option_none": "Beliebiges Datum" "search_filters_date_option_none": "Beliebiges Datum",
"error_video_not_in_playlist": "Das angeforderte Video existiert nicht in dieser Wiedergabeliste. <a href=\"`x`\">Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen.</a>"
} }

View File

@ -315,9 +315,9 @@
"`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤", "`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤",
"Audio mode": "Λειτουργία ήχου", "Audio mode": "Λειτουργία ήχου",
"Video mode": "Λειτουργία βίντεο", "Video mode": "Λειτουργία βίντεο",
"Videos": "Βίντεο", "channel_tab_videos_label": "Βίντεο",
"Playlists": "Λίστες Αναπαραγωγής", "Playlists": "Λίστες Αναπαραγωγής",
"Community": "Κοινότητα", "channel_tab_community_label": "Κοινότητα",
"Current version: ": "Τρέχουσα έκδοση: ", "Current version: ": "Τρέχουσα έκδοση: ",
"generic_playlists_count": "{{count}} λίστα αναπαραγωγής", "generic_playlists_count": "{{count}} λίστα αναπαραγωγής",
"generic_playlists_count_plural": "{{count}} λίστες αναπαραγωγής", "generic_playlists_count_plural": "{{count}} λίστες αναπαραγωγής",

View File

@ -404,9 +404,7 @@
"`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",
"Videos": "Videos",
"Playlists": "Playlists", "Playlists": "Playlists",
"Community": "Community",
"search_filters_title": "Filters", "search_filters_title": "Filters",
"search_filters_date_label": "Upload date", "search_filters_date_label": "Upload date",
"search_filters_date_option_none": "Any date", "search_filters_date_option_none": "Any date",
@ -472,5 +470,11 @@
"crash_page_read_the_faq": "read the <a href=\"`x`\">Frequently Asked Questions (FAQ)</a>", "crash_page_read_the_faq": "read the <a href=\"`x`\">Frequently Asked Questions (FAQ)</a>",
"crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on GitHub</a>", "crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on GitHub</a>",
"crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):", "crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):",
"error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>" "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>",
"channel_tab_videos_label": "Videos",
"channel_tab_shorts_label": "Shorts",
"channel_tab_streams_label": "Livestreams",
"channel_tab_playlists_label": "Playlists",
"channel_tab_community_label": "Community",
"channel_tab_channels_label": "Channels"
} }

View File

@ -5,8 +5,8 @@
"Subscribe": "Abonu", "Subscribe": "Abonu",
"View channel on YouTube": "Vidu kanalon en JuTubo", "View channel on YouTube": "Vidu kanalon en JuTubo",
"View playlist on YouTube": "Vidu ludliston en JuTubo", "View playlist on YouTube": "Vidu ludliston en JuTubo",
"newest": "pli novaj", "newest": "plej novaj",
"oldest": "pli malnovaj", "oldest": "plej malnovaj",
"popular": "popularaj", "popular": "popularaj",
"last": "lasta", "last": "lasta",
"Next page": "Sekva paĝo", "Next page": "Sekva paĝo",
@ -325,9 +325,9 @@
"`x` marked it with a ❤": "`x` markis ĝin per ❤", "`x` marked it with a ❤": "`x` markis ĝin per ❤",
"Audio mode": "Aŭda reĝimo", "Audio mode": "Aŭda reĝimo",
"Video mode": "Videa reĝimo", "Video mode": "Videa reĝimo",
"Videos": "Filmetoj", "channel_tab_videos_label": "Filmetoj",
"Playlists": "Ludlistoj", "Playlists": "Ludlistoj",
"Community": "Komunumo", "channel_tab_community_label": "Komunumo",
"search_filters_sort_option_relevance": "rilateco", "search_filters_sort_option_relevance": "rilateco",
"search_filters_sort_option_rating": "takso", "search_filters_sort_option_rating": "takso",
"search_filters_sort_option_date": "dato", "search_filters_sort_option_date": "dato",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio", "Audio mode": "Modo de audio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Videos": "Vídeos", "channel_tab_videos_label": "Vídeos",
"Playlists": "Listas de reproducción", "Playlists": "Listas de reproducción",
"Community": "Comunidad", "channel_tab_community_label": "Comunidad",
"search_filters_sort_option_relevance": "relevancia", "search_filters_sort_option_relevance": "relevancia",
"search_filters_sort_option_rating": "valoración", "search_filters_sort_option_rating": "valoración",
"search_filters_sort_option_date": "fecha", "search_filters_sort_option_date": "fecha",

View File

@ -296,8 +296,8 @@
"Corsican": "Korsika", "Corsican": "Korsika",
"Javanese": "Jaava", "Javanese": "Jaava",
"Lithuanian": "Leedu", "Lithuanian": "Leedu",
"Videos": "Videod", "channel_tab_videos_label": "Videod",
"Community": "Kogukond", "channel_tab_community_label": "Kogukond",
"CAPTCHA is a required field": "CAPTCHA on kohustuslik väli", "CAPTCHA is a required field": "CAPTCHA on kohustuslik väli",
"comments_points_count": "{{count}} punkt", "comments_points_count": "{{count}} punkt",
"comments_points_count_plural": "{{count}} punkti", "comments_points_count_plural": "{{count}} punkti",

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` نشان گذاری شده با یک ❤", "`x` marked it with a ❤": "`x` نشان گذاری شده با یک ❤",
"Audio mode": "حالت صدا", "Audio mode": "حالت صدا",
"Video mode": "حالت ویدیو", "Video mode": "حالت ویدیو",
"Videos": "ویدیو ها", "channel_tab_videos_label": "ویدیو ها",
"Playlists": "سیاهه‌های پخش", "Playlists": "سیاهه‌های پخش",
"Community": "اجتماع", "channel_tab_community_label": "اجتماع",
"search_filters_sort_option_relevance": "مرتبط بودن", "search_filters_sort_option_relevance": "مرتبط بودن",
"search_filters_sort_option_rating": "امتیاز", "search_filters_sort_option_rating": "امتیاز",
"search_filters_sort_option_date": "تاریخ بارگذاری", "search_filters_sort_option_date": "تاریخ بارگذاری",
@ -411,5 +411,18 @@
"search_filters_duration_option_long": "بلند (> 20 دقیقه)", "search_filters_duration_option_long": "بلند (> 20 دقیقه)",
"adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده", "adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده",
"search_filters_duration_option_short": "کوتاه (< 4 دقیقه)", "search_filters_duration_option_short": "کوتاه (< 4 دقیقه)",
"search_filters_title": "پالایه" "search_filters_title": "پالایه",
"Chinese (Hong Kong)": "چینی (هنگ‌کنگ)",
"Dutch (auto-generated)": "هلندی (تولید خودکار)",
"preferences_watch_history_label": "فعال‌سازی تاریخچه‌ی پخش ",
"Indonesian (auto-generated)": "اندونزیایی (تولید خودکار)",
"English (United States)": "انگلیسی (ایالات متحده)",
"Chinese": "چینی",
"Chinese (Taiwan)": "چینی (تایوان)",
"French (auto-generated)": "فرانسوی (تولید خودکار)",
"English (United Kingdom)": "انگلیسی (ایالات بریتانیا)",
"search_message_no_results": "نتیجه‌ای یافت نشد.",
"search_message_change_filters_or_query": "سعی کنید جست‌و‌جوی خود را وسیع‌تر کنید و/یا فیلترها را تغییر دهید.",
"Chinese (China)": "چینی (چین)",
"German (auto-generated)": "آلمانی (تولید خودکار)"
} }

View File

@ -324,9 +324,9 @@
"`x` marked it with a ❤": "`x` merkkasi ❤:llä", "`x` marked it with a ❤": "`x` merkkasi ❤:llä",
"Audio mode": "Äänitila", "Audio mode": "Äänitila",
"Video mode": "Videotila", "Video mode": "Videotila",
"Videos": "Videot", "channel_tab_videos_label": "Videot",
"Playlists": "Soittolistat", "Playlists": "Soittolistat",
"Community": "Yhteisö", "channel_tab_community_label": "Yhteisö",
"search_filters_sort_option_relevance": "Osuvuus", "search_filters_sort_option_relevance": "Osuvuus",
"search_filters_sort_option_rating": "Arvostelu", "search_filters_sort_option_rating": "Arvostelu",
"search_filters_sort_option_date": "Latauspäivämäärä", "search_filters_sort_option_date": "Latauspäivämäärä",
@ -471,5 +471,6 @@
"search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.", "search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
"search_filters_date_option_none": "Milloin tahansa", "search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi", "search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: " "Popular enabled: ": "Suosittu käytössä: ",
"error_video_not_in_playlist": "Pyydettyä videota ei löydy tästä soittolistasta. <a href=\"`x`\">Klikkaa tähän päästäksesi soittolistan etusivulle.</a>"
} }

View File

@ -358,9 +358,9 @@
"`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", "channel_tab_videos_label": "Vidéos",
"Playlists": "Listes de lecture", "Playlists": "Listes de lecture",
"Community": "Communauté", "channel_tab_community_label": "Communauté",
"search_filters_sort_option_relevance": "Pertinence", "search_filters_sort_option_relevance": "Pertinence",
"search_filters_sort_option_rating": "Notation", "search_filters_sort_option_rating": "Notation",
"search_filters_sort_option_date": "Date d'ajout", "search_filters_sort_option_date": "Date d'ajout",

View File

@ -271,9 +271,9 @@
"`x` marked it with a ❤": "סומנה ב־❤ על ידי `x`", "`x` marked it with a ❤": "סומנה ב־❤ על ידי `x`",
"Audio mode": "Audio mode", "Audio mode": "Audio mode",
"Video mode": "Video mode", "Video mode": "Video mode",
"Videos": "סרטונים", "channel_tab_videos_label": "סרטונים",
"Playlists": "פלייליסטים", "Playlists": "פלייליסטים",
"Community": "קהילה", "channel_tab_community_label": "קהילה",
"search_filters_sort_option_relevance": "רלוונטיות", "search_filters_sort_option_relevance": "רלוונטיות",
"search_filters_sort_option_rating": "דירוג", "search_filters_sort_option_rating": "דירוג",
"search_filters_sort_option_date": "תאריך העלאה", "search_filters_sort_option_date": "תאריך העלאה",

View File

@ -401,12 +401,12 @@
"(edited)": "(संपादित)", "(edited)": "(संपादित)",
"YouTube comment permalink": "YouTube पर टिप्पणी की स्थायी कड़ी", "YouTube comment permalink": "YouTube पर टिप्पणी की स्थायी कड़ी",
"permalink": "स्थायी कड़ी", "permalink": "स्थायी कड़ी",
"Videos": "वीडियो", "channel_tab_videos_label": "वीडियो",
"`x` marked it with a ❤": "`x` ने इसे एक ❤ से चिह्नित किया", "`x` marked it with a ❤": "`x` ने इसे एक ❤ से चिह्नित किया",
"Audio mode": "ऑडियो मोड", "Audio mode": "ऑडियो मोड",
"Playlists": "प्लेलिस्ट्स", "Playlists": "प्लेलिस्ट्स",
"Video mode": "वीडियो मोड", "Video mode": "वीडियो मोड",
"Community": "समुदाय", "channel_tab_community_label": "समुदाय",
"search_filters_title": "फ़िल्टर", "search_filters_title": "फ़िल्टर",
"search_filters_date_label": "अपलोड करने का समय", "search_filters_date_label": "अपलोड करने का समय",
"search_filters_date_option_none": "कोई भी समय", "search_filters_date_option_none": "कोई भी समय",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "Označeno sa ❤ od `x`", "`x` marked it with a ❤": "Označeno sa ❤ od `x`",
"Audio mode": "Audio modus", "Audio mode": "Audio modus",
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Videa", "channel_tab_videos_label": "Videa",
"Playlists": "Zbirke", "Playlists": "Zbirke",
"Community": "Zajednica", "channel_tab_community_label": "Zajednica",
"search_filters_sort_option_relevance": "Značaj", "search_filters_sort_option_relevance": "Značaj",
"search_filters_sort_option_rating": "Ocjena", "search_filters_sort_option_rating": "Ocjena",
"search_filters_sort_option_date": "Datum prijenosa", "search_filters_sort_option_date": "Datum prijenosa",

View File

@ -348,9 +348,9 @@
"`x` marked it with a ❤": "`x` ❤jelet adott a hozzászóláshoz", "`x` marked it with a ❤": "`x` ❤jelet adott a hozzászóláshoz",
"Audio mode": "Csak hanggal", "Audio mode": "Csak hanggal",
"Video mode": "Hanggal és képpel", "Video mode": "Hanggal és képpel",
"Videos": "Videói", "channel_tab_videos_label": "Videói",
"Playlists": "Lejátszási listái", "Playlists": "Lejátszási listái",
"Community": "Közösség", "channel_tab_community_label": "Közösség",
"Current version: ": "Jelenlegi verzió: ", "Current version: ": "Jelenlegi verzió: ",
"preferences_quality_option_medium": "Közepes", "preferences_quality_option_medium": "Közepes",
"preferences_quality_dash_option_auto": "Automatikus", "preferences_quality_dash_option_auto": "Automatikus",
@ -470,5 +470,7 @@
"search_filters_duration_option_none": "Mindegy", "search_filters_duration_option_none": "Mindegy",
"search_filters_duration_option_medium": "Átlagos (4 és 20 perc között)", "search_filters_duration_option_medium": "Átlagos (4 és 20 perc között)",
"search_filters_features_option_vr180": "180°-os virtuális valóság", "search_filters_features_option_vr180": "180°-os virtuális valóság",
"search_filters_apply_button": "Keresés a megadott szűrőkkel" "search_filters_apply_button": "Keresés a megadott szűrőkkel",
"Popular enabled: ": "Népszerű engedélyezve ",
"error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>"
} }

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` telah ditandai dengan ❤", "`x` marked it with a ❤": "`x` telah ditandai dengan ❤",
"Audio mode": "Mode audio", "Audio mode": "Mode audio",
"Video mode": "Mode video", "Video mode": "Mode video",
"Videos": "Video", "channel_tab_videos_label": "Video",
"Playlists": "Daftar putar", "Playlists": "Daftar putar",
"Community": "Komunitas", "channel_tab_community_label": "Komunitas",
"search_filters_sort_option_relevance": "Relevansi", "search_filters_sort_option_relevance": "Relevansi",
"search_filters_sort_option_rating": "Penilaian", "search_filters_sort_option_rating": "Penilaian",
"search_filters_sort_option_date": "Tanggal Unggah", "search_filters_sort_option_date": "Tanggal Unggah",

View File

@ -315,9 +315,9 @@
"`x` marked it with a ❤": "`x` merkti það með ❤", "`x` marked it with a ❤": "`x` merkti það með ❤",
"Audio mode": "Hljóð ham", "Audio mode": "Hljóð ham",
"Video mode": "Myndband ham", "Video mode": "Myndband ham",
"Videos": "Myndbönd", "channel_tab_videos_label": "Myndbönd",
"Playlists": "Spilunarlistar", "Playlists": "Spilunarlistar",
"Community": "Samfélag", "channel_tab_community_label": "Samfélag",
"Current version: ": "Núverandi útgáfa: ", "Current version: ": "Núverandi útgáfa: ",
"preferences_watch_history_label": "Virkja áhorfssögu: " "preferences_watch_history_label": "Virkja áhorfssögu: "
} }

View File

@ -290,7 +290,7 @@
"Southern Sotho": "Sotho del Sud", "Southern Sotho": "Sotho del Sud",
"Spanish": "Spagnolo", "Spanish": "Spagnolo",
"Spanish (Latin America)": "Spagnolo (America latina)", "Spanish (Latin America)": "Spagnolo (America latina)",
"Sundanese": "Sudanese", "Sundanese": "Sundanese",
"Swahili": "Swahili", "Swahili": "Swahili",
"Swedish": "Svedese", "Swedish": "Svedese",
"Tajik": "Tagico", "Tajik": "Tagico",
@ -344,9 +344,9 @@
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio", "Audio mode": "Modalità audio",
"Video mode": "Modalità video", "Video mode": "Modalità video",
"Videos": "Video", "channel_tab_videos_label": "Video",
"Playlists": "Playlist", "Playlists": "Playlist",
"Community": "Comunità", "channel_tab_community_label": "Comunità",
"search_filters_sort_option_relevance": "Pertinenza", "search_filters_sort_option_relevance": "Pertinenza",
"search_filters_sort_option_rating": "Valutazione", "search_filters_sort_option_rating": "Valutazione",
"search_filters_sort_option_date": "Data di caricamento", "search_filters_sort_option_date": "Data di caricamento",

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` が❤を込めてマークしました", "`x` marked it with a ❤": "`x` が❤を込めてマークしました",
"Audio mode": "オーディオモード", "Audio mode": "オーディオモード",
"Video mode": "ビデオモード", "Video mode": "ビデオモード",
"Videos": "動画", "channel_tab_videos_label": "動画",
"Playlists": "プレイリスト", "Playlists": "プレイリスト",
"Community": "コミュニティ", "channel_tab_community_label": "コミュニティ",
"search_filters_sort_option_relevance": "関連", "search_filters_sort_option_relevance": "関連",
"search_filters_sort_option_rating": "評価", "search_filters_sort_option_rating": "評価",
"search_filters_sort_option_date": "時刻", "search_filters_sort_option_date": "時刻",
@ -403,7 +403,7 @@
"none": "なし", "none": "なし",
"download_subtitles": "字幕 - `x` (.vtt)", "download_subtitles": "字幕 - `x` (.vtt)",
"search_filters_features_option_purchased": "購入済み", "search_filters_features_option_purchased": "購入済み",
"preferences_quality_option_dash": "DASH (適切な品質)", "preferences_quality_option_dash": "DASH (適品質)",
"preferences_quality_dash_option_worst": "最悪", "preferences_quality_dash_option_worst": "最悪",
"preferences_quality_dash_option_best": "最高", "preferences_quality_dash_option_best": "最高",
"videoinfo_started_streaming_x_ago": "`x`分前に配信を開始", "videoinfo_started_streaming_x_ago": "`x`分前に配信を開始",
@ -438,5 +438,20 @@
"search_message_no_results": "一致する検索結果はありませんでした", "search_message_no_results": "一致する検索結果はありませんでした",
"English (United States)": "英語 (アメリカ)", "English (United States)": "英語 (アメリカ)",
"search_filters_date_label": "アップロード日", "search_filters_date_label": "アップロード日",
"search_filters_features_option_vr180": "VR180" "search_filters_features_option_vr180": "VR180",
"crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>しようとしました",
"crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
"Popular enabled: ": "人気動画を有効化 ",
"search_message_use_another_instance": " <a href=\"`x`\">別のインスタンスで検索</a>することもできます。",
"search_filters_apply_button": "選択したフィルターを適用",
"user_saved_playlists": "`x` 個の保存済みプレイリスト",
"crash_page_you_found_a_bug": "Invidious でバグを見つけたようです。",
"crash_page_refresh": "<a href=\"`x`\">ページを更新</a>しようとしました",
"preferences_watch_history_label": "視聴履歴を有効化 ",
"search_filters_date_option_none": "任意の日付",
"search_filters_type_option_all": "いかなるタイプ",
"search_filters_duration_option_none": "任意の期間",
"search_filters_duration_option_medium": "ミディアム (4 20 分)",
"preferences_save_player_pos_label": "再生位置を保存: ",
"crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。"
} }

View File

@ -2,7 +2,7 @@
"preferences_sort_label": "동영상 정렬 기준: ", "preferences_sort_label": "동영상 정렬 기준: ",
"preferences_max_results_label": "피드에 표시된 동영상 수: ", "preferences_max_results_label": "피드에 표시된 동영상 수: ",
"Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ", "Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ",
"preferences_annotations_subscribed_label": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ", "preferences_annotations_subscribed_label": "구독한 채널에 기본으로 주석 표시: ",
"preferences_category_subscription": "구독 설정", "preferences_category_subscription": "구독 설정",
"preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ",
"preferences_thin_mode_label": "단순 모드: ", "preferences_thin_mode_label": "단순 모드: ",
@ -25,8 +25,8 @@
"preferences_quality_label": "선호하는 비디오 품질: ", "preferences_quality_label": "선호하는 비디오 품질: ",
"preferences_speed_label": "기본 속도: ", "preferences_speed_label": "기본 속도: ",
"preferences_local_label": "비디오를 프록시: ", "preferences_local_label": "비디오를 프록시: ",
"preferences_listen_label": "라디오 모드 활성화: ", "preferences_listen_label": "라디오 모드: ",
"preferences_continue_autoplay_label": "다음 동영상 자동재생 ", "preferences_continue_autoplay_label": "다음 동영상 자동재생: ",
"preferences_continue_label": "다음 동영상으로 이동: ", "preferences_continue_label": "다음 동영상으로 이동: ",
"preferences_autoplay_label": "자동재생: ", "preferences_autoplay_label": "자동재생: ",
"preferences_video_loop_label": "항상 반복: ", "preferences_video_loop_label": "항상 반복: ",
@ -37,8 +37,8 @@
"Register": "회원가입", "Register": "회원가입",
"Sign In": "로그인", "Sign In": "로그인",
"preferences_category_misc": "기타 설정", "preferences_category_misc": "기타 설정",
"Image CAPTCHA": "이미지 CAPTCHA", "Image CAPTCHA": "이미지 캡차",
"Text CAPTCHA": "텍스트 CAPTCHA", "Text CAPTCHA": "텍스트 캡차",
"Time (h:mm:ss):": "시각 (h:mm:ss):", "Time (h:mm:ss):": "시각 (h:mm:ss):",
"Password": "비밀번호", "Password": "비밀번호",
"User ID": "사용자 ID", "User ID": "사용자 ID",
@ -50,15 +50,15 @@
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
"History": "역사", "History": "역사",
"Delete account?": "계정을 삭제 하시겠습니까?", "Delete account?": "계정을 삭제 하시겠습니까?",
"Export data as JSON": "데이터를 JSON으로 내보내기", "Export data as JSON": "JSON으로 데이터 내보내기",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "구독을 OPML로 내보내기 (NewPipe 및 FreeTube 용)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)",
"Export subscriptions as OPML": "구독을 OPML로 내보내기", "Export subscriptions as OPML": "OPML로 구독 내보내기",
"Export": "내보내기", "Export": "내보내기",
"Import NewPipe data (.zip)": "NewPipe 데이터 가져오기 (.zip)", "Import NewPipe data (.zip)": "뉴파이프 데이터 가져오기 (.zip)",
"Import NewPipe subscriptions (.json)": "NewPipe 구독을 가져오기 (.json)", "Import NewPipe subscriptions (.json)": "뉴파이프 구독 가져오기 (.json)",
"Import FreeTube subscriptions (.db)": "FreeTube 구독 가져오기 (.db)", "Import FreeTube subscriptions (.db)": "프리튜브 구독 가져오기 (.db)",
"Import YouTube subscriptions": "유튜브 구독 가져오기", "Import YouTube subscriptions": "유튜브 구독 가져오기",
"Import Invidious data": "인비디어스 JSON 데이터 가져오기", "Import Invidious data": "인비디어스 데이터 가져오기 (.json)",
"Import": "가져오기", "Import": "가져오기",
"Import and Export Data": "데이터 가져오기 및 내보내기", "Import and Export Data": "데이터 가져오기 및 내보내기",
"No": "아니요", "No": "아니요",
@ -152,7 +152,7 @@
"Report statistics: ": "통계 보고: ", "Report statistics: ": "통계 보고: ",
"Registration enabled: ": "등록 활성화: ", "Registration enabled: ": "등록 활성화: ",
"Login enabled: ": "로그인 활성화: ", "Login enabled: ": "로그인 활성화: ",
"CAPTCHA enabled: ": "CAPTCHA 활성화: ", "CAPTCHA enabled: ": "캡차 활성화: ",
"Top enabled: ": "Top 활성화: ", "Top enabled: ": "Top 활성화: ",
"preferences_show_nick_label": "상단에 닉네임 표시: ", "preferences_show_nick_label": "상단에 닉네임 표시: ",
"preferences_feed_menu_label": "피드 메뉴: ", "preferences_feed_menu_label": "피드 메뉴: ",
@ -284,10 +284,10 @@
"Password cannot be empty": "비밀번호는 비워둘 수 없습니다", "Password cannot be empty": "비밀번호는 비워둘 수 없습니다",
"Please sign in using 'Log in with Google'": "'구글로 로그인'을 사용하여 로그인하세요", "Please sign in using 'Log in with Google'": "'구글로 로그인'을 사용하여 로그인하세요",
"Wrong username or password": "잘못된 사용자 이름 또는 비밀번호", "Wrong username or password": "잘못된 사용자 이름 또는 비밀번호",
"Password is a required field": "비밀번호는 필수 필드입니다", "Password is a required field": "비밀번호는 필수 입력란입니다",
"User ID is a required field": "사용자 ID는 필수 필드입니다", "User ID is a required field": "사용자 ID는 필수 입력란입니다",
"CAPTCHA is a required field": "CAPTCHA는 필수 필드입니다", "CAPTCHA is a required field": "캡차는 필수 입력란입니다",
"Erroneous CAPTCHA": "잘못된 CAPTCHA", "Erroneous CAPTCHA": "잘못된 캡차",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "로그인 실패. 계정에 이중 인증이 설정되어 있지 않기 때문일 수 있습니다.", "Login failed. This may be because two-factor authentication is not turned on for your account.": "로그인 실패. 계정에 이중 인증이 설정되어 있지 않기 때문일 수 있습니다.",
"Blacklisted regions: ": "차단된 지역: ", "Blacklisted regions: ": "차단된 지역: ",
"Playlists": "재생목록", "Playlists": "재생목록",
@ -297,7 +297,7 @@
"Empty playlist": "재생목록 비어 있음", "Empty playlist": "재생목록 비어 있음",
"Show annotations": "주석 보이기", "Show annotations": "주석 보이기",
"Hide annotations": "주석 숨기기", "Hide annotations": "주석 숨기기",
"Switch Invidious Instance": "Invidious 인스턴스 변경", "Switch Invidious Instance": "인비디어스 인스턴스 변경",
"Spanish": "스페인어", "Spanish": "스페인어",
"Southern Sotho": "소토어", "Southern Sotho": "소토어",
"Somali": "소말리어", "Somali": "소말리어",
@ -347,8 +347,8 @@
"search_filters_sort_option_date": "업로드 날짜", "search_filters_sort_option_date": "업로드 날짜",
"search_filters_sort_option_rating": "평점", "search_filters_sort_option_rating": "평점",
"search_filters_sort_option_relevance": "관련성", "search_filters_sort_option_relevance": "관련성",
"Community": "커뮤니티", "channel_tab_community_label": "커뮤니티",
"Videos": "동영상", "channel_tab_videos_label": "동영상",
"Video mode": "비디오 모드", "Video mode": "비디오 모드",
"Audio mode": "오디오 모드", "Audio mode": "오디오 모드",
"permalink": "퍼머링크", "permalink": "퍼머링크",
@ -383,7 +383,7 @@
"adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL", "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL",
"search_filters_title": "필터", "search_filters_title": "필터",
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320p",
"Popular enabled: ": "인기 급상승 활성화: ", "Popular enabled: ": "인기 활성화: ",
"Dutch (auto-generated)": "네덜란드어 (자동 생성됨)", "Dutch (auto-generated)": "네덜란드어 (자동 생성됨)",
"Chinese (Hong Kong)": "중국어 (홍콩)", "Chinese (Hong Kong)": "중국어 (홍콩)",
"Chinese (Taiwan)": "중국어 (대만)", "Chinese (Taiwan)": "중국어 (대만)",
@ -415,7 +415,7 @@
"Spanish (auto-generated)": "스페인어 (자동 생성됨)", "Spanish (auto-generated)": "스페인어 (자동 생성됨)",
"preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_worst": "최저", "preferences_quality_dash_option_worst": "최저",
"preferences_watch_history_label": "시청 기록 활성화: ", "preferences_watch_history_label": "시청 기록 저장: ",
"invidious": "인비디어스", "invidious": "인비디어스",
"preferences_quality_option_small": "낮음", "preferences_quality_option_small": "낮음",
"preferences_quality_dash_option_auto": "자동", "preferences_quality_dash_option_auto": "자동",
@ -439,7 +439,7 @@
"footer_donate_page": "기부하기", "footer_donate_page": "기부하기",
"preferences_quality_option_dash": "DASH (다양한 화질)", "preferences_quality_option_dash": "DASH (다양한 화질)",
"preferences_quality_dash_option_360p": "360p", "preferences_quality_dash_option_360p": "360p",
"preferences_save_player_pos_label": "이어서 보기 활성화: ", "preferences_save_player_pos_label": "이어서 보기: ",
"none": "없음", "none": "없음",
"videoinfo_started_streaming_x_ago": "`x` 전에 스트리밍을 시작했습니다", "videoinfo_started_streaming_x_ago": "`x` 전에 스트리밍을 시작했습니다",
"crash_page_you_found_a_bug": "Invidious에서 버그를 찾은 것 같습니다!", "crash_page_you_found_a_bug": "Invidious에서 버그를 찾은 것 같습니다!",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "`x` pažymėjo tai su ❤", "`x` marked it with a ❤": "`x` pažymėjo tai su ❤",
"Audio mode": "Garso rėžimas", "Audio mode": "Garso rėžimas",
"Video mode": "Vaizdo rėžimas", "Video mode": "Vaizdo rėžimas",
"Videos": "Vaizdo įrašai", "channel_tab_videos_label": "Vaizdo įrašai",
"Playlists": "Grojaraiščiai", "Playlists": "Grojaraiščiai",
"Community": "Bendruomenė", "channel_tab_community_label": "Bendruomenė",
"search_filters_sort_option_relevance": "Aktualumas", "search_filters_sort_option_relevance": "Aktualumas",
"search_filters_sort_option_rating": "Reitingas", "search_filters_sort_option_rating": "Reitingas",
"search_filters_sort_option_date": "Įkėlimo data", "search_filters_sort_option_date": "Įkėlimo data",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "`x` levnet et ❤", "`x` marked it with a ❤": "`x` levnet et ❤",
"Audio mode": "Lydmodus", "Audio mode": "Lydmodus",
"Video mode": "Video-modus", "Video mode": "Video-modus",
"Videos": "Videoer", "channel_tab_videos_label": "Videoer",
"Playlists": "Spillelister", "Playlists": "Spillelister",
"Community": "Gemenskap", "channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "relevans", "search_filters_sort_option_relevance": "relevans",
"search_filters_sort_option_rating": "vurdering", "search_filters_sort_option_rating": "vurdering",
"search_filters_sort_option_date": "dato", "search_filters_sort_option_date": "dato",

View File

@ -320,9 +320,9 @@
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤", "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
"Audio mode": "Audiomodus", "Audio mode": "Audiomodus",
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Video's", "channel_tab_videos_label": "Video's",
"Playlists": "Afspeellijsten", "Playlists": "Afspeellijsten",
"Community": "Gemeenschap", "channel_tab_community_label": "Gemeenschap",
"search_filters_sort_option_relevance": "relevantie", "search_filters_sort_option_relevance": "relevantie",
"search_filters_sort_option_rating": "beoordeling", "search_filters_sort_option_rating": "beoordeling",
"search_filters_sort_option_date": "datum", "search_filters_sort_option_date": "datum",

1
locales/or.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -324,9 +324,9 @@
"`x` marked it with a ❤": "`x` oznaczonych ❤", "`x` marked it with a ❤": "`x` oznaczonych ❤",
"Audio mode": "Tryb audio", "Audio mode": "Tryb audio",
"Video mode": "Tryb wideo", "Video mode": "Tryb wideo",
"Videos": "Filmy", "channel_tab_videos_label": "Filmy",
"Playlists": "Playlisty", "Playlists": "Playlisty",
"Community": "Społeczność", "channel_tab_community_label": "Społeczność",
"search_filters_sort_option_relevance": "Trafność", "search_filters_sort_option_relevance": "Trafność",
"search_filters_sort_option_rating": "Ocena", "search_filters_sort_option_rating": "Ocena",
"search_filters_sort_option_date": "Data przesłania", "search_filters_sort_option_date": "Data przesłania",

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` foi marcado como ❤", "`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio", "Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Videos": "Vídeos", "channel_tab_videos_label": "Vídeos",
"Playlists": "Listas de reprodução", "Playlists": "Listas de reprodução",
"Community": "Comunidade", "channel_tab_community_label": "Comunidade",
"search_filters_sort_option_relevance": "relevância", "search_filters_sort_option_relevance": "relevância",
"search_filters_sort_option_rating": "avaliação", "search_filters_sort_option_rating": "avaliação",
"search_filters_sort_option_date": "data", "search_filters_sort_option_date": "data",
@ -471,5 +471,6 @@
"Turkish (auto-generated)": "Turco (gerado automaticamente)", "Turkish (auto-generated)": "Turco (gerado automaticamente)",
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)", "search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"Popular enabled: ": "Popular habilitado: " "Popular enabled: ": "Popular habilitado: ",
"error_video_not_in_playlist": "O vídeo solicitado não existe nesta playlist. <a href=\"`x`\">Clique aqui para acessar a página inicial da playlist.</a>"
} }

View File

@ -22,14 +22,14 @@
"Import and Export Data": "Importar e exportar dados", "Import and Export Data": "Importar e exportar dados",
"Import": "Importar", "Import": "Importar",
"Import Invidious data": "Importar dados JSON do Invidious", "Import Invidious data": "Importar dados JSON do Invidious",
"Import YouTube subscriptions": "Importar subscrições OPML ou do YouTube", "Import YouTube subscriptions": "Importar subscrições do YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
"Export": "Exportar", "Export": "Exportar",
"Export subscriptions as OPML": "Exportar subscrições como OPML", "Export subscriptions as OPML": "Exportar subscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados do Invidious como JSON", "Export data as JSON": "Exportar dados Invidious como JSON",
"Delete account?": "Eliminar conta?", "Delete account?": "Eliminar conta?",
"History": "Histórico", "History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` foi marcado como ❤", "`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio", "Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Videos": "Vídeos", "channel_tab_videos_label": "Vídeos",
"Playlists": "Listas de reprodução", "Playlists": "Listas de reprodução",
"Community": "Comunidade", "channel_tab_community_label": "Comunidade",
"search_filters_sort_option_relevance": "Relevância", "search_filters_sort_option_relevance": "Relevância",
"search_filters_sort_option_rating": "Avaliação", "search_filters_sort_option_rating": "Avaliação",
"search_filters_sort_option_date": "Data de envio", "search_filters_sort_option_date": "Data de envio",
@ -379,24 +379,24 @@
"generic_videos_count_plural": "{{count}} vídeos", "generic_videos_count_plural": "{{count}} vídeos",
"generic_playlists_count": "{{count}} lista de reprodução", "generic_playlists_count": "{{count}} lista de reprodução",
"generic_playlists_count_plural": "{{count}} listas de reprodução", "generic_playlists_count_plural": "{{count}} listas de reprodução",
"generic_subscriptions_count": "{{count}} subscrição", "generic_subscriptions_count": "{{count}} inscrição",
"generic_subscriptions_count_plural": "{{count}} subscrições", "generic_subscriptions_count_plural": "{{count}} inscrições",
"generic_views_count": "{{count}} visualização", "generic_views_count": "{{count}} visualização",
"generic_views_count_plural": "{{count}} visualizações", "generic_views_count_plural": "{{count}} visualizações",
"generic_subscribers_count": "{{count}} subscritor", "generic_subscribers_count": "{{count}} inscrito",
"generic_subscribers_count_plural": "{{count}} subscritores", "generic_subscribers_count_plural": "{{count}} inscritos",
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferencial ", "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
"preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_2160p": "2160p",
"subscriptions_unseen_notifs_count": "{{count}} notificação por ver", "subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
"subscriptions_unseen_notifs_count_plural": "{{count}} notificações por ver", "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
"Popular enabled: ": "Página \"Popular\" ativada: ", "Popular enabled: ": "Página \"popular\" ativada: ",
"search_message_no_results": "Nenhum resultado encontrado.", "search_message_no_results": "Nenhum resultado encontrado.",
"preferences_quality_dash_option_auto": "Automática", "preferences_quality_dash_option_auto": "Automático",
"preferences_region_label": "País para o conteúdo: ", "preferences_region_label": "País do conteúdo: ",
"preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_720p": "720p", "preferences_quality_dash_option_720p": "720p",
"preferences_watch_history_label": "Ativar histórico de visualizações ", "preferences_watch_history_label": "Ativar histórico de reprodução: ",
"preferences_quality_dash_option_best": "Melhor", "preferences_quality_dash_option_best": "Melhor",
"preferences_quality_dash_option_worst": "Pior", "preferences_quality_dash_option_worst": "Pior",
"preferences_quality_dash_option_144p": "144p", "preferences_quality_dash_option_144p": "144p",
@ -404,13 +404,13 @@
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_option_dash": "DASH (qualidade adaptativa)", "preferences_quality_option_dash": "DASH (qualidade adaptativa)",
"preferences_quality_option_medium": "Média", "preferences_quality_option_medium": "Média",
"preferences_quality_option_small": "Pequena", "preferences_quality_option_small": "Baixa",
"preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_480p": "480p",
"preferences_quality_dash_option_360p": "360p", "preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_240p": "240p",
"Video unavailable": "Vídeo indisponível", "Video unavailable": "Vídeo não disponível",
"Russian (auto-generated)": "Russo (geradas automaticamente)", "Russian (auto-generated)": "Russo (gerado automaticamente)",
"comments_view_x_replies": "Ver {{count}} resposta", "comments_view_x_replies": "Ver {{count}} resposta",
"comments_view_x_replies_plural": "Ver {{count}} respostas", "comments_view_x_replies_plural": "Ver {{count}} respostas",
"comments_points_count": "{{count}} ponto", "comments_points_count": "{{count}} ponto",
@ -418,18 +418,18 @@
"English (United Kingdom)": "Inglês (Reino Unido)", "English (United Kingdom)": "Inglês (Reino Unido)",
"Chinese (Hong Kong)": "Chinês (Hong Kong)", "Chinese (Hong Kong)": "Chinês (Hong Kong)",
"Chinese (Taiwan)": "Chinês (Taiwan)", "Chinese (Taiwan)": "Chinês (Taiwan)",
"Dutch (auto-generated)": "Holandês (geradas automaticamente)", "Dutch (auto-generated)": "Holandês (gerado automaticamente)",
"French (auto-generated)": "Francês (geradas automaticamente)", "French (auto-generated)": "Francês (gerado automaticamente)",
"German (auto-generated)": "Alemão (geradas automaticamente)", "German (auto-generated)": "Alemão (gerado automaticamente)",
"Indonesian (auto-generated)": "Indonésio (geradas automaticamente)", "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)",
"Interlingue": "Interlingue", "Interlingue": "Interlíngua",
"Italian (auto-generated)": "Italiano (geradas automaticamente)", "Italian (auto-generated)": "Italiano (gerado automaticamente)",
"Japanese (auto-generated)": "Japonês (geradas automaticamente)", "Japanese (auto-generated)": "Japonês (gerado automaticamente)",
"Korean (auto-generated)": "Coreano (geradas automaticamente)", "Korean (auto-generated)": "Coreano (gerado automaticamente)",
"Portuguese (auto-generated)": "Português (geradas automaticamente)", "Portuguese (auto-generated)": "Português (gerado automaticamente)",
"Portuguese (Brazil)": "Português (Brasil)", "Portuguese (Brazil)": "Português (Brasil)",
"Spanish (Spain)": "Espanhol (Espanha)", "Spanish (Spain)": "Espanhol (Espanha)",
"Vietnamese (auto-generated)": "Vietnamita (geradas automaticamente)", "Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)",
"search_filters_type_option_all": "Qualquer tipo", "search_filters_type_option_all": "Qualquer tipo",
"search_filters_duration_option_none": "Qualquer duração", "search_filters_duration_option_none": "Qualquer duração",
"search_filters_duration_option_short": "Curto (< 4 minutos)", "search_filters_duration_option_short": "Curto (< 4 minutos)",
@ -438,29 +438,39 @@
"search_filters_features_option_purchased": "Comprado", "search_filters_features_option_purchased": "Comprado",
"search_filters_apply_button": "Aplicar filtros selecionados", "search_filters_apply_button": "Aplicar filtros selecionados",
"videoinfo_watch_on_youTube": "Ver no YouTube", "videoinfo_watch_on_youTube": "Ver no YouTube",
"videoinfo_youTube_embed_link": "Embutir", "videoinfo_youTube_embed_link": "Incorporar",
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte modificado", "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
"videoinfo_invidious_embed_link": "Ligação embutida", "videoinfo_invidious_embed_link": "Incorporar hiperligação",
"none": "nenhum", "none": "nenhum",
"videoinfo_started_streaming_x_ago": "Entrou em direto há `x`", "videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
"download_subtitles": "Legendas - `x` (.vtt)", "download_subtitles": "Legendas - `x` (.vtt)",
"user_created_playlists": "`x` listas de reprodução criadas", "user_created_playlists": "`x` listas de reprodução criadas",
"user_saved_playlists": "`x` listas de reprodução guardadas", "user_saved_playlists": "`x` listas de reprodução guardadas",
"preferences_save_player_pos_label": "Guardar posição de reprodução: ", "preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ",
"Turkish (auto-generated)": "Turco (geradas automaticamente)", "Turkish (auto-generated)": "Turco (gerado automaticamente)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)", "Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
"Chinese (China)": "Chinês (China)", "Chinese (China)": "Chinês (China)",
"Spanish (auto-generated)": "Espanhol (geradas automaticamente)", "Spanish (auto-generated)": "Espanhol (gerado automaticamente)",
"Spanish (Mexico)": "Espanhol (México)", "Spanish (Mexico)": "Espanhol (México)",
"English (United States)": "Inglês (Estados Unidos)", "English (United States)": "Inglês (Estados Unidos)",
"footer_donate_page": "Doar", "footer_donate_page": "Doar",
"footer_documentation": "Documentação", "footer_documentation": "Documentação",
"footer_source_code": "Código-fonte", "footer_source_code": "Código-fonte",
"footer_original_source_code": "Código-fonte original", "footer_original_source_code": "Código-fonte original",
"footer_modfied_source_code": "Código-fonte modificado", "footer_modfied_source_code": "Código-fonte alterado",
"Chinese": "Chinês", "Chinese": "Chinês",
"search_filters_date_label": "Data de carregamento", "search_filters_date_label": "Data de publicação",
"search_filters_date_option_none": "Qualquer data", "search_filters_date_option_none": "Qualquer data",
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180" "search_filters_features_option_vr180": "VR180",
"search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
"crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
"crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
"crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>",
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
"crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
"crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
"error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>"
} }

View File

@ -267,9 +267,9 @@
"Next page": "Próxima página", "Next page": "Próxima página",
"last": "últimos", "last": "últimos",
"Current version: ": "Versão atual: ", "Current version: ": "Versão atual: ",
"Community": "Comunidade", "channel_tab_community_label": "Comunidade",
"Playlists": "Listas de reprodução", "Playlists": "Listas de reprodução",
"Videos": "Vídeos", "channel_tab_videos_label": "Vídeos",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Audio mode": "Modo de áudio", "Audio mode": "Modo de áudio",
"`x` marked it with a ❤": "`x` foi marcado como ❤", "`x` marked it with a ❤": "`x` foi marcado como ❤",

View File

@ -315,9 +315,9 @@
"`x` marked it with a ❤": "`x` l-a marcat cu o ❤", "`x` marked it with a ❤": "`x` l-a marcat cu o ❤",
"Audio mode": "Mod audio", "Audio mode": "Mod audio",
"Video mode": "Mod video", "Video mode": "Mod video",
"Videos": "Videoclipuri", "channel_tab_videos_label": "Videoclipuri",
"Playlists": "Liste de redare", "Playlists": "Liste de redare",
"Community": "Comunitate", "channel_tab_community_label": "Comunitate",
"Current version: ": "Versiunea actuală: ", "Current version: ": "Versiunea actuală: ",
"crash_page_read_the_faq": "citit lista <a href=\"`x`\">Întrebărilor Frecvente (FAQ)</a>", "crash_page_read_the_faq": "citit lista <a href=\"`x`\">Întrebărilor Frecvente (FAQ)</a>",
"generic_count_days_0": "{{count}} zi", "generic_count_days_0": "{{count}} zi",

View File

@ -325,9 +325,9 @@
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим", "Audio mode": "Аудио режим",
"Video mode": "Видео режим", "Video mode": "Видео режим",
"Videos": "Видео", "channel_tab_videos_label": "Видео",
"Playlists": "Плейлисты", "Playlists": "Плейлисты",
"Community": "Сообщество", "channel_tab_community_label": "Сообщество",
"search_filters_sort_option_relevance": "по актуальности", "search_filters_sort_option_relevance": "по актуальности",
"search_filters_sort_option_rating": "по рейтингу", "search_filters_sort_option_rating": "по рейтингу",
"search_filters_sort_option_date": "по дате загрузки", "search_filters_sort_option_date": "по дате загрузки",

View File

@ -222,7 +222,7 @@
"About": "O aplikaciji", "About": "O aplikaciji",
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"Audio mode": "Avdio način", "Audio mode": "Avdio način",
"Videos": "Videoposnetki", "channel_tab_videos_label": "Videoposnetki",
"search_filters_date_label": "Datum nalaganja", "search_filters_date_label": "Datum nalaganja",
"search_filters_date_option_today": "Danes", "search_filters_date_option_today": "Danes",
"search_filters_date_option_week": "Ta teden", "search_filters_date_option_week": "Ta teden",
@ -455,7 +455,7 @@
"Download": "Prenesi", "Download": "Prenesi",
"permalink": "stalna povezava", "permalink": "stalna povezava",
"`x` marked it with a ❤": "`x` ga je označil/a z ❤", "`x` marked it with a ❤": "`x` ga je označil/a z ❤",
"Community": "Skupnost", "channel_tab_community_label": "Skupnost",
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"Video mode": "Video način", "Video mode": "Video način",
"search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_c_commons": "Creative Commons",

View File

@ -259,10 +259,10 @@
"YouTube comment permalink": "Permalidhje komenti YouTube", "YouTube comment permalink": "Permalidhje komenti YouTube",
"Audio mode": "Mënyrë për audion", "Audio mode": "Mënyrë për audion",
"Playlists": "Luajlista", "Playlists": "Luajlista",
"Community": "Bashkësi", "channel_tab_community_label": "Bashkësi",
"search_filters_sort_option_relevance": "Rëndësi", "search_filters_sort_option_relevance": "Rëndësi",
"Video mode": "Mënyrë video", "Video mode": "Mënyrë video",
"Videos": "Video", "channel_tab_videos_label": "Video",
"search_filters_sort_option_rating": "Vlerësim", "search_filters_sort_option_rating": "Vlerësim",
"search_filters_sort_option_date": "Datë ngarkimi", "search_filters_sort_option_date": "Datë ngarkimi",
"search_filters_sort_option_views": "Numër parjesh", "search_filters_sort_option_views": "Numër parjesh",
@ -446,6 +446,22 @@
"Import YouTube subscriptions": "Importoni pajtime YouTube/OPML", "Import YouTube subscriptions": "Importoni pajtime YouTube/OPML",
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON", "Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ", "preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
"Shared `x`": "Ndau me të tjerë `x`", "Shared `x`": "Ndarë me të tjerë më `x`",
"search_filters_title": "Filtra" "search_filters_title": "Filtra",
"Popular enabled: ": "Me populloret të aktivizuara: ",
"error_video_not_in_playlist": "Videoja e kërkuar sekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
"search_message_use_another_instance": " Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
"search_filters_date_label": "Datë ngarkimi",
"preferences_watch_history_label": "Aktivizo historik parjesh: ",
"Top enabled: ": "Me kryesueset të aktivizuara: ",
"preferences_video_loop_label": "Përsërite gjithmonë: ",
"search_message_no_results": "Su gjetën përfundime.",
"Could not pull trending pages.": "Su morën dot faqet në modë.",
"search_filters_date_option_none": "Çfarëdo date",
"search_message_change_filters_or_query": "Provoni të zgjeroni kërkesën tuaj të kërkimit dhe/ose të ndryshoni filtrat.",
"search_filters_type_option_all": "Çfarëdo lloji",
"search_filters_duration_option_none": "Çfarëdo kohëzgjatjeje",
"search_filters_duration_option_medium": "Mesatare (4 - 20 minuta)",
"search_filters_features_option_vr180": "VR180",
"search_filters_apply_button": "Apliko filtrat e përzgjedhur"
} }

View File

@ -257,7 +257,7 @@
"preferences_volume_label": "Jačina zvuka: ", "preferences_volume_label": "Jačina zvuka: ",
"preferences_locale_label": "Jezik: ", "preferences_locale_label": "Jezik: ",
"adminprefs_modified_source_code_url_label": "URL veza do skladišta sa Izmenjenom Izvornom Kodom", "adminprefs_modified_source_code_url_label": "URL veza do skladišta sa Izmenjenom Izvornom Kodom",
"Community": "Zajednica", "channel_tab_community_label": "Zajednica",
"Video mode": "Video mod", "Video mode": "Video mod",
"Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ", "Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ",
"Private": "Privatno", "Private": "Privatno",
@ -289,7 +289,7 @@
"Erroneous token": "Pogrešan žeton", "Erroneous token": "Pogrešan žeton",
"Czech": "Češki", "Czech": "Češki",
"Latin": "Latinski", "Latin": "Latinski",
"Videos": "Video klipovi", "channel_tab_videos_label": "Video klipovi",
"search_filters_features_option_four_k": "4К", "search_filters_features_option_four_k": "4К",
"footer_donate_page": "Doniraj", "footer_donate_page": "Doniraj",
"English": "Engleski", "English": "Engleski",

View File

@ -245,7 +245,7 @@
"(edited)": "(измењено)", "(edited)": "(измењено)",
"`x` marked it with a ❤": "`x` је означио/ла ово са ❤", "`x` marked it with a ❤": "`x` је означио/ла ово са ❤",
"Audio mode": "Аудио мод", "Audio mode": "Аудио мод",
"Videos": "Видео клипови", "channel_tab_videos_label": "Видео клипови",
"search_filters_sort_option_views": "Број прегледа", "search_filters_sort_option_views": "Број прегледа",
"search_filters_features_label": "Карактеристике", "search_filters_features_label": "Карактеристике",
"search_filters_date_option_today": "Данас", "search_filters_date_option_today": "Данас",
@ -298,7 +298,7 @@
"Ukrainian": "Украјински", "Ukrainian": "Украјински",
"permalink": "трајна веза", "permalink": "трајна веза",
"Pashto": "Паштунски", "Pashto": "Паштунски",
"Community": "Заједница", "channel_tab_community_label": "Заједница",
"Sindhi": "Синди", "Sindhi": "Синди",
"Could not fetch comments": "Узимање коментара није успело", "Could not fetch comments": "Узимање коментара није успело",
"Bangla": "Бангла/Бенгалски", "Bangla": "Бангла/Бенгалски",

View File

@ -323,9 +323,9 @@
"`x` marked it with a ❤": "`x` lämnade ett ❤", "`x` marked it with a ❤": "`x` lämnade ett ❤",
"Audio mode": "Ljudläge", "Audio mode": "Ljudläge",
"Video mode": "Videoläge", "Video mode": "Videoläge",
"Videos": "Videor", "channel_tab_videos_label": "Videor",
"Playlists": "Spellistor", "Playlists": "Spellistor",
"Community": "Gemenskap", "channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "Relevans", "search_filters_sort_option_relevance": "Relevans",
"search_filters_sort_option_rating": "Rankning", "search_filters_sort_option_rating": "Rankning",
"search_filters_sort_option_date": "Datum", "search_filters_sort_option_date": "Datum",

View File

@ -1,126 +1,126 @@
{ {
"LIVE": "CANLI", "LIVE": "CANLI",
"Shared `x` ago": "`x` önce paylaşıldı", "Shared `x` ago": "`x` Önce Paylaşıldı",
"Unsubscribe": "Abonelikten çık", "Unsubscribe": "Abonelikten Çık",
"Subscribe": "Abone ol", "Subscribe": "Abone Ol",
"View channel on YouTube": "Kanalı YouTube'da görüntüle", "View channel on YouTube": "Kanalı YouTube'da Görüntüle",
"View playlist on YouTube": "Oynatma listesini YouTube'da görüntüle", "View playlist on YouTube": "Oynatma Listesini YouTube'da Görüntüle",
"newest": "en yeni", "newest": "En Yeni",
"oldest": "en eski", "oldest": "En Eski",
"popular": "popüler", "popular": "Popüler",
"last": "son", "last": "Son",
"Next page": "Sonraki sayfa", "Next page": "Sonraki Sayfa",
"Previous page": "Önceki sayfa", "Previous page": "Önceki Sayfa",
"Clear watch history?": "İzleme geçmişi temizlensin mi?", "Clear watch history?": "İzleme geçmişi temizlensin mi?",
"New password": "Yeni parola", "New password": "Yeni Parola",
"New passwords must match": "Yeni parolalar eşleşmek zorunda", "New passwords must match": "Yeni Parolalar Eşleşmek Zorunda",
"Cannot change password for Google accounts": "Google hesapları için parola değiştirilemez", "Cannot change password for Google accounts": "Google Hesapları İçin Parola Değiştirilemez",
"Authorize token?": "Belirteç yetkilendirilsin mi?", "Authorize token?": "Belirteç yetkilendirilsin mi?",
"Authorize token for `x`?": "`x` için belirteç yetkilendirilsin mi?", "Authorize token for `x`?": "`x` için belirteç yetkilendirilsin mi?",
"Yes": "Evet", "Yes": "Evet",
"No": "Hayır", "No": "Hayır",
"Import and Export Data": "Verileri İçe ve Dışa Aktar", "Import and Export Data": "Verileri İçe ve Dışa Aktar",
"Import": "İçe aktar", "Import": "İçe Aktar",
"Import Invidious data": "İnvidious JSON verilerini içe aktar", "Import Invidious data": "Invidious JSON Verilerini İçe Aktar",
"Import YouTube subscriptions": "YouTube/OPML aboneliklerini içe aktar", "Import YouTube subscriptions": "YouTube/OPML Aboneliklerini İçe Aktar",
"Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)", "Import FreeTube subscriptions (.db)": "FreeTube Aboneliklerini İçe Aktar (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)", "Import NewPipe subscriptions (.json)": "NewPipe Aboneliklerini İçe Aktar (.json)",
"Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)", "Import NewPipe data (.zip)": "NewPipe Verilerini İçe Aktar (.zip)",
"Export": "Dışa aktar", "Export": "Dışa Aktar",
"Export subscriptions as OPML": "Abonelikleri OPML olarak 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 subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML Olarak Dışa Aktar (NewPipe ve FreeTube İçin)",
"Export data as JSON": "Invidious verilerini JSON olarak dışa aktar", "Export data as JSON": "İnvidious Verilerini JSON Olarak Dışa Aktar",
"Delete account?": "Hesap silinsin mi?", "Delete account?": "Hesap silinsin mi?",
"History": "Geçmiş", "History": "Geçmiş",
"An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz", "An alternative front-end to YouTube": "YouTube İçin Alternatif Bir Ön-Yüz",
"JavaScript license information": "JavaScript lisans bilgileri", "JavaScript license information": "JavaScript Lisans Bilgileri",
"source": "kaynak", "source": "Kaynak",
"Log in": "Oturum aç", "Log in": "Oturum Aç",
"Log in/register": "Oturum aç/kayıt ol", "Log in/register": "Oturum Aç/Kayıt Ol",
"Log in with Google": "Google ile oturum aç", "Log in with Google": "Google İle Oturum Aç",
"User ID": "Kullanıcı kimliği", "User ID": "Kullanıcı Kimliği",
"Password": "Parola", "Password": "Parola",
"Time (h:mm:ss):": "Zaman (h:mm:ss):", "Time (h:mm:ss):": "Zaman (h:mm:ss):",
"Text CAPTCHA": "Metin CAPTCHA", "Text CAPTCHA": "Metin CAPTCHA",
"Image CAPTCHA": "Resim CAPTCHA", "Image CAPTCHA": "Resim CAPTCHA",
"Sign In": "Oturum Aç", "Sign In": "Oturum Aç",
"Register": "Kayıt Ol", "Register": "Kayıt Ol",
"E-mail": "E-posta", "E-mail": "E-Posta",
"Google verification code": "Google doğrulama kodu", "Google verification code": "Google Doğrulama Kodu",
"Preferences": "Tercihler", "Preferences": "Tercihler",
"preferences_category_player": "Oynatıcı tercihleri", "preferences_category_player": "Oynatıcı Tercihleri",
"preferences_video_loop_label": "Sürekli döngü: ", "preferences_video_loop_label": "Sürekli Döngü: ",
"preferences_autoplay_label": "Otomatik oynat: ", "preferences_autoplay_label": "Otomatik Oynat: ",
"preferences_continue_label": "Öntanımlı olarak sonrakini oynat: ", "preferences_continue_label": "Öntanımlı Olarak Sonrakini Oynat: ",
"preferences_continue_autoplay_label": "Sonraki videoyu otomatik oynat: ", "preferences_continue_autoplay_label": "Sonraki Videoyu Otomatik Oynat: ",
"preferences_listen_label": "Öntanımlı olarak dinle: ", "preferences_listen_label": "Öntanımlı Olarak Dinle: ",
"preferences_local_label": "Videoları proxy'le: ", "preferences_local_label": "Videolara Proxy Uygula: ",
"preferences_speed_label": "Öntanımlı hız: ", "preferences_speed_label": "Öntanımlı Hız: ",
"preferences_quality_label": "Tercih edilen video kalitesi: ", "preferences_quality_label": "Tercih Edilen Video Kalitesi: ",
"preferences_volume_label": "Oynatıcı ses seviyesi: ", "preferences_volume_label": "Oynatıcı Ses Seviyesi: ",
"preferences_comments_label": "Öntanımlı yorumlar: ", "preferences_comments_label": "Öntanımlı Yorumlar: ",
"youtube": "YouTube", "youtube": "YouTube",
"reddit": "Reddit", "reddit": "Reddit",
"preferences_captions_label": "Öntanımlı altyazılar: ", "preferences_captions_label": "Öntanımlı Altyazılar: ",
"Fallback captions: ": "Yedek altyazılar: ", "Fallback captions: ": "Yedek Altyazılar: ",
"preferences_related_videos_label": "İlgili videoları göster: ", "preferences_related_videos_label": "İlgili Videoları Göster: ",
"preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ", "preferences_annotations_label": "Öntanımlı Olarak Ek Açıklamaları Göster: ",
"preferences_extend_desc_label": "Video ıklamasını otomatik olarak genişlet: ", "preferences_extend_desc_label": "Video ıklamasını Otomatik Olarak Genişlet: ",
"preferences_vr_mode_label": "Etkileşimli 360 derece videolar (WebGL gerektirir): ", "preferences_vr_mode_label": "Etkileşimli 360 Derece Videolar (WebGL Gerektirir): ",
"preferences_category_visual": "Görsel tercihler", "preferences_category_visual": "Görsel Tercihler",
"preferences_player_style_label": "Oynatıcı biçimi: ", "preferences_player_style_label": "Oynatıcı Biçimi: ",
"Dark mode: ": "Karanlık mod: ", "Dark mode: ": "Koyu Mod: ",
"preferences_dark_mode_label": "Tema: ", "preferences_dark_mode_label": "Tema: ",
"dark": "karanlık", "dark": "Koyu",
"light": "aydınlık", "light": "ık",
"preferences_thin_mode_label": "İnce mod: ", "preferences_thin_mode_label": "İnce Mod: ",
"preferences_category_misc": "Çeşitli tercihler", "preferences_category_misc": "Çeşitli Tercihler",
"preferences_automatic_instance_redirect_label": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", "preferences_automatic_instance_redirect_label": "Otomatik Örnek Yeniden Yönlendirmesi (Yedek: redirect.invidious.io): ",
"preferences_category_subscription": "Abonelik tercihleri", "preferences_category_subscription": "Abonelik Tercihleri",
"preferences_annotations_subscribed_label": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", "preferences_annotations_subscribed_label": "Abone Olunan Kanallar İçin Ek Açıklamaları Öntanımlı Olarak Göster: ",
"Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", "Redirect homepage to feed: ": "Ana Sayfayı Akışa Yönlendir: ",
"preferences_max_results_label": "Akışta gösterilen video sayısı: ", "preferences_max_results_label": "Akışta Gösterilen Video Sayısı: ",
"preferences_sort_label": "Videoları sıralama kriteri: ", "preferences_sort_label": "Videoları Sıralama Kriteri: ",
"published": "yayınlandı", "published": "Yayınlandı",
"published - reverse": "yayınlandı - ters", "published - reverse": "Yayınlandı - Ters",
"alphabetically": "alfabetik olarak", "alphabetically": "Alfabetik Olarak",
"alphabetically - reverse": "alfabetik olarak - ters", "alphabetically - reverse": "Alfabetik Olarak - Ters",
"channel name": "kanal adı", "channel name": "Kanal Adı",
"channel name - reverse": "kanal adı - ters", "channel name - reverse": "Kanal Adı - Ters",
"Only show latest video from channel: ": "Sadece kanaldaki en son videoyu göster: ", "Only show latest video from channel: ": "Sadece Kanaldaki En Son Videoyu Göster: ",
"Only show latest unwatched video from channel: ": "Sadece kanaldaki en son izlenmemiş videoyu göster: ", "Only show latest unwatched video from channel: ": "Sadece Kanaldaki En Son İzlenmemiş Videoyu Göster: ",
"preferences_unseen_only_label": "Sadece izlenmemişleri göster: ", "preferences_unseen_only_label": "Sadece İzlenmemişleri Göster: ",
"preferences_notifications_only_label": "Sadece bildirimleri göster (eğer varsa): ", "preferences_notifications_only_label": "Sadece Bildirimleri Göster (Eğer Varsa): ",
"Enable web notifications": "Ağ bildirimlerini etkinleştir", "Enable web notifications": "Ağ Bildirimlerini Etkinleştir",
"`x` uploaded a video": "`x` bir video yükledi", "`x` uploaded a video": "`x` Bir Video Yükledi",
"`x` is live": "`x` canlı yayında", "`x` is live": "`x` Canlı Yayında",
"preferences_category_data": "Veri tercihleri", "preferences_category_data": "Veri Tercihleri",
"Clear watch history": "İzleme geçmişini temizle", "Clear watch history": "İzleme Geçmişini Temizle",
"Import/export data": "Verileri içe/dışa aktar", "Import/export data": "Verileri İçe/Dışa Aktar",
"Change password": "Parolayı değiştir", "Change password": "Parolayı Değiştir",
"Manage subscriptions": "Abonelikleri yönet", "Manage subscriptions": "Abonelikleri Yönet",
"Manage tokens": "Belirteçleri yönet", "Manage tokens": "Belirteçleri Yönet",
"Watch history": "İzleme geçmişi", "Watch history": "İzleme Geçmişi",
"Delete account": "Hesap silme", "Delete account": "Hesap Silme",
"preferences_category_admin": "Yönetici tercihleri", "preferences_category_admin": "Yönetici Tercihleri",
"preferences_default_home_label": "Öntanımlı ana sayfa: ", "preferences_default_home_label": "Öntanımlı Ana Sayfa: ",
"preferences_feed_menu_label": "Akış menüsü: ", "preferences_feed_menu_label": "Akış Menüsü: ",
"preferences_show_nick_label": "Takma adı üstte göster: ", "preferences_show_nick_label": "Takma Adı Üstte Göster: ",
"Top enabled: ": "Top etkin: ", "Top enabled: ": "Top Etkin: ",
"CAPTCHA enabled: ": "CAPTCHA etkin: ", "CAPTCHA enabled: ": "CAPTCHA Etkin: ",
"Login enabled: ": "Oturum açma etkin: ", "Login enabled: ": "Oturum Açma Etkin: ",
"Registration enabled: ": "Kayıt olma etkin: ", "Registration enabled: ": "Kayıt Olma Etkin: ",
"Report statistics: ": "Rapor istatistikleri: ", "Report statistics: ": "Rapor İstatistikleri: ",
"Save preferences": "Tercihleri kaydet", "Save preferences": "Tercihleri Kaydet",
"Subscription manager": "Abonelik yöneticisi", "Subscription manager": "Abonelik Yöneticisi",
"Token manager": "Belirteç yöneticisi", "Token manager": "Belirteç Yöneticisi",
"Token": "Belirteç", "Token": "Belirteç",
"Import/export": "İçe/dışa aktar", "Import/export": "İçe/Dışa Aktar",
"unsubscribe": "abonelikten çık", "unsubscribe": "Abonelikten Çık",
"revoke": "geri al", "revoke": "Geri Al",
"Subscriptions": "Abonelikler", "Subscriptions": "Abonelikler",
"search": "ara", "search": "Ara",
"Log out": ıkış yap", "Log out": ıkış Yap",
"Released under the AGPLv3 on Github.": "GitHub'da AGPLv3 altında yayınlandı.", "Released under the AGPLv3 on Github.": "GitHub'da AGPLv3 altında yayınlandı.",
"Source available here.": "Kaynak kodları burada bulunabilir.", "Source available here.": "Kaynak kodları burada bulunabilir.",
"View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.",
@ -129,76 +129,76 @@
"Public": "Genel", "Public": "Genel",
"Unlisted": "Listelenmemiş", "Unlisted": "Listelenmemiş",
"Private": "Özel", "Private": "Özel",
"View all playlists": "Tüm oynatma listelerini görüntüle", "View all playlists": "Tüm Oynatma Listelerini Görüntüle",
"Updated `x` ago": "`x` önce güncellendi", "Updated `x` ago": "`x` Önce Güncellendi",
"Delete playlist `x`?": "`x` oynatma listesi silinsin mi?", "Delete playlist `x`?": "`x` oynatma listesi silinsin mi?",
"Delete playlist": "Oynatma listesini sil", "Delete playlist": "Oynatma Listesini Sil",
"Create playlist": "Oynatma listesi oluştur", "Create playlist": "Oynatma Listesi Oluştur",
"Title": "Başlık", "Title": "Başlık",
"Playlist privacy": "Oynatma listesi gizliliği", "Playlist privacy": "Oynatma Listesi Gizliliği",
"Editing playlist `x`": "`x` oynatma listesi düzenleniyor", "Editing playlist `x`": "`x` Oynatma Listesi Düzenleniyor",
"Show more": "Daha fazla göster", "Show more": "Daha Fazla Göster",
"Show less": "Daha az göster", "Show less": "Daha Az Göster",
"Watch on YouTube": "YouTube'da izle", "Watch on YouTube": "YouTube'da İzle",
"Switch Invidious Instance": "Invidious Örneğini Değiştir", "Switch Invidious Instance": "Invidious Örneğini Değiştir",
"Hide annotations": "Ek ıklamaları gizle", "Hide annotations": "Ek ıklamaları Gizle",
"Show annotations": "Ek ıklamaları göster", "Show annotations": "Ek ıklamaları Göster",
"Genre: ": "Tür: ", "Genre: ": "Tür: ",
"License: ": "Lisans: ", "License: ": "Lisans: ",
"Family friendly? ": "Aile için uygun mu? ", "Family friendly? ": "Aile için uygun mu? ",
"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: ",
"Blacklisted regions: ": "Kara listeye alınan bölgeler: ", "Blacklisted regions: ": "Kara Listeye Alınan Bölgeler: ",
"Shared `x`": "`x` paylaşıldı", "Shared `x`": "`x` Paylaşıldı",
"Premieres in `x`": "`x`içinde ilk gösterim", "Premieres in `x`": "`x`İçinde İlk Gösterim",
"Premieres `x`": "`x` ilk gösterim", "Premieres `x`": "`x` İlk Gösterim",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Merhaba! JavaScript'i kapatmış gibi görünüyorsun. Yorumları görüntülemek için buraya tıkla, yüklenmelerinin biraz uzun sürebileceğini unutma.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Merhaba! JavaScript'i kapatmış gibi görünüyorsun. Yorumları görüntülemek için buraya tıkla, yüklenmelerinin biraz uzun sürebileceğini unutma.",
"View YouTube comments": "YouTube yorumlarını görüntüle", "View YouTube comments": "YouTube Yorumlarını Görüntüle",
"View more comments on Reddit": "Reddit'te daha fazla yorum görüntüle", "View more comments on Reddit": "Reddit'te Daha Fazla Yorum Görüntüle",
"View `x` comments": { "View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Yorumu Görüntüle",
"": "`x` yorumu görüntüle" "": "`x` Yorumu Görüntüle"
}, },
"View Reddit comments": "Reddit yorumlarını görüntüle", "View Reddit comments": "Reddit Yorumlarını Görüntüle",
"Hide replies": "Cevapları gizle", "Hide replies": "Cevapları Gizle",
"Show replies": "Cevapları göster", "Show replies": "Cevapları Göster",
"Incorrect password": "Yanlış parola", "Incorrect password": "Yanlış Parola",
"Quota exceeded, try again in a few hours": "Kota aşıldı, birkaç saat içinde tekrar deneyin", "Quota exceeded, try again in a few hours": "Kota aşıldı, birkaç saat içinde tekrar deneyin.",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Oturum açılamadı, iki faktörlü kimlik doğrulamanın (Authenticator ya da SMS) açık olduğundan emin olun.", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Oturum açılamadı, iki faktörlü kimlik doğrulamanın (Kimlik Doğrulayıcı ya da SMS) açık olduğundan emin olun.",
"Invalid TFA code": "Geçersiz TFA kodu", "Invalid TFA code": "Geçersiz TFA Kodu",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Giriş başarısız. Bunun nedeni, hesabınız için iki faktörlü kimlik doğrulamanın açık olmaması olabilir.", "Login failed. This may be because two-factor authentication is not turned on for your account.": "Giriş başarısız. Bunun nedeni, hesabınız için iki faktörlü kimlik doğrulamanın açık olmaması olabilir.",
"Wrong answer": "Yanlış cevap", "Wrong answer": "Yanlış Cevap",
"Erroneous CAPTCHA": "Hatalı CAPTCHA", "Erroneous CAPTCHA": "Hatalı CAPTCHA",
"CAPTCHA is a required field": "CAPTCHA zorunlu bir alandır", "CAPTCHA is a required field": "CAPTCHA Zorunlu Bir Alandır",
"User ID is a required field": "Kullanıcı kimliği zorunlu bir alandır", "User ID is a required field": "Kullanıcı Kimliği Zorunlu Bir Alandır",
"Password is a required field": "Parola zorunlu bir alandır", "Password is a required field": "Parola Zorunlu Bir Alandır",
"Wrong username or password": "Yanlış kullanıcı adı ya da parola", "Wrong username or password": "Yanlış Kullanıcı Adı ya da Parola",
"Please sign in using 'Log in with Google'": "Lütfen 'Google ile giriş yap' seçeneğini kullanarak oturum açın", "Please sign in using 'Log in with Google'": "Lütfen 'Google İle Giriş Yap' Seçeneğini Kullanarak Oturum Açın",
"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",
"Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", "Invidious Private Feed for `x`": "`x` İçin Invidious Özel Akışı",
"channel:`x`": "kanal:`x`", "channel:`x`": "Kanal:`x`",
"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ı.",
"Could not fetch comments": "Yorumlar alınamadı", "Could not fetch comments": "Yorumlar Alınamadı",
"`x` ago": "`x` önce", "`x` ago": "`x` Önce",
"Load more": "Daha fazla yükle", "Load more": "Daha Fazla Yükle",
"Could not create mix.": "Mix oluşturulamadı.", "Could not create mix.": "Mix oluşturulamadı.",
"Empty playlist": "Boş oynatma listesi", "Empty playlist": "Boş Oynatma Listesi",
"Not a playlist.": "Oynatma listesi değil.", "Not a playlist.": "Oynatma listesi değil.",
"Playlist does not exist.": "Oynatma listesi mevcut değil.", "Playlist does not exist.": "Oynatma listesi mevcut değil.",
"Could not pull trending pages.": "Trend sayfaları alınamıyor.", "Could not pull trending pages.": "Trend sayfaları alınamıyor.",
"Hidden field \"challenge\" is a required field": "Gizli alan \"challenge\" zorunlu bir alandır", "Hidden field \"challenge\" is a required field": "Gizli Alan \"Challenge\" Zorunlu Bir Alandır",
"Hidden field \"token\" is a required field": "\"belirteç\" gizli alanı zorunlu bir alandır", "Hidden field \"token\" is a required field": "\"Belirteç\" Gizli Alanı Zorunlu Bir Alandır",
"Erroneous challenge": "Hatalı challenge", "Erroneous challenge": "Hatalı Challenge",
"Erroneous token": "Hatalı belirteç", "Erroneous token": "Hatalı Belirteç",
"No such user": "Böyle bir kullanıcı yok", "No such user": "Böyle Bir Kullanıcı Yok",
"Token is expired, please try again": "Belirtecin süresi doldu, lütfen tekrar deneyin", "Token is expired, please try again": "Belirtecin Süresi Doldu, Lütfen Tekrar Deneyin",
"English": "İngilizce", "English": "İngilizce",
"English (auto-generated)": "İngilizce (otomatik oluşturuldu)", "English (auto-generated)": "İngilizce (Otomatik Oluşturuldu)",
"Afrikaans": "Afrikanca", "Afrikaans": "Afrikanca",
"Albanian": "Arnavutça", "Albanian": "Arnavutça",
"Amharic": "Amharca", "Amharic": "Amharca",
@ -230,9 +230,9 @@
"German": "Almanca", "German": "Almanca",
"Greek": "Yunanca", "Greek": "Yunanca",
"Gujarati": "Guceratça", "Gujarati": "Guceratça",
"Haitian Creole": "Haiti Creole dili", "Haitian Creole": "Haiti Creole Dili",
"Hausa": "Hausaca", "Hausa": "Hausaca",
"Hawaiian": "Hawaii dili", "Hawaiian": "Hawaii Dili",
"Hebrew": "İbranice", "Hebrew": "İbranice",
"Hindi": "Hintçe", "Hindi": "Hintçe",
"Hmong": "Hmong", "Hmong": "Hmong",
@ -244,7 +244,7 @@
"Italian": "İtalyanca", "Italian": "İtalyanca",
"Japanese": "Japonca", "Japanese": "Japonca",
"Javanese": "Cava dili", "Javanese": "Cava dili",
"Kannada": "Kannada dili", "Kannada": "Kannada Dili",
"Kazakh": "Kazakça", "Kazakh": "Kazakça",
"Khmer": "Kmerce", "Khmer": "Kmerce",
"Korean": "Korece", "Korean": "Korece",
@ -258,10 +258,10 @@
"Macedonian": "Makedonca", "Macedonian": "Makedonca",
"Malagasy": "Malgaşça", "Malagasy": "Malgaşça",
"Malay": "Malayca", "Malay": "Malayca",
"Malayalam": "Malayalam dili", "Malayalam": "Malayalam Dili",
"Maltese": "Maltaca", "Maltese": "Maltaca",
"Maori": "Maori dili", "Maori": "Maori Dili",
"Marathi": "Marati dili", "Marathi": "Marati Dili",
"Mongolian": "Moğolca", "Mongolian": "Moğolca",
"Nepali": "Nepalce", "Nepali": "Nepalce",
"Norwegian Bokmål": "Norveççe Bokmål", "Norwegian Bokmål": "Norveççe Bokmål",
@ -270,19 +270,19 @@
"Persian": "Farsça", "Persian": "Farsça",
"Polish": "Lehçe", "Polish": "Lehçe",
"Portuguese": "Portekizce", "Portuguese": "Portekizce",
"Punjabi": "Pencap dili", "Punjabi": "Pencap Dili",
"Romanian": "Rumence", "Romanian": "Rumence",
"Russian": "Rusça", "Russian": "Rusça",
"Samoan": "Samoa dili", "Samoan": "Samoa Dili",
"Scottish Gaelic": "İskoç Galcesi", "Scottish Gaelic": "İskoç Galcesi",
"Serbian": "Sırpça", "Serbian": "Sırpça",
"Shona": "Şona dili", "Shona": "Şona Dili",
"Sindhi": "Sintçe", "Sindhi": "Sintçe",
"Sinhala": "Seylanca", "Sinhala": "Seylanca",
"Slovak": "Slovakça", "Slovak": "Slovakça",
"Slovenian": "Slovence", "Slovenian": "Slovence",
"Somali": "Somalice", "Somali": "Somalice",
"Southern Sotho": "Güney Sotho dili", "Southern Sotho": "Güney Sotho Dili",
"Spanish": "İspanyolca", "Spanish": "İspanyolca",
"Spanish (Latin America)": "İspanyolca (Latin Amerika)", "Spanish (Latin America)": "İspanyolca (Latin Amerika)",
"Sundanese": "Sundaca", "Sundanese": "Sundaca",
@ -290,7 +290,7 @@
"Swedish": "İsveççe", "Swedish": "İsveççe",
"Tajik": "Tacikçe", "Tajik": "Tacikçe",
"Tamil": "Tamilce", "Tamil": "Tamilce",
"Telugu": "Telugu dili", "Telugu": "Telugu Dili",
"Thai": "Tayca", "Thai": "Tayca",
"Turkish": "Türkçe", "Turkish": "Türkçe",
"Ukrainian": "Ukraynaca", "Ukrainian": "Ukraynaca",
@ -299,178 +299,178 @@
"Vietnamese": "Vietnamca", "Vietnamese": "Vietnamca",
"Welsh": "Galce", "Welsh": "Galce",
"Western Frisian": "Batı Frizcesi", "Western Frisian": "Batı Frizcesi",
"Xhosa": "Xhosa dili", "Xhosa": "Xhosa Dili",
"Yiddish": "Yiddiş", "Yiddish": "Yiddiş",
"Yoruba": "Yoruba dili", "Yoruba": "Yoruba Dili",
"Zulu": "Zuluca", "Zulu": "Zuluca",
"Fallback comments: ": "Yedek yorumlar: ", "Fallback comments: ": "Yedek Yorumlar: ",
"Popular": "Popüler", "Popular": "Popüler",
"Search": "Ara", "Search": "Ara",
"Top": "Enler", "Top": "Enler",
"About": "Hakkında", "About": "Hakkında",
"Rating: ": "Değerlendirme: ", "Rating: ": "Değerlendirme: ",
"preferences_locale_label": "Dil: ", "preferences_locale_label": "Dil: ",
"View as playlist": "Oynatma listesi olarak görüntüle", "View as playlist": "Oynatma Listesi Olarak Görüntüle",
"Default": "Öntanımlı", "Default": "Öntanımlı",
"Music": "Müzik", "Music": "Müzik",
"Gaming": "Oyun", "Gaming": "Oyun",
"News": "Haberler", "News": "Haberler",
"Movies": "Filmler", "Movies": "Filmler",
"Download": "İndir", "Download": "İndir",
"Download as: ": "Şu şekilde indir: ", "Download as: ": "Şu Şekilde İndir: ",
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(düzenlendi)", "(edited)": "(Düzenlendi)",
"YouTube comment permalink": "YouTube yorumu kalıcı linki", "YouTube comment permalink": "YouTube Yorumu Kalıcı Linki",
"permalink": "kalıcı link", "permalink": "Kalıcı Link",
"`x` marked it with a ❤": "`x` ❤ ile işaretledi", "`x` marked it with a ❤": "`x` ❤ İle İşaretledi",
"Audio mode": "Ses modu", "Audio mode": "Ses Modu",
"Video mode": "Video modu", "Video mode": "Video Modu",
"Videos": "Videolar", "channel_tab_videos_label": "Videolar",
"Playlists": "Oynatma listeleri", "Playlists": "Oynatma Listeleri",
"Community": "Topluluk", "channel_tab_community_label": "Topluluk",
"search_filters_sort_option_relevance": "İlgi", "search_filters_sort_option_relevance": "İlgi",
"search_filters_sort_option_rating": "Değerlendirme", "search_filters_sort_option_rating": "Değerlendirme",
"search_filters_sort_option_date": "Yükleme tarihi", "search_filters_sort_option_date": "Yükleme Tarihi",
"search_filters_sort_option_views": "Görüntüleme sayısı", "search_filters_sort_option_views": "Görüntüleme Sayısı",
"search_filters_type_label": "Tür", "search_filters_type_label": "Tür",
"search_filters_duration_label": "Süre", "search_filters_duration_label": "Süre",
"search_filters_features_label": "Özellikler", "search_filters_features_label": "Özellikler",
"search_filters_sort_label": "Sıralama Ölçütü", "search_filters_sort_label": "Sıralama Ölçütü",
"search_filters_date_option_hour": "Son Saat", "search_filters_date_option_hour": "Son Saat",
"search_filters_date_option_today": "Bugün", "search_filters_date_option_today": "Bugün",
"search_filters_date_option_week": "Bu hafta", "search_filters_date_option_week": "Bu Hafta",
"search_filters_date_option_month": "Bu ay", "search_filters_date_option_month": "Bu Ay",
"search_filters_date_option_year": "Bu yıl", "search_filters_date_option_year": "Bu Yıl",
"search_filters_type_option_video": "Video", "search_filters_type_option_video": "Video",
"search_filters_type_option_channel": "Kanal", "search_filters_type_option_channel": "Kanal",
"search_filters_type_option_playlist": "Oynatma listesi", "search_filters_type_option_playlist": "Oynatma Listesi",
"search_filters_type_option_movie": "Film", "search_filters_type_option_movie": "Film",
"search_filters_type_option_show": "Gösteri", "search_filters_type_option_show": "Gösteri",
"search_filters_features_option_hd": "HD", "search_filters_features_option_hd": "HD",
"search_filters_features_option_subtitles": "Alt yazılar", "search_filters_features_option_subtitles": "Alt Yazılar",
"search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_c_commons": "Yaratıcı",
"search_filters_features_option_three_d": "3B", "search_filters_features_option_three_d": "3D",
"search_filters_features_option_live": "Canlı", "search_filters_features_option_live": "Canlı",
"search_filters_features_option_four_k": "4K", "search_filters_features_option_four_k": "4K",
"search_filters_features_option_location": "Konum", "search_filters_features_option_location": "Konum",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"Current version: ": "Şu anki sürüm: ", "Current version: ": "Şu Anki Sürüm: ",
"next_steps_error_message": "Bundan sonra şunları denemelisiniz: ", "next_steps_error_message": "Bundan Sonra Şunları Denemelisiniz: ",
"next_steps_error_message_refresh": "Yenile", "next_steps_error_message_refresh": "Yenile",
"next_steps_error_message_go_to_youtube": "YouTube'a git", "next_steps_error_message_go_to_youtube": "YouTube'a Git",
"search_filters_duration_option_short": "Kısa (4 dakikadan az)", "search_filters_duration_option_short": "Kısa (4 Dakikadan Az)",
"search_filters_duration_option_long": "Uzun (20 dakikadan fazla)", "search_filters_duration_option_long": "Uzun (20 Dakikadan Fazla)",
"footer_documentation": "Belgelendirme", "footer_documentation": "Belgelendirme",
"footer_source_code": "Kaynak kodları", "footer_source_code": "Kaynak Kodları",
"footer_original_source_code": "Orijinal kaynak kodları", "footer_original_source_code": "Orijinal Kaynak Kodları",
"footer_modfied_source_code": "Değiştirilmiş kaynak kodları", "footer_modfied_source_code": "Değiştirilmiş Kaynak Kodları",
"adminprefs_modified_source_code_url_label": "Değiştirilmiş kaynak kodları deposunun URL'si", "adminprefs_modified_source_code_url_label": "Değiştirilmiş Kaynak Kodları Deposunun URL'si",
"footer_donate_page": "Bağış yap", "footer_donate_page": "Bağış Yap",
"preferences_region_label": "İçerik ülkesi: ", "preferences_region_label": "İçerik Ülkesi: ",
"preferences_quality_dash_label": "Tercih edilen DASH video kalitesi: ", "preferences_quality_dash_label": "Tercih Edilen DASH Video Kalitesi: ",
"preferences_quality_option_hd720": "HD720", "preferences_quality_option_hd720": "HD720",
"preferences_quality_dash_option_best": "En iyi", "preferences_quality_dash_option_best": "En İyi",
"preferences_quality_dash_option_worst": "En kötü", "preferences_quality_dash_option_worst": "En Kötü",
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320P",
"preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_2160p": "2160P",
"preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_480p": "480P",
"preferences_quality_dash_option_360p": "360p", "preferences_quality_dash_option_360p": "360P",
"preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_240p": "240P",
"preferences_quality_dash_option_144p": "144p", "preferences_quality_dash_option_144p": "144P",
"invidious": "Invidious", "invidious": "Invidious",
"none": "yok", "none": "Yok",
"videoinfo_started_streaming_x_ago": "`x` önce yayına başladı", "videoinfo_started_streaming_x_ago": "`x` Önce Yayına Başladı",
"videoinfo_youTube_embed_link": "Göm", "videoinfo_youTube_embed_link": "Entegre Et",
"videoinfo_invidious_embed_link": "Bağlantıyı Göm", "videoinfo_invidious_embed_link": "Bağlantıyı Entegre Et",
"user_created_playlists": "`x` oluşturulan oynatma listeleri", "user_created_playlists": "`x` Oluşturulan Oynatma Listeleri",
"user_saved_playlists": "`x` kaydedilen oynatma listeleri", "user_saved_playlists": "`x` Kaydedilen Oynatma Listeleri",
"preferences_quality_option_small": "Küçük", "preferences_quality_option_small": "Küçük",
"preferences_quality_dash_option_720p": "720p", "preferences_quality_dash_option_720p": "720P",
"preferences_quality_option_medium": "Orta", "preferences_quality_option_medium": "Orta",
"preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_1440p": "1440P",
"preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_1080p": "1080P",
"Video unavailable": "Video kullanılamıyor", "Video unavailable": "Video Kullanılamıyor",
"preferences_quality_option_dash": "DASH (uyarlanabilir kalite)", "preferences_quality_option_dash": "DASH (Uyarlanabilir Kalite)",
"preferences_quality_dash_option_auto": "Otomatik", "preferences_quality_dash_option_auto": "Otomatik",
"search_filters_features_option_purchased": "Satın alınan", "search_filters_features_option_purchased": "Satın Alınan",
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"videoinfo_watch_on_youTube": "YouTube'da izle", "videoinfo_watch_on_youTube": "YouTube'da İzle",
"download_subtitles": "Alt yazılar - `x` (.vtt)", "download_subtitles": "Alt Yazılar - `x` (.vtt)",
"preferences_save_player_pos_label": "Oynatma konumunu kaydet: ", "preferences_save_player_pos_label": "Oynatma Konumunu Kaydet: ",
"generic_views_count": "{{count}} görüntüleme", "generic_views_count": "{{count}} Görüntüleme",
"generic_views_count_plural": "{{count}} görüntüleme", "generic_views_count_plural": "{{count}} Görüntüleme",
"generic_subscribers_count": "{{count}} abone", "generic_subscribers_count": "{{count}} Abone",
"generic_subscribers_count_plural": "{{count}} abone", "generic_subscribers_count_plural": "{{count}} Abone",
"generic_subscriptions_count": "{{count}} abonelik", "generic_subscriptions_count": "{{count}} Abonelik",
"generic_subscriptions_count_plural": "{{count}} abonelik", "generic_subscriptions_count_plural": "{{count}} Abonelik",
"subscriptions_unseen_notifs_count": "{{count}} okunmamış bildirim", "subscriptions_unseen_notifs_count": "{{count}} Okunmamış Bildirim",
"subscriptions_unseen_notifs_count_plural": "{{count}} okunmamış bildirim", "subscriptions_unseen_notifs_count_plural": "{{count}} Okunmamış Bildirim",
"comments_points_count": "{{count}} puan", "comments_points_count": "{{count}} Puan",
"comments_points_count_plural": "{{count}} puan", "comments_points_count_plural": "{{count}} Puan",
"generic_count_hours": "{{count}} saat", "generic_count_hours": "{{count}} Saat",
"generic_count_hours_plural": "{{count}} saat", "generic_count_hours_plural": "{{count}} Saat",
"generic_count_minutes": "{{count}} dakika", "generic_count_minutes": "{{count}} Dakika",
"generic_count_minutes_plural": "{{count}} dakika", "generic_count_minutes_plural": "{{count}} Dakika",
"generic_count_seconds": "{{count}} saniye", "generic_count_seconds": "{{count}} Saniye",
"generic_count_seconds_plural": "{{count}} saniye", "generic_count_seconds_plural": "{{count}} Saniye",
"generic_playlists_count": "{{count}} oynatma listesi", "generic_playlists_count": "{{count}} Oynatma Listesi",
"generic_playlists_count_plural": "{{count}} oynatma listesi", "generic_playlists_count_plural": "{{count}} Oynatma Listesi",
"tokens_count": "{{count}} belirteç", "tokens_count": "{{count}} Belirteç",
"tokens_count_plural": "{{count}} belirteç", "tokens_count_plural": "{{count}} Belirteç",
"comments_view_x_replies": "{{count}} yanıtı görüntüle", "comments_view_x_replies": "{{count}} Yanıtı Görüntüle",
"comments_view_x_replies_plural": "{{count}} yanıtı görüntüle", "comments_view_x_replies_plural": "{{count}} Yanıtı Görüntüle",
"generic_count_years": "{{count}} yıl", "generic_count_years": "{{count}} Yıl",
"generic_count_years_plural": "{{count}} yıl", "generic_count_years_plural": "{{count}} Yıl",
"generic_count_months": "{{count}} ay", "generic_count_months": "{{count}} Ay",
"generic_count_months_plural": "{{count}} ay", "generic_count_months_plural": "{{count}} Ay",
"generic_count_days": "{{count}} gün", "generic_count_days": "{{count}} Gün",
"generic_count_days_plural": "{{count}} gün", "generic_count_days_plural": "{{count}} Gün",
"generic_videos_count": "{{count}} video", "generic_videos_count": "{{count}} Video",
"generic_videos_count_plural": "{{count}} video", "generic_videos_count_plural": "{{count}} Video",
"generic_count_weeks": "{{count}} hafta", "generic_count_weeks": "{{count}} Hafta",
"generic_count_weeks_plural": "{{count}} hafta", "generic_count_weeks_plural": "{{count}} Hafta",
"crash_page_you_found_a_bug": "Görünüşe göre Invidious'ta bir hata buldunuz!", "crash_page_you_found_a_bug": "Görünüşe göre Invidious'ta bir hata buldunuz!",
"crash_page_before_reporting": "Bir hatayı bildirmeden önce, şunları yaptığınızdan emin olun:", "crash_page_before_reporting": "Bir hatayı bildirmeden önce, şunları yaptığınızdan emin olun:",
"crash_page_refresh": "<a href=\"`x`\">sayfayı yenilemeye</a> çalıştınız", "crash_page_refresh": "<a href=\"`x`\">Sayfayı Yenilemeye</a> Çalıştınız",
"crash_page_switch_instance": "<a href=\"`x`\">başka bir örnek kullanmaya</a> çalıştınız", "crash_page_switch_instance": "<a href=\"`x`\">Başka Bir Örnek Kullanmaya</a> Çalıştınız",
"crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> okudunuz", "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> Okudunuz",
"crash_page_search_issue": "<a href=\"`x`\">GitHub'daki sorunlarda</a> aradınız", "crash_page_search_issue": "<a href=\"`x`\">GitHub'daki Sorunlarda</a> Aradınız",
"crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):", "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (Tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (Bu metni ÇEVİRMEYİN):",
"English (United Kingdom)": "İngilizce (Birleşik Krallık)", "English (United Kingdom)": "İngilizce (Birleşik Krallık)",
"Chinese": "Çince", "Chinese": "Çince",
"Interlingue": "İnterlingue", "Interlingue": "İnterlingue",
"Italian (auto-generated)": "İtalyanca (otomatik oluşturuldu)", "Italian (auto-generated)": "İtalyanca (Otomatik Oluşturuldu)",
"Japanese (auto-generated)": "Japonca (otomatik oluşturuldu)", "Japanese (auto-generated)": "Japonca (Otomatik Oluşturuldu)",
"Portuguese (Brazil)": "Portekizce (Brezilya)", "Portuguese (Brazil)": "Portekizce (Brezilya)",
"Russian (auto-generated)": "Rusça (otomatik oluşturuldu)", "Russian (auto-generated)": "Rusça (Otomatik Oluşturuldu)",
"Spanish (auto-generated)": "İspanyolca (otomatik oluşturuldu)", "Spanish (auto-generated)": "İspanyolca (Otomatik Oluşturuldu)",
"Spanish (Mexico)": "İspanyolca (Meksika)", "Spanish (Mexico)": "İspanyolca (Meksika)",
"English (United States)": "İngilizce (ABD)", "English (United States)": "İngilizce (ABD)",
"Cantonese (Hong Kong)": "Kantonca (Hong Kong)", "Cantonese (Hong Kong)": "Kantonca (Hong Kong)",
"Chinese (Taiwan)": "Çince (Tayvan)", "Chinese (Taiwan)": "Çince (Tayvan)",
"Dutch (auto-generated)": "Felemenkçe (otomatik oluşturuldu)", "Dutch (auto-generated)": "Felemenkçe (Otomatik Oluşturuldu)",
"Indonesian (auto-generated)": "Endonezyaca (otomatik oluşturuldu)", "Indonesian (auto-generated)": "Endonezyaca (Otomatik Oluşturuldu)",
"Chinese (Hong Kong)": "Çince (Hong Kong)", "Chinese (Hong Kong)": "Çince (Hong Kong)",
"French (auto-generated)": "Fransızca (otomatik oluşturuldu)", "French (auto-generated)": "Fransızca (Otomatik Oluşturuldu)",
"Korean (auto-generated)": "Korece (otomatik oluşturuldu)", "Korean (auto-generated)": "Korece (Otomatik Oluşturuldu)",
"Turkish (auto-generated)": "Türkçe (otomatik oluşturuldu)", "Turkish (auto-generated)": "Türkçe (Otomatik Oluşturuldu)",
"Chinese (China)": "Çince (Çin)", "Chinese (China)": "Çince (Çin)",
"German (auto-generated)": "Almanca (otomatik oluşturuldu)", "German (auto-generated)": "Almanca (Otomatik Oluşturuldu)",
"Portuguese (auto-generated)": "Portekizce (otomatik oluşturuldu)", "Portuguese (auto-generated)": "Portekizce (Otomatik Oluşturuldu)",
"Spanish (Spain)": "İspanyolca (İspanya)", "Spanish (Spain)": "İspanyolca (İspanya)",
"Vietnamese (auto-generated)": "Vietnamca (otomatik oluşturuldu)", "Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)",
"preferences_watch_history_label": "İzleme geçmişini etkinleştir: ", "preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ",
"search_message_use_another_instance": " Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.", "search_message_use_another_instance": " Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
"search_filters_type_option_all": "Herhangi bir tür", "search_filters_type_option_all": "Herhangi Bir Tür",
"search_filters_duration_option_none": "Herhangi bir süre", "search_filters_duration_option_none": "Herhangi Bir Süre",
"search_message_no_results": "Sonuç bulunamadı.", "search_message_no_results": "Sonuç bulunamadı.",
"search_filters_date_label": "Yükleme tarihi", "search_filters_date_label": "Yükleme Tarihi",
"search_filters_apply_button": "Seçili filtreleri uygula", "search_filters_apply_button": "Seçili Filtreleri Uygula",
"search_filters_date_option_none": "Herhangi bir tarih", "search_filters_date_option_none": "Herhangi Bir Tarih",
"search_filters_duration_option_medium": "Orta (4 - 20 dakika)", "search_filters_duration_option_medium": "Orta (4 - 20 Dakika)",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_filters_title": "Filtreler", "search_filters_title": "Filtreler",
"search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.", "search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.",
"Popular enabled: ": "Popüler etkin: ", "Popular enabled: ": "Popüler Etkin: ",
"error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. <a href=\"`x`\">Oynatma listesi ana sayfası için buraya tıklayın.</a>" "error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. <a href=\"`x`\">Oynatma listesi ana sayfası için buraya tıklayın.</a>"
} }

View File

@ -315,9 +315,9 @@
"`x` marked it with a ❤": "❤ цьому від каналу `x`", "`x` marked it with a ❤": "❤ цьому від каналу `x`",
"Audio mode": "Аудіорежим", "Audio mode": "Аудіорежим",
"Video mode": "Відеорежим", "Video mode": "Відеорежим",
"Videos": "Відео", "channel_tab_videos_label": "Відео",
"Playlists": "Плейлисти", "Playlists": "Плейлисти",
"Community": "Спільнота", "channel_tab_community_label": "Спільнота",
"Current version: ": "Поточна версія: ", "Current version: ": "Поточна версія: ",
"generic_views_count_0": "{{count}} перегляд", "generic_views_count_0": "{{count}} перегляд",
"generic_views_count_1": "{{count}} перегляди", "generic_views_count_1": "{{count}} перегляди",

View File

@ -311,9 +311,9 @@
"`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤", "`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤",
"Audio mode": "Chế độ âm thanh", "Audio mode": "Chế độ âm thanh",
"Video mode": "Chế độ quay", "Video mode": "Chế độ quay",
"Videos": "Video", "channel_tab_videos_label": "Video",
"Playlists": "Danh sách phát", "Playlists": "Danh sách phát",
"Community": "Cộng đồng", "channel_tab_community_label": "Cộng đồng",
"search_filters_sort_option_relevance": "liên quan", "search_filters_sort_option_relevance": "liên quan",
"search_filters_sort_option_rating": "Xếp hạng", "search_filters_sort_option_rating": "Xếp hạng",
"search_filters_sort_option_date": "ngày", "search_filters_sort_option_date": "ngày",

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` 为此加 ❤", "`x` marked it with a ❤": "`x` 为此加 ❤",
"Audio mode": "音频模式", "Audio mode": "音频模式",
"Video mode": "视频模式", "Video mode": "视频模式",
"Videos": "视频", "channel_tab_videos_label": "视频",
"Playlists": "播放列表", "Playlists": "播放列表",
"Community": "社区", "channel_tab_community_label": "社区",
"search_filters_sort_option_relevance": "相关度", "search_filters_sort_option_relevance": "相关度",
"search_filters_sort_option_rating": "评分", "search_filters_sort_option_rating": "评分",
"search_filters_sort_option_date": "上传日期", "search_filters_sort_option_date": "上传日期",

View File

@ -341,9 +341,9 @@
"`x` marked it with a ❤": "`x` 為此標記 ❤", "`x` marked it with a ❤": "`x` 為此標記 ❤",
"Audio mode": "音訊模式", "Audio mode": "音訊模式",
"Video mode": "視訊模式", "Video mode": "視訊模式",
"Videos": "影片", "channel_tab_videos_label": "影片",
"Playlists": "播放清單", "Playlists": "播放清單",
"Community": "社群", "channel_tab_community_label": "社群",
"search_filters_sort_option_relevance": "關聯", "search_filters_sort_option_relevance": "關聯",
"search_filters_sort_option_rating": "評分", "search_filters_sort_option_rating": "評分",
"search_filters_sort_option_date": "日期", "search_filters_sort_option_date": "日期",

2
mocks

@ -1 +1 @@
Subproject commit c401dd9203434b561022242c24b0c200d72284c0 Subproject commit dfd53ea6ceb3cbcbbce6004f6ce60b330ad0f9b1

0
scripts/deploy-database.sh Normal file → Executable file
View File

2
scripts/fetch-player-dependencies.cr Normal file → Executable file
View File

@ -129,7 +129,7 @@ dependencies_to_install.each do |dep|
dep = "videojs.markers" if dep == "videojs-markers" dep = "videojs.markers" if dep == "videojs-markers"
if File.exists?("#{download_path}/package/dist/#{dep}.css") if File.exists?("#{download_path}/package/dist/#{dep}.css")
if minified && File.exists?("#{tmp_dir_path}/#{dep}/package/dist/#{dep}.min.css") if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.css")
`mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css` `mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css`
else else
`mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css`

0
scripts/install-dependencies.sh Normal file → Executable file
View File

View File

@ -34,7 +34,7 @@ shards:
protodec: protodec:
git: https://github.com/iv-org/protodec.git git: https://github.com/iv-org/protodec.git
version: 0.1.4 version: 0.1.5
radix: radix:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git

View File

@ -24,7 +24,7 @@ dependencies:
version: ~> 0.6.1 version: ~> 0.6.1
protodec: protodec:
github: iv-org/protodec github: iv-org/protodec
version: ~> 0.1.4 version: ~> 0.1.5
lsquic: lsquic:
github: iv-org/lsquic.cr github: iv-org/lsquic.cr
version: ~> 2.18.1-2 version: ~> 2.18.1-2

View File

@ -4,7 +4,7 @@ Spectator.describe Invidious::Hashtag do
it "parses richItemRenderer containers (test 1)" do it "parses richItemRenderer containers (test 1)" do
# Enable mock # Enable mock
test_content = load_mock("hashtag/martingarrix_page1") test_content = load_mock("hashtag/martingarrix_page1")
videos = extract_items(test_content) videos, _ = extract_items(test_content)
expect(typeof(videos)).to eq(Array(SearchItem)) expect(typeof(videos)).to eq(Array(SearchItem))
expect(videos.size).to eq(60) expect(videos.size).to eq(60)
@ -57,7 +57,7 @@ Spectator.describe Invidious::Hashtag do
it "parses richItemRenderer containers (test 2)" do it "parses richItemRenderer containers (test 2)" do
# Enable mock # Enable mock
test_content = load_mock("hashtag/martingarrix_page2") test_content = load_mock("hashtag/martingarrix_page2")
videos = extract_items(test_content) videos, _ = extract_items(test_content)
expect(typeof(videos)).to eq(Array(SearchItem)) expect(typeof(videos)).to eq(Array(SearchItem))
expect(videos.size).to eq(60) expect(videos.size).to eq(60)

View File

@ -23,12 +23,6 @@ Spectator.describe "Helper" do
end end
end end
describe "#produce_channel_playlists_url" do
it "correctly produces a /browse_ajax URL with the given UCID and cursor" do
expect(produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW")).to eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en")
end
end
describe "#produce_comment_continuation" do describe "#produce_comment_continuation" do
it "correctly produces a continuation token for comments" do it "correctly produces a continuation token for comments" do
expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")

View File

@ -0,0 +1,168 @@
require "../../parsers_helper.cr"
Spectator.describe "parse_video_info" do
it "parses a regular video" do
# Enable mock
_player = load_mock("video/regular_mrbeast.player")
_next = load_mock("video/regular_mrbeast.next")
raw_data = _player.merge!(_next)
info = parse_video_info("2isYuQZMbdU", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["videoType"].as_s).to eq("Video")
# Basic video infos
expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island")
expect(info["views"].as_i).to eq(32_846_329)
expect(info["likes"].as_i).to eq(2_611_650)
# For some reason the video length from VideoDetails and the
# one from microformat differs by 1s...
expect(info["lengthSeconds"].as_i).to be_between(930_i64, 931_i64)
expect(info["published"].as_s).to eq("2022-08-04T00:00:00Z")
# Extra video infos
expect(info["allowedRegions"].as_a).to_not be_empty
expect(info["allowedRegions"].as_a.size).to eq(249)
expect(info["allowedRegions"].as_a).to contain(
"AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR",
"TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU",
"WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"
)
expect(info["keywords"].as_a).to be_empty
expect(info["allowRatings"].as_bool).to be_true
expect(info["isFamilyFriendly"].as_bool).to be_true
expect(info["isListed"].as_bool).to be_true
expect(info["isUpcoming"].as_bool).to be_false
# Related videos
expect(info["relatedVideos"].as_a.size).to eq(19)
expect(info["relatedVideos"][0]["id"]).to eq("tVWWp1PqDus")
expect(info["relatedVideos"][0]["title"]).to eq("100 Girls Vs 100 Boys For $500,000")
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
expect(info["relatedVideos"][0]["view_count"]).to eq("49702799")
expect(info["relatedVideos"][0]["short_view_count"]).to eq("49M")
expect(info["relatedVideos"][0]["author_verified"]).to eq("true")
# Description
description = "🚀Launch a store on Shopify, Ill buy from 100 random stores that do ▸ "
expect(info["description"].as_s).to start_with(description)
expect(info["shortDescription"].as_s).to start_with(description)
expect(info["descriptionHtml"].as_s).to start_with(description)
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
# Author infos
expect(info["author"].as_s).to eq("MrBeast")
expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/ytc/AMLnZu84dsnlYtuUFBMC8imQs0IUcTKA9khWAmUOgQZltw=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_true
expect(info["subCountText"].as_s).to eq("101M")
end
it "parses a regular video with no descrition/comments" do
# Enable mock
_player = load_mock("video/regular_no-description.player")
_next = load_mock("video/regular_no-description.next")
raw_data = _player.merge!(_next)
info = parse_video_info("iuevw6218F0", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["videoType"].as_s).to eq("Video")
# Basic video infos
expect(info["title"].as_s).to eq("Chris Rea - Auberge")
expect(info["views"].as_i).to eq(10_356_197)
expect(info["likes"].as_i).to eq(0)
expect(info["lengthSeconds"].as_i).to eq(283_i64)
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
# Extra video infos
expect(info["allowedRegions"].as_a).to_not be_empty
expect(info["allowedRegions"].as_a.size).to eq(249)
expect(info["allowedRegions"].as_a).to contain(
"AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR",
"TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU",
"WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"
)
expect(info["keywords"].as_a).to_not be_empty
expect(info["keywords"].as_a.size).to eq(4)
expect(info["keywords"].as_a).to contain_exactly(
"Chris",
"Rea",
"Auberge",
"1991"
).in_any_order
expect(info["allowRatings"].as_bool).to be_true
expect(info["isFamilyFriendly"].as_bool).to be_true
expect(info["isListed"].as_bool).to be_true
expect(info["isUpcoming"].as_bool).to be_false
# Related videos
expect(info["relatedVideos"].as_a.size).to eq(19)
expect(info["relatedVideos"][0]["id"]).to eq("0bkrY_V0yZg")
expect(info["relatedVideos"][0]["title"]).to eq(
"Chris Rea Best Songs Collection - Chris Rea Greatest Hits Full Album 2022"
)
expect(info["relatedVideos"][0]["author"]).to eq("Rock Ultimate")
expect(info["relatedVideos"][0]["ucid"]).to eq("UCekSc2A19di9koUIpj8gxlQ")
expect(info["relatedVideos"][0]["view_count"]).to eq("1992412")
expect(info["relatedVideos"][0]["short_view_count"]).to eq("1.9M")
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
# Description
expect(info["description"].as_s).to eq(" ")
expect(info["shortDescription"].as_s).to be_empty
expect(info["descriptionHtml"].as_s).to eq("<p></p>")
# Video metadata
expect(info["genre"].as_s).to eq("Music")
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
# Author infos
expect(info["author"].as_s).to eq("ChrisReaOfficial")
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
expect(info["authorThumbnail"].as_s).to be_empty
expect(info["authorVerified"].as_bool).to be_false
expect(info["subCountText"].as_s).to eq("-")
end
end

View File

@ -1,6 +1,6 @@
require "../../parsers_helper.cr" require "../../parsers_helper.cr"
Spectator.describe Invidious::Hashtag do Spectator.describe "parse_video_info" do
it "parses scheduled livestreams data (test 1)" do it "parses scheduled livestreams data (test 1)" do
# Enable mock # Enable mock
_player = load_mock("video/scheduled_live_nintendo.player") _player = load_mock("video/scheduled_live_nintendo.player")
@ -12,26 +12,50 @@ Spectator.describe Invidious::Hashtag do
# Some basic verifications # Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any)) expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["shortDescription"].as_s).to eq( expect(info["videoType"].as_s).to eq("Scheduled")
"Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch."
)
expect(info["descriptionHtml"].as_s).to eq(
"Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch."
)
# Basic video infos
expect(info["title"].as_s).to eq("Xenoblade Chronicles 3 Nintendo Direct")
expect(info["views"].as_i).to eq(160)
expect(info["likes"].as_i).to eq(2_283) expect(info["likes"].as_i).to eq(2_283)
expect(info["lengthSeconds"].as_i).to eq(0_i64)
expect(info["published"].as_s).to eq("2022-06-22T14:00:00Z") # Unix 1655906400
expect(info["genre"].as_s).to eq("Gaming") # Extra video infos
expect(info["genreUrl"].raw).to be_nil
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
expect(info["authorThumbnail"].as_s).to eq( expect(info["allowedRegions"].as_a).to_not be_empty
"https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj" expect(info["allowedRegions"].as_a.size).to eq(249)
expect(info["allowedRegions"].as_a).to contain(
"AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR",
"TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU",
"WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"
) )
expect(info["authorVerified"].as_bool).to be_true expect(info["keywords"].as_a).to_not be_empty
expect(info["subCountText"].as_s).to eq("8.5M") expect(info["keywords"].as_a.size).to eq(11)
expect(info["keywords"].as_a).to contain_exactly(
"nintendo",
"game",
"gameplay",
"fun",
"video game",
"action",
"adventure",
"rpg",
"play",
"switch",
"nintendo switch"
).in_any_order
expect(info["allowRatings"].as_bool).to be_true
expect(info["isFamilyFriendly"].as_bool).to be_true
expect(info["isListed"].as_bool).to be_true
expect(info["isUpcoming"].as_bool).to be_true
# Related videos
expect(info["relatedVideos"].as_a.size).to eq(20) expect(info["relatedVideos"].as_a.size).to eq(20)
@ -50,6 +74,32 @@ Spectator.describe Invidious::Hashtag do
expect(info["relatedVideos"][16]["view_count"].as_s).to eq("53510") expect(info["relatedVideos"][16]["view_count"].as_s).to eq("53510")
expect(info["relatedVideos"][16]["short_view_count"].as_s).to eq("53K") expect(info["relatedVideos"][16]["short_view_count"].as_s).to eq("53K")
expect(info["relatedVideos"][16]["author_verified"].as_s).to eq("true") expect(info["relatedVideos"][16]["author_verified"].as_s).to eq("true")
# Description
description = "Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch."
expect(info["description"].as_s).to eq(description)
expect(info["shortDescription"].as_s).to eq(description)
expect(info["descriptionHtml"].as_s).to eq(description)
# Video metadata
expect(info["genre"].as_s).to eq("Gaming")
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
# Author infos
expect(info["author"].as_s).to eq("Nintendo")
expect(info["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg")
expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_true
expect(info["subCountText"].as_s).to eq("8.5M")
end end
it "parses scheduled livestreams data (test 2)" do it "parses scheduled livestreams data (test 2)" do
@ -63,34 +113,63 @@ Spectator.describe Invidious::Hashtag do
# Some basic verifications # Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any)) expect(typeof(info)).to eq(Hash(String, JSON::Any))
expect(info["shortDescription"].as_s).to start_with( expect(info["videoType"].as_s).to eq("Scheduled")
<<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL # Basic video infos
TXT
)
expect(info["descriptionHtml"].as_s).to start_with(
<<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a>
TXT
)
expect(info["title"].as_s).to eq("The Truth About Greenpeace w/ Dr. Patrick Moore | PBD Podcast | Ep. 171")
expect(info["views"].as_i).to eq(24)
expect(info["likes"].as_i).to eq(22) expect(info["likes"].as_i).to eq(22)
expect(info["lengthSeconds"].as_i).to eq(0_i64)
expect(info["published"].as_s).to eq("2022-07-14T13:00:00Z") # Unix 1657803600
expect(info["genre"].as_s).to eq("Entertainment") # Extra video infos
expect(info["genreUrl"].raw).to be_nil
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
expect(info["authorThumbnail"].as_s).to eq( expect(info["allowedRegions"].as_a).to_not be_empty
"https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" expect(info["allowedRegions"].as_a.size).to eq(249)
expect(info["allowedRegions"].as_a).to contain(
"AD", "AR", "BA", "BT", "CZ", "FO", "GL", "IO", "KE", "KH", "LS",
"LT", "MP", "NO", "PR", "RO", "SE", "SK", "SS", "SX", "SZ", "ZW"
) )
expect(info["authorVerified"].as_bool).to be_false expect(info["keywords"].as_a).to_not be_empty
expect(info["subCountText"].as_s).to eq("227K") expect(info["keywords"].as_a.size).to eq(25)
expect(info["keywords"].as_a).to contain_exactly(
"Patrick Bet-David",
"Valeutainment",
"The BetDavid Podcast",
"The BetDavid Show",
"Betdavid",
"PBD",
"BetDavid show",
"Betdavid podcast",
"podcast betdavid",
"podcast patrick",
"patrick bet david podcast",
"Valuetainment podcast",
"Entrepreneurs",
"Entrepreneurship",
"Entrepreneur Motivation",
"Entrepreneur Advice",
"Startup Entrepreneurs",
"valuetainment",
"patrick bet david",
"PBD podcast",
"Betdavid show",
"Betdavid Podcast",
"Podcast Betdavid",
"Show Betdavid",
"PBDPodcast"
).in_any_order
expect(info["allowRatings"].as_bool).to be_true
expect(info["isFamilyFriendly"].as_bool).to be_true
expect(info["isListed"].as_bool).to be_true
expect(info["isUpcoming"].as_bool).to be_true
# Related videos
expect(info["relatedVideos"].as_a.size).to eq(20) expect(info["relatedVideos"].as_a.size).to eq(20)
@ -109,5 +188,41 @@ Spectator.describe Invidious::Hashtag do
expect(info["relatedVideos"][9]["view_count"]).to eq("26432") expect(info["relatedVideos"][9]["view_count"]).to eq("26432")
expect(info["relatedVideos"][9]["short_view_count"]).to eq("26K") expect(info["relatedVideos"][9]["short_view_count"]).to eq("26K")
expect(info["relatedVideos"][9]["author_verified"]).to eq("true") expect(info["relatedVideos"][9]["author_verified"]).to eq("true")
# Description
description_start_text = <<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL
TXT
expect(info["description"].as_s).to start_with(description_start_text)
expect(info["shortDescription"].as_s).to start_with(description_start_text)
expect(info["descriptionHtml"].as_s).to start_with(
<<-TXT
PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick.
Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a>
TXT
)
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
expect(info["genreUcid"].as_s).to be_empty
expect(info["license"].as_s).to be_empty
# Author infos
expect(info["author"].as_s).to eq("PBD Podcast")
expect(info["ucid"].as_s).to eq("UCGX7nGXpz-CmO_Arg-cgJ7A")
expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_false
expect(info["subCountText"].as_s).to eq("227K")
end end
end end

View File

@ -12,6 +12,7 @@ require "../src/invidious/helpers/logger"
require "../src/invidious/helpers/utils" require "../src/invidious/helpers/utils"
require "../src/invidious/videos" require "../src/invidious/videos"
require "../src/invidious/videos/*"
require "../src/invidious/comments" require "../src/invidious/comments"
require "../src/invidious/helpers/serialized_yt_data" require "../src/invidious/helpers/serialized_yt_data"

View File

@ -5,6 +5,7 @@ require "protodec/utils"
require "yaml" require "yaml"
require "../src/invidious/helpers/*" require "../src/invidious/helpers/*"
require "../src/invidious/channels/*" require "../src/invidious/channels/*"
require "../src/invidious/videos/caption"
require "../src/invidious/videos" require "../src/invidious/videos"
require "../src/invidious/comments" require "../src/invidious/comments"
require "../src/invidious/playlists" require "../src/invidious/playlists"

View File

@ -34,9 +34,13 @@ require "protodec/utils"
require "./invidious/database/*" require "./invidious/database/*"
require "./invidious/database/migrations/*" require "./invidious/database/migrations/*"
require "./invidious/http_server/*"
require "./invidious/helpers/*" require "./invidious/helpers/*"
require "./invidious/yt_backend/*" require "./invidious/yt_backend/*"
require "./invidious/frontend/*" require "./invidious/frontend/*"
require "./invidious/videos/*"
require "./invidious/jsonify/**"
require "./invidious/*" require "./invidious/*"
require "./invidious/channels/*" require "./invidious/channels/*"
@ -45,6 +49,13 @@ require "./invidious/search/*"
require "./invidious/routes/**" require "./invidious/routes/**"
require "./invidious/jobs/**" require "./invidious/jobs/**"
# Declare the base namespace for invidious
module Invidious
end
# Simple alias to make code easier to read
alias IV = Invidious
CONFIG = Config.load CONFIG = Config.load
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
@ -169,7 +180,7 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32) CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new

View File

@ -16,12 +16,6 @@ record AboutChannel,
tabs : Array(String), tabs : Array(String),
verified : Bool verified : Bool
record AboutRelatedChannel,
ucid : String,
author : String,
author_url : String,
author_thumbnail : String
def get_about_info(ucid, locale) : AboutChannel def get_about_info(ucid, locale) : AboutChannel
begin begin
# "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"}
@ -100,38 +94,51 @@ def get_about_info(ucid, locale) : AboutChannel
total_views = 0_i64 total_views = 0_i64
joined = Time.unix(0) joined = Time.unix(0)
tabs = [] of String tab_names = [] of String
tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
if !tabs_json.nil? # Get the name of the tabs available on this channel
# Retrieve information from the tabs array. The index we are looking for varies between channels. tab_names = tabs_json.as_a.compact_map do |entry|
tabs_json.each do |node| name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
# Try to find the about section which is located in only one of the tabs.
channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]?
.try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]?
.try &.[0]?.try &.["channelAboutFullMetadataRenderer"]?
if !channel_about_meta.nil? # This is a small fix to not add extra code on the HTML side
total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 # I.e, the URL for the "live" tab is .../streams, so use "streams"
# everywhere for the sake of simplicity
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date. (name == "live") ? "streams" : name
joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, nd| acc + nd["text"].as_s } end
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Get the currently active tab ("About")
# Normal Auto-generated channels about_tab = extract_selected_tab(tabs_json)
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] # Try to find the about metadata section
if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && channel_about_meta = about_tab.dig?(
(channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" "content",
auto_generated = true "sectionListRenderer", "contents", 0,
end "itemSectionRenderer", "contents", 0,
end "channelAboutFullMetadataRenderer"
)
if !channel_about_meta.nil?
total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
joined = extract_text(channel_about_meta["joinedDateText"]?)
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Normal Auto-generated channels
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has
# ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
auto_generated = (
(channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube"
)
end end
tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase)
end end
sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? sub_count = initdata
.try { |text| short_text_to_number(text.split(" ")[0]) } || 0 .dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s?
.try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0
AboutChannel.new( AboutChannel.new(
ucid: ucid, ucid: ucid,
@ -147,46 +154,20 @@ def get_about_info(ucid, locale) : AboutChannel
joined: joined, joined: joined,
is_family_friendly: is_family_friendly, is_family_friendly: is_family_friendly,
allowed_regions: allowed_regions, allowed_regions: allowed_regions,
tabs: tabs, tabs: tab_names,
verified: author_verified || false, verified: author_verified || false,
) )
end end
def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedChannel) def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?}
# params is {"2:string":"channels"} encoded if continuation.nil?
channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") # params is {"2:string":"channels"} encoded
initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D")
tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any else
tab = tabs.find(&.dig?("tabRenderer", "title").try(&.as_s?).try(&.== "Channels")) initial_data = YoutubeAPI.browse(continuation)
return [] of AboutRelatedChannel if tab.nil?
items = tab.dig?(
"tabRenderer", "content",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"gridRenderer", "items"
).try &.as_a?
related = [] of AboutRelatedChannel
return related if (items.nil? || items.empty?)
items.each do |item|
renderer = item["gridChannelRenderer"]?
next if !renderer
related_id = renderer.dig("channelId").as_s
related_title = renderer.dig("title", "simpleText").as_s
related_author_url = renderer.dig("navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s
related_author_thumbnail = HelperExtractors.get_thumbnails(renderer)
related << AboutRelatedChannel.new(
ucid: related_id,
author: related_title,
author_url: related_author_url,
author_thumbnail: related_author_thumbnail,
)
end end
return related items, continuation = extract_items(initial_data)
return items.select(SearchChannel), continuation
end end

View File

@ -29,7 +29,7 @@ struct ChannelVideo
json.field "title", self.title json.field "title", self.title
json.field "videoId", self.id json.field "videoId", self.id
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, self.id) Invidious::JSONify::APIv1.thumbnails(json, self.id)
end end
json.field "lengthSeconds", self.length_seconds json.field "lengthSeconds", self.length_seconds
@ -180,11 +180,16 @@ def fetch_channel(ucid, pull_all_videos : Bool)
LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
page = 1 channel = InvidiousChannel.new({
id: ucid,
author: author,
updated: Time.utc,
deleted: false,
subscribed: nil,
})
LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page")
initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) videos, continuation = IV::Channel::Tabs.get_videos(channel)
videos = extract_videos(initial_data, author, ucid)
LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
rss.xpath_nodes("//feed/entry").each do |entry| rss.xpath_nodes("//feed/entry").each do |entry|
@ -197,7 +202,9 @@ def fetch_channel(ucid, pull_all_videos : Bool)
views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64?
views ||= 0_i64 views ||= 0_i64
channel_video = videos.select { |video| video.id == video_id }[0]? channel_video = videos
.select(SearchVideo)
.select(&.id.== video_id)[0]?
length_seconds = channel_video.try &.length_seconds length_seconds = channel_video.try &.length_seconds
length_seconds ||= 0 length_seconds ||= 0
@ -228,58 +235,56 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if was_insert if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
Invidious::Database::Users.add_notification(video) if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
else
Invidious::Database::Users.feed_needs_update(video)
end
else else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end end
end end
if pull_all_videos if pull_all_videos
page += 1
ids = [] of String
loop do loop do
initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) # Keep fetching videos using the continuation token retrieved earlier
videos = extract_videos(initial_data, author, ucid) videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation)
count = videos.size count = 0
videos = videos.map { |video| ChannelVideo.new({ videos.select(SearchVideo).each do |video|
id: video.id, count += 1
title: video.title, video = ChannelVideo.new({
published: video.published, id: video.id,
updated: Time.utc, title: video.title,
ucid: video.ucid, published: video.published,
author: video.author, updated: Time.utc,
length_seconds: video.length_seconds, ucid: video.ucid,
live_now: video.live_now, author: video.author,
premiere_timestamp: video.premiere_timestamp, length_seconds: video.length_seconds,
views: video.views, live_now: video.live_now,
}) } premiere_timestamp: video.premiere_timestamp,
views: video.views,
videos.each do |video| })
ids << video.id
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
# so since they don't provide a published date here we can safely ignore them. # so since they don't provide a published date here we can safely ignore them.
if Time.utc - video.published > 1.minute if Time.utc - video.published > 1.minute
was_insert = Invidious::Database::ChannelVideos.insert(video) was_insert = Invidious::Database::ChannelVideos.insert(video)
Invidious::Database::Users.add_notification(video) if was_insert if was_insert
if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
else
Invidious::Database::Users.feed_needs_update(video)
end
end
end end
end end
break if count < 25 break if count < 25
page += 1 sleep 500.milliseconds
end end
end end
channel = InvidiousChannel.new({ channel.updated = Time.utc
id: ucid,
author: author,
updated: Time.utc,
deleted: false,
subscribed: nil,
})
return channel return channel
end end

View File

@ -138,7 +138,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
json.field "title", video_title json.field "title", video_title
json.field "videoId", video_id json.field "videoId", video_id
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video_id) Invidious::JSONify::APIv1.thumbnails(json, video_id)
end end
json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)

View File

@ -1,93 +1,28 @@
def fetch_channel_playlists(ucid, author, continuation, sort_by) def fetch_channel_playlists(ucid, author, continuation, sort_by)
if continuation if continuation
response_json = YoutubeAPI.browse(continuation) initial_data = YoutubeAPI.browse(continuation)
continuation_items = response_json["onResponseReceivedActions"]?
.try &.[0]["appendContinuationItemsAction"]["continuationItems"]
return [] of SearchItem, nil if !continuation_items
items = [] of SearchItem
continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item|
extract_item(item, author, ucid).try { |t| items << t }
}
continuation = continuation_items.as_a.last["continuationItemRenderer"]?
.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
else else
url = "/channel/#{ucid}/playlists?flow=list&view=1" params =
case sort_by
when "last", "last_added"
# Equivalent to "&sort=lad"
# {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1}
"EglwbGF5bGlzdHMYBCABMAE%3D"
when "oldest", "oldest_created"
# formerly "&sort=da"
# Not available anymore :c or maybe ??
# {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1}
"EglwbGF5bGlzdHMYAiABMAE%3D"
# {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1}
# "EglwbGF5bGlzdHMYASABMAE%3D"
when "newest", "newest_created"
# Formerly "&sort=dd"
# {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1}
"EglwbGF5bGlzdHMYAyABMAE%3D"
end
case sort_by initial_data = YoutubeAPI.browse(ucid, params: params || "")
when "last", "last_added"
#
when "oldest", "oldest_created"
url += "&sort=da"
when "newest", "newest_created"
url += "&sort=dd"
else nil # Ignore
end
response = YT_POOL.client &.get(url)
initial_data = extract_initial_data(response.body)
return [] of SearchItem, nil if !initial_data
items = extract_items(initial_data, author, ucid)
continuation = response.body.match(/"token":"(?<continuation>[^"]+)"/).try &.["continuation"]?
end end
return items, continuation return extract_items(initial_data, author, ucid)
end
# ## NOTE: DEPRECATED
# Reason -> Unstable
# The Protobuf object must be provided with an id of the last playlist from the current "page"
# in order to fetch the next one accurately
# (if the id isn't included, entries shift around erratically between pages,
# leading to repetitions and skip overs)
#
# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user,
# it's better to stick to continuation tokens provided by the first request and onward
def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false)
object = {
"80226972:embedded" => {
"2:string" => ucid,
"3:base64" => {
"2:string" => "playlists",
"6:varint" => 2_i64,
"7:varint" => 1_i64,
"12:varint" => 1_i64,
"13:string" => "",
"23:varint" => 0_i64,
},
},
}
if cursor
cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
end
if auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64
else
object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64
case sort
when "oldest", "oldest_created"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64
when "newest", "newest_created"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64
when "last", "last_added"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64
else nil # Ignore
end
end
object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
object["80226972:embedded"].delete("3:base64")
continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
end end

View File

@ -16,6 +16,14 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
.try { |i| Base64.urlsafe_encode(i) } .try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) } .try { |i| URI.encode_www_form(i) }
sort_by_numerical =
case sort_by
when "newest" then 1_i64
when "popular" then 2_i64
when "oldest" then 3_i64 # Broken as of 10/2022 :c
else 1_i64 # Fallback to "newest"
end
object_inner_1 = { object_inner_1 = {
"110:embedded" => { "110:embedded" => {
"3:embedded" => { "3:embedded" => {
@ -24,7 +32,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
"1:string" => object_inner_2_encoded, "1:string" => object_inner_2_encoded,
"2:string" => "00000000-0000-0000-0000-000000000000", "2:string" => "00000000-0000-0000-0000-000000000000",
}, },
"3:varint" => 1_i64, "3:varint" => sort_by_numerical,
}, },
}, },
}, },
@ -52,34 +60,138 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
return continuation return continuation
end end
def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest")
continuation = produce_channel_videos_continuation(ucid, page,
auto_generated: auto_generated, sort_by: sort_by, v2: true)
return YoutubeAPI.browse(continuation)
end
def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
videos = [] of SearchVideo
# 2.times do |i|
# initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by)
initial_data = get_channel_videos_response(ucid, 1, auto_generated: auto_generated, sort_by: sort_by)
videos = extract_videos(initial_data, author, ucid)
# end
return videos.size, videos
end
def get_latest_videos(ucid)
initial_data = get_channel_videos_response(ucid)
author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s
return extract_videos(initial_data, author, ucid)
end
# Used in bypass_captcha_job.cr # Used in bypass_captcha_job.cr
def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2)
return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
end end
module Invidious::Channel::Tabs
extend self
# -------------------
# Regular videos
# -------------------
def make_initial_video_ctoken(ucid, sort_by) : String
return produce_channel_videos_continuation(ucid, sort_by: sort_by)
end
# Wrapper for AboutChannel, as we still need to call get_videos with
# an author name and ucid directly (e.g in RSS feeds).
# TODO: figure out how to get rid of that
def get_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
return get_videos(
channel.author, channel.ucid,
continuation: continuation, sort_by: sort_by
)
end
# Wrapper for InvidiousChannel, as we still need to call get_videos with
# an author name and ucid directly (e.g in RSS feeds).
# TODO: figure out how to get rid of that
def get_videos(channel : InvidiousChannel, *, continuation : String? = nil, sort_by = "newest")
return get_videos(
channel.author, channel.id,
continuation: continuation, sort_by: sort_by
)
end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_video_ctoken(ucid, sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid)
end
def get_60_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
if continuation.nil?
# Fetch the first "page" of video
items, next_continuation = get_videos(channel, sort_by: sort_by)
else
# Fetch a "page" of videos using the given continuation token
items, next_continuation = get_videos(channel, continuation: continuation)
end
# If there is more to load, then load a second "page"
# and replace the previous continuation token
if !next_continuation.nil?
items_2, next_continuation = get_videos(channel, continuation: next_continuation)
items.concat items_2
end
return items, next_continuation
end
# -------------------
# Shorts
# -------------------
private def fetch_shorts_data(ucid : String, continuation : String? = nil)
if continuation.nil?
# EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts"
# TODO: try to extract the continuation tokens that allows other sorting options
return YoutubeAPI.browse(ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")
else
return YoutubeAPI.browse(continuation: continuation)
end
end
def get_shorts(channel : AboutChannel, continuation : String? = nil)
initial_data = self.fetch_shorts_data(channel.ucid, continuation)
begin
# Try to parse the initial data fetched above
return extract_items(initial_data, channel.author, channel.ucid)
rescue ex : RetryOnceException
# Sometimes, for a completely unknown reason, the "reelItemRenderer"
# object is missing some critical information (it happens once in about
# 20 subsequent requests). Refreshing the page is required to properly
# show the "shorts" tab.
#
# In order to make the experience smoother for the user, we simulate
# said page refresh by fetching again the JSON. If that still doesn't
# work, we raise a BrokenTubeException, as something is really broken.
begin
initial_data = self.fetch_shorts_data(channel.ucid, continuation)
return extract_items(initial_data, channel.author, channel.ucid)
rescue ex : RetryOnceException
raise BrokenTubeException.new "reelPlayerHeaderSupportedRenderers"
end
end
end
# -------------------
# Livestreams
# -------------------
def get_livestreams(channel : AboutChannel, continuation : String? = nil)
if continuation.nil?
# EgdzdHJlYW1z8gYECgJ6AA%3D%3D is the protobuf object to load "streams"
initial_data = YoutubeAPI.browse(channel.ucid, params: "EgdzdHJlYW1z8gYECgJ6AA%3D%3D")
else
initial_data = YoutubeAPI.browse(continuation: continuation)
end
return extract_items(initial_data, channel.author, channel.ucid)
end
def get_60_livestreams(channel : AboutChannel, continuation : String? = nil)
if continuation.nil?
# Fetch the first "page" of streams
items, next_continuation = get_livestreams(channel)
else
# Fetch a "page" of streams using the given continuation token
items, next_continuation = get_livestreams(channel, continuation: continuation)
end
# If there is more to load, then load a second "page"
# and replace the previous continuation token
if !next_continuation.nil?
items_2, next_continuation = get_livestreams(channel, continuation: next_continuation)
items.concat items_2
end
return items, next_continuation
end
end

View File

@ -110,6 +110,8 @@ class Config
property hsts : Bool? = true property hsts : Bool? = true
# Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
property disable_proxy : Bool? | Array(String)? = false property disable_proxy : Bool? | Array(String)? = false
# Enable the user notifications for all users
property enable_user_notifications : Bool = true
# URL to the modified source code to be easily AGPL compliant # URL to the modified source code to be easily AGPL compliant
# Will display in the footer, next to the main source code link # Will display in the footer, next to the main source code link

View File

@ -154,6 +154,16 @@ module Invidious::Database::Users
# Update (misc) # Update (misc)
# ------------------- # -------------------
def feed_needs_update(video : ChannelVideo)
request = <<-SQL
UPDATE users
SET feed_needs_update = true
WHERE $1 = ANY(subscriptions)
SQL
PG_DB.exec(request, video.ucid)
end
def update_preferences(user : User) def update_preferences(user : User)
request = <<-SQL request = <<-SQL
UPDATE users UPDATE users

View File

@ -33,3 +33,8 @@ end
class VideoNotAvailableException < Exception class VideoNotAvailableException < Exception
end end
# Exception used to indicate that the JSON response from YT is missing
# some important informations, and that the query should be sent again.
class RetryOnceException < Exception
end

View File

@ -0,0 +1,44 @@
module Invidious::Frontend::ChannelPage
extend self
enum TabsAvailable
Videos
Shorts
Streams
Playlists
Community
Channels
end
def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable)
return String.build(1500) do |str|
base_url = "/channel/#{channel.ucid}"
TabsAvailable.each do |tab|
# Ignore playlists, as it is not supported for auto-generated channels yet
next if (tab.playlists? && channel.auto_generated)
tab_name = tab.to_s.downcase
if channel.tabs.includes? tab_name
str << %(<div class="pure-u-1 pure-md-1-3">\n)
if tab == selected_tab
str << "\t<b>"
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</b>\n"
else
# Video tab doesn't have the last path component
url = tab.videos? ? base_url : "#{base_url}/#{tab_name}"
str << %(\t<a href=") << url << %(">)
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</a>\n"
end
str << "</div>"
end
end
end
end
end

View File

@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage
getter full_videos : Array(Hash(String, JSON::Any)) getter full_videos : Array(Hash(String, JSON::Any))
getter video_streams : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any))
getter audio_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any))
getter captions : Array(Caption) getter captions : Array(Invidious::Videos::Caption)
def initialize( def initialize(
@full_videos, @full_videos,
@ -50,7 +50,7 @@ module Invidious::Frontend::WatchPage
video_assets.full_videos.each do |option| video_assets.full_videos.each do |option|
mimetype = option["mimeType"].as_s.split(";")[0] mimetype = option["mimeType"].as_s.split(";")[0]
height = itag_to_metadata?(option["itag"]).try &.["height"]? height = Invidious::Videos::Formats.itag_to_metadata?(option["itag"]).try &.["height"]?
value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json

View File

@ -8,7 +8,8 @@ module Invidious::Hashtag
client_config = YoutubeAPI::ClientConfig.new(region: region) client_config = YoutubeAPI::ClientConfig.new(region: region)
response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config)
return extract_items(response) items, _ = extract_items(response)
return items
end end
def generate_continuation(hashtag : String, cursor : Int) def generate_continuation(hashtag : String, cursor : Int)

View File

@ -20,7 +20,7 @@ module JSONFilter
/^\(|\(\(|\/\(/ /^\(|\(\(|\/\(/
end end
def self.parse_fields(fields_text : String) : Nil def self.parse_fields(fields_text : String, &) : Nil
if fields_text.empty? if fields_text.empty?
raise FieldsParser::ParseError.new "Fields is empty" raise FieldsParser::ParseError.new "Fields is empty"
end end
@ -42,7 +42,7 @@ module JSONFilter
parse_nest_groups(fields_text) { |nest_list| yield nest_list } parse_nest_groups(fields_text) { |nest_list| yield nest_list }
end end
def self.parse_single_nests(fields_text : String) : Nil def self.parse_single_nests(fields_text : String, &) : Nil
single_nests = remove_nest_groups(fields_text) single_nests = remove_nest_groups(fields_text)
if !single_nests.empty? if !single_nests.empty?
@ -60,7 +60,7 @@ module JSONFilter
end end
end end
def self.parse_nest_groups(fields_text : String) : Nil def self.parse_nest_groups(fields_text : String, &) : Nil
nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64) nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64)
bracket_pairs = get_bracket_pairs(fields_text, true) bracket_pairs = get_bracket_pairs(fields_text, true)

View File

@ -76,7 +76,7 @@ struct SearchVideo
json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorUrl", "/channel/#{self.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, self.id) Invidious::JSONify::APIv1.thumbnails(json, self.id)
end end
json.field "description", html_to_content(self.description_html) json.field "description", html_to_content(self.description_html)
@ -155,7 +155,7 @@ struct SearchPlaylist
json.field "lengthSeconds", video.length_seconds json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) Invidious::JSONify::APIv1.thumbnails(json, video.id)
end end
end end
end end
@ -265,4 +265,11 @@ class Category
end end
end end
struct Continuation
getter token
def initialize(@token : String)
end
end
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category

View File

@ -161,21 +161,19 @@ def number_with_separator(number)
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
end end
def short_text_to_number(short_text : String) : Int32 def short_text_to_number(short_text : String) : Int64
case short_text matches = /(?<number>\d+(\.\d+)?)\s?(?<suffix>[mMkKbB])?/.match(short_text)
when .ends_with? "M" number = matches.try &.["number"].to_f || 0.0
number = short_text.rstrip(" mM").to_f
number *= 1000000 case matches.try &.["suffix"].downcase
when .ends_with? "K" when "k" then number *= 1_000
number = short_text.rstrip(" kK").to_f when "m" then number *= 1_000_000
number *= 1000 when "b" then number *= 1_000_000_000
else
number = short_text.rstrip(" ")
end end
number = number.to_i return number.to_i64
rescue ex
return number return 0_i64
end end
def number_to_short_text(number) def number_to_short_text(number)

View File

@ -0,0 +1,20 @@
module Invidious::HttpServer
module Utils
extend self
def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false)
url = URI.parse(raw_url)
# Add some URL parameters
params = url.query_params
params["host"] = url.host.not_nil! # Should never be nil, in theory
params["region"] = region if !region.nil?
if absolute
return "#{HOST_URL}#{url.request_target}?#{params}"
else
return "#{url.request_target}?#{params}"
end
end
end
end

View File

@ -1,12 +1,12 @@
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
private getter connection_channel : Channel({Bool, Channel(PQ::Notification)}) private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
private getter pg_url : URI private getter pg_url : URI
def initialize(@connection_channel, @pg_url) def initialize(@connection_channel, @pg_url)
end end
def begin def begin
connections = [] of Channel(PQ::Notification) connections = [] of ::Channel(PQ::Notification)
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }

View File

@ -8,7 +8,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
max_fibers = CONFIG.channel_threads max_fibers = CONFIG.channel_threads
lim_fibers = max_fibers lim_fibers = max_fibers
active_fibers = 0 active_fibers = 0
active_channel = Channel(Bool).new active_channel = ::Channel(Bool).new
backoff = 2.minutes backoff = 2.minutes
loop do loop do

View File

@ -7,7 +7,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
def begin def begin
max_fibers = CONFIG.feed_threads max_fibers = CONFIG.feed_threads
active_fibers = 0 active_fibers = 0
active_channel = Channel(Bool).new active_channel = ::Channel(Bool).new
loop do loop do
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|

View File

@ -12,7 +12,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob
end end
active_fibers = 0 active_fibers = 0
active_channel = Channel(Bool).new active_channel = ::Channel(Bool).new
loop do loop do
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs|

View File

@ -0,0 +1,18 @@
require "json"
module Invidious::JSONify::APIv1
extend self
def thumbnails(json : JSON::Builder, id : String)
json.array do
build_thumbnails(id).each do |thumbnail|
json.object do
json.field "quality", thumbnail[:name]
json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "width", thumbnail[:width]
json.field "height", thumbnail[:height]
end
end
end
end
end

View File

@ -0,0 +1,258 @@
require "json"
module Invidious::JSONify::APIv1
extend self
def video(video : Video, json : JSON::Builder, *, locale : String?, proxy : Bool = false)
json.object do
json.field "type", video.video_type
json.field "title", video.title
json.field "videoId", video.id
json.field "error", video.info["reason"] if video.info["reason"]?
json.field "videoThumbnails" do
self.thumbnails(json, video.id)
end
json.field "storyboards" do
self.storyboards(json, video.id, video.storyboards)
end
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "published", video.published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
json.field "keywords", video.keywords
json.field "viewCount", video.views
json.field "likeCount", video.likes
json.field "dislikeCount", 0_i64
json.field "paid", video.paid
json.field "premium", video.premium
json.field "isFamilyFriendly", video.is_family_friendly
json.field "allowedRegions", video.allowed_regions
json.field "genre", video.genre
json.field "genreUrl", video.genre_url
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.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", video.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
json.field "width", quality
json.field "height", quality
end
end
end
end
json.field "subCountText", video.sub_count_text
json.field "lengthSeconds", video.length_seconds
json.field "allowRatings", video.allow_ratings
json.field "rating", 0_i64
json.field "isListed", video.is_listed
json.field "liveNow", video.live_now
json.field "isUpcoming", video.is_upcoming
if video.premiere_timestamp
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
end
if hlsvp = video.hls_manifest_url
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL)
json.field "hlsUrl", hlsvp
end
json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{video.id}"
json.field "adaptiveFormats" do
json.array do
video.adaptive_fmts.each do |fmt|
json.object do
# Only available on regular videos, not livestreams/OTF streams
if init_range = fmt["initRange"]?
json.field "init", "#{init_range["start"]}-#{init_range["end"]}"
end
if index_range = fmt["indexRange"]?
json.field "index", "#{index_range["start"]}-#{index_range["end"]}"
end
# Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only)
json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
if proxy
json.field "url", Invidious::HttpServer::Utils.proxy_video_url(
fmt["url"].to_s, absolute: true
)
else
json.field "url", fmt["url"]
end
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
json.field "clen", fmt["contentLength"]? || "-1"
# Last modified is a unix timestamp with µS, with the dot omitted.
# E.g: 1638056732(.)141582
#
# On livestreams, it's not present, so always fall back to the
# current unix timestamp (up to mS precision) for compatibility.
last_modified = fmt["lastModified"]?
last_modified ||= "#{Time.utc.to_unix_ms.to_s}000"
json.field "lmt", last_modified
json.field "projectionType", fmt["projectionType"]
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
json.field "fps", fps
json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end
# Livestream chunk infos
json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec")
json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec")
# Audio-related data
json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality")
json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate")
json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels")
# Extra misc stuff
json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo")
json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack")
end
end
end
end
json.field "formatStreams" do
json.array do
video.fmt_stream.each do |fmt|
json.object do
json.field "url", fmt["url"]
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
json.field "quality", fmt["quality"]
fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
json.field "fps", fps
json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end
end
end
end
end
end
json.field "captions" do
json.array do
video.captions.each do |caption|
json.object do
json.field "label", caption.name
json.field "language_code", caption.language_code
json.field "url", "/api/v1/captions/#{video.id}?label=#{URI.encode_www_form(caption.name)}"
end
end
end
end
json.field "recommendedVideos" do
json.array do
video.related_videos.each do |rv|
if rv["id"]?
json.object do
json.field "videoId", rv["id"]
json.field "title", rv["title"]
json.field "videoThumbnails" do
self.thumbnails(json, rv["id"])
end
json.field "author", rv["author"]
json.field "authorUrl", "/channel/#{rv["ucid"]?}"
json.field "authorId", rv["ucid"]?
if rv["author_thumbnail"]?
json.field "authorThumbnails" do
json.array do
qualities = {32, 48, 76, 100, 176, 512}
qualities.each do |quality|
json.object do
json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
end
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]?
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
end
end
end
end
end
end
end
def storyboards(json, id, storyboards)
json.array do
storyboards.each do |storyboard|
json.object do
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
json.field "templateUrl", storyboard[:url]
json.field "width", storyboard[:width]
json.field "height", storyboard[:height]
json.field "count", storyboard[:count]
json.field "interval", storyboard[:interval]
json.field "storyboardWidth", storyboard[:storyboard_width]
json.field "storyboardHeight", storyboard[:storyboard_height]
json.field "storyboardCount", storyboard[:storyboard_count]
end
end
end
end
end

View File

@ -56,7 +56,7 @@ struct PlaylistVideo
json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorUrl", "/channel/#{self.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, self.id) Invidious::JSONify::APIv1.thumbnails(json, self.id)
end end
if index if index

View File

@ -14,8 +14,6 @@ module Invidious::Routes::API::Manifest
begin begin
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, status_code: 404 haltf env, status_code: 404
rescue ex rescue ex
@ -31,7 +29,7 @@ module Invidious::Routes::API::Manifest
if local if local
uri = URI.parse(url) uri = URI.parse(url)
url = "#{uri.request_target}host/#{uri.host}/" url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/"
end end
"<BaseURL>#{url}</BaseURL>" "<BaseURL>#{url}</BaseURL>"
@ -44,7 +42,7 @@ module Invidious::Routes::API::Manifest
if local if local
adaptive_fmts.each do |fmt| adaptive_fmts.each do |fmt|
fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}")
end end
end end

View File

@ -1,13 +1,7 @@
module Invidious::Routes::API::V1::Channels module Invidious::Routes::API::V1::Channels
def self.home(env) # Macro to avoid duplicating some code below
locale = env.get("preferences").as(Preferences).locale # This sets the `channel` variable, or handles Exceptions.
private macro get_channel
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
sort_by = env.params.query["sort_by"]?.try &.downcase
sort_by ||= "newest"
begin begin
channel = get_about_info(ucid, locale) channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect rescue ex : ChannelRedirect
@ -18,17 +12,25 @@ module Invidious::Routes::API::V1::Channels
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
end
page = 1 def self.home(env)
if channel.auto_generated locale = env.get("preferences").as(Preferences).locale
videos = [] of SearchVideo ucid = env.params.url["ucid"]
count = 0
else env.response.content_type = "application/json"
begin
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) # Use the private macro defined above.
rescue ex channel = nil # Make the compiler happy
return error_json(500, ex) get_channel()
end
# Retrieve "sort by" setting from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
return error_json(500, ex)
end end
JSON.build do |json| JSON.build do |json|
@ -100,31 +102,13 @@ module Invidious::Routes::API::V1::Channels
json.array do json.array do
# Fetch related channels # Fetch related channels
begin begin
related_channels = fetch_related_channels(channel) related_channels, _ = fetch_related_channels(channel)
rescue ex rescue ex
related_channels = [] of AboutRelatedChannel related_channels = [] of SearchChannel
end end
related_channels.each do |related_channel| related_channels.each do |related_channel|
json.object do related_channel.to_json(locale, json)
json.field "author", related_channel.author
json.field "authorId", related_channel.ucid
json.field "authorUrl", related_channel.author_url
json.field "authorThumbnails" do
json.array do
qualities = {32, 48, 76, 100, 176, 512}
qualities.each do |quality|
json.object do
json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
json.field "width", quality
json.field "height", quality
end
end
end
end
end
end end
end end
end # relatedChannels end # relatedChannels
@ -134,61 +118,112 @@ module Invidious::Routes::API::V1::Channels
end end
def self.latest(env) def self.latest(env)
locale = env.get("preferences").as(Preferences).locale # Remove parameters that could affect this endpoint's behavior
env.params.query.delete("sort_by") if env.params.query.has_key?("sort_by")
env.params.query.delete("continuation") if env.params.query.has_key?("continuation")
env.response.content_type = "application/json" return self.videos(env)
ucid = env.params.url["ucid"]
begin
videos = get_latest_videos(ucid)
rescue ex
return error_json(500, ex)
end
JSON.build do |json|
json.array do
videos.each do |video|
video.to_json(locale, json)
end
end
end
end end
def self.videos(env) def self.videos(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
ucid = env.params.url["ucid"]
env.response.content_type = "application/json" env.response.content_type = "application/json"
ucid = env.params.url["ucid"] # Use the private macro defined above.
page = env.params.query["page"]?.try &.to_i? channel = nil # Make the compiler happy
page ||= 1 get_channel()
sort_by = env.params.query["sort"]?.try &.downcase
sort_by ||= env.params.query["sort_by"]?.try &.downcase # Retrieve some URL parameters
sort_by ||= "newest" sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
begin begin
channel = get_about_info(ucid, locale) videos, next_continuation = Channel::Tabs.get_60_videos(
rescue ex : ChannelRedirect channel, continuation: continuation, sort_by: sort_by
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) )
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
begin return JSON.build do |json|
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) json.object do
rescue ex json.field "videos" do
return error_json(500, ex) json.array do
end videos.each &.to_json(locale, json)
end
JSON.build do |json|
json.array do
videos.each do |video|
video.to_json(locale, json)
end end
json.field "continuation", next_continuation if next_continuation
end
end
end
def self.shorts(env)
locale = env.get("preferences").as(Preferences).locale
ucid = env.params.url["ucid"]
env.response.content_type = "application/json"
# Use the private macro defined above.
channel = nil # Make the compiler happy
get_channel()
# Retrieve continuation from URL parameters
continuation = env.params.query["continuation"]?
begin
videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
rescue ex
return error_json(500, ex)
end
return JSON.build do |json|
json.object do
json.field "videos" do
json.array do
videos.each &.to_json(locale, json)
end
end
json.field "continuation", next_continuation if next_continuation
end
end
end
def self.streams(env)
locale = env.get("preferences").as(Preferences).locale
ucid = env.params.url["ucid"]
env.response.content_type = "application/json"
# Use the private macro defined above.
channel = nil # Make the compiler happy
get_channel()
# Retrieve continuation from URL parameters
continuation = env.params.query["continuation"]?
begin
videos, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation
)
rescue ex
return error_json(500, ex)
end
return JSON.build do |json|
json.object do
json.field "videos" do
json.array do
videos.each &.to_json(locale, json)
end
end
json.field "continuation", next_continuation if next_continuation
end end
end end
end end
@ -204,16 +239,9 @@ module Invidious::Routes::API::V1::Channels
env.params.query["sort_by"]?.try &.downcase || env.params.query["sort_by"]?.try &.downcase ||
"last" "last"
begin # Use the macro defined above
channel = get_about_info(ucid, locale) channel = nil # Make the compiler happy
rescue ex : ChannelRedirect get_channel()
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
@ -255,6 +283,37 @@ module Invidious::Routes::API::V1::Channels
end end
end end
def self.channels(env)
locale = env.get("preferences").as(Preferences).locale
ucid = env.params.url["ucid"]
env.response.content_type = "application/json"
# Use the macro defined above
channel = nil # Make the compiler happy
get_channel()
continuation = env.params.query["continuation"]?
begin
items, next_continuation = fetch_related_channels(channel, continuation)
rescue ex
return error_json(500, ex)
end
JSON.build do |json|
json.object do
json.field "relatedChannels" do
json.array do
items.each &.to_json(locale, json)
end
end
json.field "continuation", next_continuation if next_continuation
end
end
end
def self.search(env) def self.search(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]? region = env.params.query["region"]?

View File

@ -124,7 +124,7 @@ module Invidious::Routes::API::V1::Misc
json.field "videoThumbnails" do json.field "videoThumbnails" do
json.array do json.array do
generate_thumbnails(json, video.id) Invidious::JSONify::APIv1.thumbnails(json, video.id)
end end
end end

View File

@ -6,19 +6,19 @@ module Invidious::Routes::API::V1::Videos
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]?
proxy = {"1", "true"}.any? &.== env.params.query["local"]?
begin begin
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException rescue ex : NotFoundException
return error_json(404, ex) return error_json(404, ex)
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
video.to_json(locale, nil) return JSON.build do |json|
Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy)
end
end end
def self.captions(env) def self.captions(env)
@ -41,9 +41,6 @@ module Invidious::Routes::API::V1::Videos
begin begin
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, 404 haltf env, 404
rescue ex rescue ex
@ -168,9 +165,6 @@ module Invidious::Routes::API::V1::Videos
begin begin
video = get_video(id, region: region) video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex : NotFoundException rescue ex : NotFoundException
haltf env, 404 haltf env, 404
rescue ex rescue ex
@ -185,7 +179,7 @@ module Invidious::Routes::API::V1::Videos
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
json.field "storyboards" do json.field "storyboards" do
generate_storyboards(json, id, storyboards) Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
end end
end end
end end

View File

@ -7,21 +7,19 @@ module Invidious::Routes::Channels
def self.videos(env) def self.videos(env)
data = self.fetch_basic_information(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple) return data if !data.is_a?(Tuple)
return data
end
locale, user, subscriptions, continuation, ucid, channel = data
page = env.params.query["page"]?.try &.to_i? locale, user, subscriptions, continuation, ucid, channel = data
page ||= 1
sort_by = env.params.query["sort_by"]?.try &.downcase sort_by = env.params.query["sort_by"]?.try &.downcase
if channel.auto_generated if channel.auto_generated
sort_options = {"last", "oldest", "newest"} sort_options = {"last", "oldest", "newest"}
sort_by ||= "last"
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items, next_continuation = fetch_channel_playlists(
channel.ucid, channel.author, continuation, (sort_by || "last")
)
items.uniq! do |item| items.uniq! do |item|
if item.responds_to?(:title) if item.responds_to?(:title)
item.title item.title
@ -33,34 +31,85 @@ module Invidious::Routes::Channels
items.each(&.author = "") items.each(&.author = "")
else else
sort_options = {"newest", "oldest", "popular"} sort_options = {"newest", "oldest", "popular"}
sort_by ||= "newest"
count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) # Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_videos(
channel, continuation: continuation, sort_by: (sort_by || "newest")
)
end end
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
templated "channel"
end
def self.shorts(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
locale, user, subscriptions, continuation, ucid, channel = data
if !channel.tabs.includes? "shorts"
return env.redirect "/channel/#{channel.ucid}"
end
# TODO: support sort option for shorts
sort_by = ""
sort_options = [] of String
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation
)
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
templated "channel"
end
def self.streams(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
locale, user, subscriptions, continuation, ucid, channel = data
if !channel.tabs.includes? "streams"
return env.redirect "/channel/#{channel.ucid}"
end
# TODO: support sort option for livestreams
sort_by = ""
sort_options = [] of String
# Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation
)
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
templated "channel" templated "channel"
end end
def self.playlists(env) def self.playlists(env)
data = self.fetch_basic_information(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple) return data if !data.is_a?(Tuple)
return data
end
locale, user, subscriptions, continuation, ucid, channel = data locale, user, subscriptions, continuation, ucid, channel = data
sort_options = {"last", "oldest", "newest"} sort_options = {"last", "oldest", "newest"}
sort_by = env.params.query["sort_by"]?.try &.downcase sort_by = env.params.query["sort_by"]?.try &.downcase
sort_by ||= "last"
if channel.auto_generated if channel.auto_generated
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items, next_continuation = fetch_channel_playlists(
channel.ucid, channel.author, continuation, (sort_by || "last")
)
items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) items = items.select(SearchPlaylist).map(&.as(SearchPlaylist))
items.each(&.author = "") items.each(&.author = "")
templated "playlists" selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists
templated "channel"
end end
def self.community(env) def self.community(env)
@ -74,12 +123,15 @@ module Invidious::Routes::Channels
thin_mode = thin_mode == "true" thin_mode = thin_mode == "true"
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
# sort_by = env.params.query["sort_by"]?.try &.downcase
if !channel.tabs.includes? "community" if !channel.tabs.includes? "community"
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
# TODO: support sort options for community posts
sort_by = ""
sort_options = [] of String
begin begin
items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
rescue ex : InfoException rescue ex : InfoException
@ -95,6 +147,26 @@ module Invidious::Routes::Channels
templated "community" templated "community"
end end
def self.channels(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
locale, user, subscriptions, continuation, ucid, channel = data
if channel.auto_generated
return env.redirect "/channel/#{channel.ucid}"
end
items, next_continuation = fetch_related_channels(channel, continuation)
# Featured/related channels can't be sorted
sort_options = [] of String
sort_by = nil
selected_tab = Frontend::ChannelPage::TabsAvailable::Channels
templated "channel"
end
def self.about(env) def self.about(env)
data = self.fetch_basic_information(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple) if !data.is_a?(Tuple)
@ -125,7 +197,7 @@ module Invidious::Routes::Channels
end end
selected_tab = env.request.path.split("/")[-1] selected_tab = env.request.path.split("/")[-1]
if ["home", "videos", "playlists", "community", "channels", "about"].includes? selected_tab if {"home", "videos", "shorts", "streams", "playlists", "community", "channels", "about"}.includes? selected_tab
url = "/channel/#{ucid}/#{selected_tab}" url = "/channel/#{ucid}/#{selected_tab}"
else else
url = "/channel/#{ucid}" url = "/channel/#{ucid}"

View File

@ -131,8 +131,6 @@ module Invidious::Routes::Embed
begin begin
video = get_video(id, region: params.region) video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_template(404, ex) return error_template(404, ex)
rescue ex rescue ex
@ -149,7 +147,7 @@ module Invidious::Routes::Embed
# PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) # PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email)
# end # end
if notifications && notifications.includes? id if CONFIG.enable_user_notifications && notifications && notifications.includes? id
Invidious::Database::Users.remove_notification(user.as(User), id) Invidious::Database::Users.remove_notification(user.as(User), id)
env.get("user").as(User).notifications.delete(id) env.get("user").as(User).notifications.delete(id)
notifications.delete(id) notifications.delete(id)

View File

@ -96,12 +96,14 @@ module Invidious::Routes::Feeds
videos, notifications = get_subscription_feed(user, max_results, page) videos, notifications = get_subscription_feed(user, max_results, page)
# "updated" here is used for delivering new notifications, so if if CONFIG.enable_user_notifications
# we know a user has looked at their feed e.g. in the past 10 minutes, # "updated" here is used for delivering new notifications, so if
# they've already seen a video posted 20 minutes ago, and don't need # we know a user has looked at their feed e.g. in the past 10 minutes,
# to be notified. # they've already seen a video posted 20 minutes ago, and don't need
Invidious::Database::Users.clear_notifications(user) # to be notified.
user.notifications = [] of String Invidious::Database::Users.clear_notifications(user)
user.notifications = [] of String
end
env.set "user", user env.set "user", user
templated "feeds/subscriptions" templated "feeds/subscriptions"
@ -404,13 +406,15 @@ module Invidious::Routes::Feeds
video = get_video(id, force_refresh: true) video = get_video(id, force_refresh: true)
# Deliver notifications to `/api/v1/auth/notifications` if CONFIG.enable_user_notifications
payload = { # Deliver notifications to `/api/v1/auth/notifications`
"topic" => video.ucid, payload = {
"videoId" => video.id, "topic" => video.ucid,
"published" => published.to_unix, "videoId" => video.id,
}.to_json "published" => published.to_unix,
PG_DB.exec("NOTIFY notifications, E'#{payload}'") }.to_json
PG_DB.exec("NOTIFY notifications, E'#{payload}'")
end
video = ChannelVideo.new({ video = ChannelVideo.new({
id: id, id: id,
@ -426,7 +430,13 @@ module Invidious::Routes::Feeds
}) })
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
Invidious::Database::Users.add_notification(video) if was_insert if was_insert
if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
else
Invidious::Database::Users.feed_needs_update(video)
end
end
end end
end end

View File

@ -35,6 +35,13 @@ module Invidious::Routes::VideoPlayback
end end
end end
# See: https://github.com/iv-org/invidious/issues/3302
range_header = env.request.headers["Range"]?
if range_header.nil?
range_for_head = query_params["range"]? || "0-640"
headers["Range"] = "bytes=#{range_for_head}"
end
client = make_client(URI.parse(host), region) client = make_client(URI.parse(host), region)
response = HTTP::Client::Response.new(500) response = HTTP::Client::Response.new(500)
error = "" error = ""
@ -70,6 +77,9 @@ module Invidious::Routes::VideoPlayback
end end
end end
# Remove the Range header added previously.
headers.delete("Range") if range_header.nil?
if response.status_code >= 400 if response.status_code >= 400
env.response.content_type = "text/plain" env.response.content_type = "text/plain"
haltf env, response.status_code haltf env, response.status_code
@ -91,14 +101,8 @@ module Invidious::Routes::VideoPlayback
env.response.headers["Access-Control-Allow-Origin"] = "*" env.response.headers["Access-Control-Allow-Origin"] = "*"
if location = resp.headers["Location"]? if location = resp.headers["Location"]?
location = URI.parse(location) url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region)
location = "#{location.request_target}&host=#{location.host}" return env.redirect url
if region
location += "&region=#{region}"
end
return env.redirect location
end end
IO.copy(resp.body_io, env.response) IO.copy(resp.body_io, env.response)

View File

@ -61,8 +61,6 @@ module Invidious::Routes::Watch
begin begin
video = get_video(id, region: params.region) video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex : NotFoundException rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}") LOGGER.error("get_video not found: #{id} : #{ex.message}")
return error_template(404, ex) return error_template(404, ex)
@ -82,7 +80,7 @@ module Invidious::Routes::Watch
Invidious::Database::Users.mark_watched(user.as(User), id) Invidious::Database::Users.mark_watched(user.as(User), id)
end end
if notifications && notifications.includes? id if CONFIG.enable_user_notifications && notifications && notifications.includes? id
Invidious::Database::Users.remove_notification(user.as(User), id) Invidious::Database::Users.remove_notification(user.as(User), id)
env.get("user").as(User).notifications.delete(id) env.get("user").as(User).notifications.delete(id)
notifications.delete(id) notifications.delete(id)

View File

@ -37,7 +37,9 @@ module Invidious::Routing
get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get
post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post
get "/modify_notifications", Routes::Notifications, :modify if CONFIG.enable_user_notifications
get "/modify_notifications", Routes::Notifications, :modify
end
{% end %} {% end %}
self.register_image_routes self.register_image_routes
@ -115,14 +117,17 @@ module Invidious::Routing
get "/channel/:ucid", Routes::Channels, :home get "/channel/:ucid", Routes::Channels, :home
get "/channel/:ucid/home", Routes::Channels, :home get "/channel/:ucid/home", Routes::Channels, :home
get "/channel/:ucid/videos", Routes::Channels, :videos get "/channel/:ucid/videos", Routes::Channels, :videos
get "/channel/:ucid/shorts", Routes::Channels, :shorts
get "/channel/:ucid/streams", Routes::Channels, :streams
get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/playlists", Routes::Channels, :playlists
get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/community", Routes::Channels, :community
get "/channel/:ucid/channels", Routes::Channels, :channels
get "/channel/:ucid/about", Routes::Channels, :about get "/channel/:ucid/about", Routes::Channels, :about
get "/channel/:ucid/live", Routes::Channels, :live get "/channel/:ucid/live", Routes::Channels, :live
get "/user/:user/live", Routes::Channels, :live get "/user/:user/live", Routes::Channels, :live
get "/c/:user/live", Routes::Channels, :live get "/c/:user/live", Routes::Channels, :live
["", "/videos", "/playlists", "/community", "/about"].each do |path| {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path|
# /c/LinusTechTips # /c/LinusTechTips
get "/c/:user#{path}", Routes::Channels, :brand_redirect get "/c/:user#{path}", Routes::Channels, :brand_redirect
# /user/linustechtips | Not always the same as /c/ # /user/linustechtips | Not always the same as /c/
@ -220,6 +225,10 @@ module Invidious::Routing
# Channels # Channels
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
{% for route in {"videos", "latest", "playlists", "community", "search"} %} {% for route in {"videos", "latest", "playlists", "community", "search"} %}
get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
@ -260,8 +269,10 @@ module Invidious::Routing
post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications if CONFIG.enable_user_notifications
post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
end
# Misc # Misc
get "/api/v1/stats", {{namespace}}::Misc, :stats get "/api/v1/stats", {{namespace}}::Misc, :stats

View File

@ -9,7 +9,8 @@ module Invidious::Search
client_config = YoutubeAPI::ClientConfig.new(region: query.region) client_config = YoutubeAPI::ClientConfig.new(region: query.region)
initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config)
return extract_items(initial_data) items, _ = extract_items(initial_data)
return items
end end
# Search a youtube channel # Search a youtube channel
@ -30,16 +31,7 @@ module Invidious::Search
continuation = produce_channel_search_continuation(ucid, query.text, query.page) continuation = produce_channel_search_continuation(ucid, query.text, query.page)
response_json = YoutubeAPI.browse(continuation) response_json = YoutubeAPI.browse(continuation)
continuation_items = response_json["onResponseReceivedActions"]? items, _ = extract_items(response_json, "", ucid)
.try &.[0]["appendContinuationItemsAction"]["continuationItems"]
return [] of SearchItem if !continuation_items
items = [] of SearchItem
continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item|
extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t }
end
return items return items
end end

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
require "json"
module Invidious::Videos
struct Caption
property name : String
property language_code : String
property base_url : String
def initialize(@name, @language_code, @base_url)
end
# Parse the JSON structure from Youtube
def self.from_yt_json(container : JSON::Any) : Array(Caption)
caption_tracks = container
.dig?("playerCaptionsTracklistRenderer", "captionTracks")
.try &.as_a
captions_list = [] of Caption
return captions_list if caption_tracks.nil?
caption_tracks.each do |caption|
name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"]
name = name.to_s.split(" - ")[0]
language_code = caption["languageCode"].to_s
base_url = caption["baseUrl"].to_s
captions_list << Caption.new(name, language_code, base_url)
end
return captions_list
end
# List of all caption languages available on Youtube.
LANGUAGES = {
"",
"English",
"English (auto-generated)",
"English (United Kingdom)",
"English (United States)",
"Afrikaans",
"Albanian",
"Amharic",
"Arabic",
"Armenian",
"Azerbaijani",
"Bangla",
"Basque",
"Belarusian",
"Bosnian",
"Bulgarian",
"Burmese",
"Cantonese (Hong Kong)",
"Catalan",
"Cebuano",
"Chinese",
"Chinese (China)",
"Chinese (Hong Kong)",
"Chinese (Simplified)",
"Chinese (Taiwan)",
"Chinese (Traditional)",
"Corsican",
"Croatian",
"Czech",
"Danish",
"Dutch",
"Dutch (auto-generated)",
"Esperanto",
"Estonian",
"Filipino",
"Finnish",
"French",
"French (auto-generated)",
"Galician",
"Georgian",
"German",
"German (auto-generated)",
"Greek",
"Gujarati",
"Haitian Creole",
"Hausa",
"Hawaiian",
"Hebrew",
"Hindi",
"Hmong",
"Hungarian",
"Icelandic",
"Igbo",
"Indonesian",
"Indonesian (auto-generated)",
"Interlingue",
"Irish",
"Italian",
"Italian (auto-generated)",
"Japanese",
"Japanese (auto-generated)",
"Javanese",
"Kannada",
"Kazakh",
"Khmer",
"Korean",
"Korean (auto-generated)",
"Kurdish",
"Kyrgyz",
"Lao",
"Latin",
"Latvian",
"Lithuanian",
"Luxembourgish",
"Macedonian",
"Malagasy",
"Malay",
"Malayalam",
"Maltese",
"Maori",
"Marathi",
"Mongolian",
"Nepali",
"Norwegian Bokmål",
"Nyanja",
"Pashto",
"Persian",
"Polish",
"Portuguese",
"Portuguese (auto-generated)",
"Portuguese (Brazil)",
"Punjabi",
"Romanian",
"Russian",
"Russian (auto-generated)",
"Samoan",
"Scottish Gaelic",
"Serbian",
"Shona",
"Sindhi",
"Sinhala",
"Slovak",
"Slovenian",
"Somali",
"Southern Sotho",
"Spanish",
"Spanish (auto-generated)",
"Spanish (Latin America)",
"Spanish (Mexico)",
"Spanish (Spain)",
"Sundanese",
"Swahili",
"Swedish",
"Tajik",
"Tamil",
"Telugu",
"Thai",
"Turkish",
"Turkish (auto-generated)",
"Ukrainian",
"Urdu",
"Uzbek",
"Vietnamese",
"Vietnamese (auto-generated)",
"Welsh",
"Western Frisian",
"Xhosa",
"Yiddish",
"Yoruba",
"Zulu",
}
end
end

View File

@ -0,0 +1,116 @@
module Invidious::Videos::Formats
def self.itag_to_metadata?(itag : JSON::Any)
return FORMATS[itag.to_s]?
end
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
private FORMATS = {
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
"6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
"13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"},
"17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"},
"18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"},
"22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"},
"37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
# 3D videos
"82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
# Apple HTTP Live Streaming
"91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"},
"96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"},
"132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"},
# DASH mp4 video
"133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"},
"134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"},
"135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"},
"137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"},
"138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
"160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"},
"212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"},
"298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60},
"299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60},
"266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"},
# Dash mp4 audio
"139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"},
"140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"},
"141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"},
"256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"},
"258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"},
"325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"},
"328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"},
# Dash webm
"167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"},
"242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"},
"243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"},
"244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"},
"248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"},
"271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"},
# itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
"272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"},
"302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"},
"315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
# Dash webm audio
"171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128},
"172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256},
# Dash webm audio with opus inside
"249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50},
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
"251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160},
# av01 video only formats sometimes served with "unknown" codecs
"394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"},
"395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"},
"396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"},
"397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"},
}
end

View File

@ -0,0 +1,373 @@
require "json"
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
# The compact renderer has video length in seconds, where the end
# screen rendered has a full text version ("42:40")
length = related["lengthInSeconds"]?.try &.as_i.to_s
length ||= related.dig?("lengthText", "simpleText").try do |box|
decode_length_seconds(box.as_s).to_s
end
# Both have "short", so the "long" option shouldn't be required
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
.try &.dig?("runs", 0)
author = channel_info.try &.dig?("text")
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
# "4,088,033 views", only available on compact renderer
# and when video is not a livestream
view_count = related.dig?("viewCountText", "simpleText")
.try &.as_s.gsub(/\D/, "")
short_view_count = related.try do |r|
HelperExtractors.get_short_view_count(r).to_s
end
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
"id" => related["videoId"],
"title" => related["title"]["simpleText"],
"author" => author || JSON::Any.new(""),
"ucid" => JSON::Any.new(ucid || ""),
"length_seconds" => JSON::Any.new(length || "0"),
"view_count" => JSON::Any.new(view_count || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
}
end
def extract_video_info(video_id : String, proxy_region : String? = nil)
# Init client config for the API
client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
# Fetch data from the player endpoint
# 8AEB param is used to fetch YouTube stories
player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "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
# Stop here if video is not a scheduled livestream or
# for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help
if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) ||
playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails")
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new(reason),
}
end
elsif video_id != player_response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
else
reason = nil
end
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
player_response = player_response.merge(next_response)
end
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
new_player_response = nil
if reason.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::Android
new_player_response = try_fetch_streaming_data(video_id, client_config)
elsif !reason.includes?("your country") # Handled separately
# The Android embedded client could help here
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Last hope
if new_player_response.nil?
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Replace player response and reset reason
if !new_player_response.nil?
player_response = new_player_response
params.delete("reason")
end
{"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
# Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
return params
end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
# 8AEB param is used to fetch YouTube stories
response = YoutubeAPI.player(video_id: id, params: "8AEB", client_config: client_config)
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
if id != response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise VideoNotAvailableException.new(
"The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)"
)
elsif playability_status == "OK"
return response
else
return nil
end
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
# Primary results are not available on Music videos
# See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725
if primary_results = main_results.dig?("results", "results", "contents")
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
.try &.["videoPrimaryInfoRenderer"]
video_secondary_renderer = primary_results
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
.try &.["videoSecondaryInfoRenderer"]
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
end
video_details = player_response.dig?("videoDetails")
microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
raise BrokenTubeException.new("videoDetails") if !video_details
raise BrokenTubeException.new("microformat") if !microformat
# Basic video infos
title = video_details["title"]?.try &.as_s
# We have to try to extract viewCount from videoPrimaryInfoRenderer first,
# then from videoDetails, as the latter is "0" for livestreams (we want
# to get the amount of viewers watching).
views_txt = video_primary_renderer
.try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text")
views_txt ||= video_details["viewCount"]?
views = views_txt.try &.as_s.gsub(/\D/, "").to_i64?
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
.try &.as_s.to_i64
published = microformat["publishDate"]?
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool || false
# Extra video infos
allowed_regions = microformat["availableCountries"]?
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
family_friendly = microformat["isFamilySafe"].try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
keywords = video_details["keywords"]?
.try &.as_a.map &.as_s || [] of String
# Related videos
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
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
end
end
# If nothing was found previously, fall back to end screen renderer
if related.empty?
# Container for "endScreenVideoRenderer" items
player_overlays = player_response.dig?(
"playerOverlays", "playerOverlayRenderer",
"endScreen", "watchNextEndScreenRenderer", "results"
)
player_overlays.try &.as_a.each do |element|
if item = element["endScreenVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
end
# Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
likes_button = toplevel_buttons.try &.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
# New format as of september 2022
likes_button ||= toplevel_buttons.try &.as_a
.find(&.["segmentedLikeDislikeButtonRenderer"]?)
.try &.dig?(
"segmentedLikeDislikeButtonRenderer",
"likeButton", "toggleButtonRenderer"
)
if likes_button
# Note: The like count from `toggledText` is off by one, as it would
# represent the new like count in the event where the user clicks on "like".
likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
end
# Description
description = microformat.dig?("description", "simpleText").try &.as_s || ""
short_description = player_response.dig?("videoDetails", "shortDescription")
description_html = video_secondary_renderer.try &.dig?("description", "runs")
.try &.as_a.try { |t| content_to_comment_html(t, video_id) }
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
genre = microformat["category"]?
genre_ucid = nil
license = nil
metadata.try &.each do |row|
metadata_title = extract_text(row.dig?("metadataRowRenderer", "title"))
contents = row.dig?("metadataRowRenderer", "contents", 0)
if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
genre = contents.try &.["text"]?
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
elsif metadata_title == "License"
license = contents.try &.dig?("runs", 0, "text")
elsif metadata_title == "Licensed to YouTube by"
license = contents.try &.["simpleText"]?
end
end
# Author infos
author = video_details["author"]?.try &.as_s
ucid = video_details["channelId"]?.try &.as_s
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
author_verified = has_verified_badge?(author_info["badges"]?)
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
end
# Return data
if live_now
video_type = VideoType::Livestream
elsif !premiere_timestamp.nil?
video_type = VideoType::Scheduled
published = premiere_timestamp || Time.utc
else
video_type = VideoType::Video
end
params = {
"videoType" => JSON::Any.new(video_type.to_s),
# Basic video infos
"title" => JSON::Any.new(title || ""),
"views" => JSON::Any.new(views || 0_i64),
"likes" => JSON::Any.new(likes || 0_i64),
"lengthSeconds" => JSON::Any.new(length_txt || 0_i64),
"published" => JSON::Any.new(published.to_rfc3339),
# Extra video infos
"allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }),
"allowRatings" => JSON::Any.new(allow_ratings || false),
"isFamilyFriendly" => JSON::Any.new(family_friendly || false),
"isListed" => JSON::Any.new(is_listed || false),
"isUpcoming" => JSON::Any.new(is_upcoming || false),
"keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
# Related videos
"relatedVideos" => JSON::Any.new(related),
# Description
"description" => JSON::Any.new(description || ""),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Author infos
"author" => JSON::Any.new(author || ""),
"ucid" => JSON::Any.new(ucid || ""),
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
"authorVerified" => JSON::Any.new(author_verified || false),
"subCountText" => JSON::Any.new(subs_text || "-"),
}
return params
end

Some files were not shown because too many files have changed in this diff Show More