mirror of
https://github.com/iv-org/invidious.git
synced 2024-11-22 05:27:21 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
420e12bb8b
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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).
|
||||||
|
@ -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" ]
|
||||||
|
@ -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" ]
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
130
locales/ar.json
130
locales/ar.json
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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í",
|
||||||
|
@ -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",
|
||||||
|
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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}} λίστες αναπαραγωγής",
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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)": "آلمانی (تولید خودکار)"
|
||||||
}
|
}
|
||||||
|
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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": "תאריך העלאה",
|
||||||
|
@ -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": "कोई भी समय",
|
||||||
|
@ -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",
|
||||||
|
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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: "
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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": "バグを報告する前に、次のことを確認してください。"
|
||||||
}
|
}
|
||||||
|
@ -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에서 버그를 찾은 것 같습니다!",
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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
1
locales/or.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
@ -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",
|
||||||
|
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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>"
|
||||||
}
|
}
|
||||||
|
@ -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 ❤",
|
||||||
|
@ -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",
|
||||||
|
@ -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": "по дате загрузки",
|
||||||
|
@ -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",
|
||||||
|
@ -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 s’ekziston 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": "S’u gjetën përfundime.",
|
||||||
|
"Could not pull trending pages.": "S’u 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"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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": "Бангла/Бенгалски",
|
||||||
|
@ -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",
|
||||||
|
558
locales/tr.json
558
locales/tr.json
@ -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 açıklamasını otomatik olarak genişlet: ",
|
"preferences_extend_desc_label": "Video Açı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": "Açı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 açıklamaları gizle",
|
"Hide annotations": "Ek Açıklamaları Gizle",
|
||||||
"Show annotations": "Ek açıklamaları göster",
|
"Show annotations": "Ek Açı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>"
|
||||||
}
|
}
|
||||||
|
@ -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}} перегляди",
|
||||||
|
@ -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",
|
||||||
|
@ -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": "上传日期",
|
||||||
|
@ -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
2
mocks
@ -1 +1 @@
|
|||||||
Subproject commit c401dd9203434b561022242c24b0c200d72284c0
|
Subproject commit dfd53ea6ceb3cbcbbce6004f6ce60b330ad0f9b1
|
0
scripts/deploy-database.sh
Normal file → Executable file
0
scripts/deploy-database.sh
Normal file → Executable file
2
scripts/fetch-player-dependencies.cr
Normal file → Executable file
2
scripts/fetch-player-dependencies.cr
Normal file → Executable 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
0
scripts/install-dependencies.sh
Normal file → Executable 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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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")
|
||||||
|
168
spec/invidious/videos/regular_videos_extract_spec.cr
Normal file
168
spec/invidious/videos/regular_videos_extract_spec.cr
Normal 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, I’ll 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
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
44
src/invidious/frontend/channel_page.cr
Normal file
44
src/invidious/frontend/channel_page.cr
Normal 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
|
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
20
src/invidious/http_server/utils.cr
Normal file
20
src/invidious/http_server/utils.cr
Normal 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
|
@ -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)) }
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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|
|
||||||
|
@ -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|
|
||||||
|
18
src/invidious/jsonify/api_v1/common.cr
Normal file
18
src/invidious/jsonify/api_v1/common.cr
Normal 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
|
258
src/invidious/jsonify/api_v1/video_json.cr
Normal file
258
src/invidious/jsonify/api_v1/video_json.cr
Normal 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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"]?
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}"
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 += "®ion=#{region}"
|
|
||||||
end
|
|
||||||
|
|
||||||
return env.redirect location
|
|
||||||
end
|
end
|
||||||
|
|
||||||
IO.copy(resp.body_io, env.response)
|
IO.copy(resp.body_io, env.response)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
168
src/invidious/videos/caption.cr
Normal file
168
src/invidious/videos/caption.cr
Normal 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
|
116
src/invidious/videos/formats.cr
Normal file
116
src/invidious/videos/formats.cr
Normal 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
|
373
src/invidious/videos/parser.cr
Normal file
373
src/invidious/videos/parser.cr
Normal 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
Loading…
Reference in New Issue
Block a user