diff --git a/assets/css/default.css b/assets/css/default.css index ab2b79e6..9788e9f7 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -490,8 +490,9 @@ hr { } /* Description Expansion Styling*/ -#descexpansionbutton { - display: none +#descexpansionbutton, +#music-desc-expansion { + display: none; } #descexpansionbutton ~ div { @@ -509,6 +510,11 @@ hr { margin-top: 20px; } +label[for="descexpansionbutton"]:hover, +label[for="music-desc-expansion"]:hover { + cursor: pointer; +} + /* Bidi (bidirectional text) support */ h1, h2, @@ -517,14 +523,38 @@ h4, h5, p, #descriptionWrapper, -#description-box { - unicode-bidi: plaintext; - text-align: start; +#description-box, +#music-description-box { + unicode-bidi: plaintext; + text-align: start; } #descriptionWrapper { - max-width: 600px; - white-space: pre-wrap; + max-width: 600px; + white-space: pre-wrap; +} + +#music-description-box { + display: none; +} + +#music-desc-expansion:checked ~ #music-description-box { + display: block; +} + +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-down { + display: none; +} + +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-down { + display: inline; +} + +/* Select all the music items except the first one */ +.music-item + .music-item { + border-top: 1px solid #ffffff; } /* Center the "invidious" logo on the search page */ diff --git a/config/config.example.yml b/config/config.example.yml index 8794880d..8abe1b9e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -295,6 +295,17 @@ https_only: false ## #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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 34549df1..57864883 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -43,7 +43,7 @@ RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ FROM alpine:3.16 -RUN apk add --no-cache librsvg ttf-opensans +RUN apk add --no-cache librsvg ttf-opensans tini WORKDIR /invidious RUN addgroup -g 1000 -S invidious && \ adduser -u 1000 -S invidious -G invidious @@ -58,4 +58,5 @@ RUN chmod o+rX -R ./assets ./config ./locales EXPOSE 3000 USER invidious +ENTRYPOINT ["/sbin/tini", "--"] CMD [ "/invidious/invidious" ] diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index ef3284b1..10135efb 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -42,7 +42,7 @@ RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ fi FROM alpine:3.16 -RUN apk add --no-cache librsvg ttf-opensans +RUN apk add --no-cache librsvg ttf-opensans tini WORKDIR /invidious RUN addgroup -g 1000 -S invidious && \ adduser -u 1000 -S invidious -G invidious @@ -57,4 +57,5 @@ RUN chmod o+rX -R ./assets ./config ./locales EXPOSE 3000 USER invidious +ENTRYPOINT ["/sbin/tini", "--"] CMD [ "/invidious/invidious" ] diff --git a/kubernetes/Chart.lock b/kubernetes/Chart.lock index 37fcdbbd..cc76e920 100644 --- a/kubernetes/Chart.lock +++ b/kubernetes/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: postgresql repository: https://charts.bitnami.com/bitnami/ - version: 11.1.3 -digest: sha256:79061645472b6fb342d45e8e5b3aacd018ef5067193e46a060bccdc99fe7f6e1 -generated: "2022-03-02T05:57:20.081432389+13:00" + version: 12.1.9 +digest: sha256:71ff342a6c0a98bece3d7fe199983afb2113f8db65a3e3819de875af2c45add7 +generated: "2023-01-20T20:42:32.757707004Z" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml index ca44f4b7..4e4295ba 100644 --- a/kubernetes/Chart.yaml +++ b/kubernetes/Chart.yaml @@ -17,6 +17,6 @@ maintainers: email: mail@leonklingele.de dependencies: - name: postgresql - version: ~11.1.3 + version: ~12.1.6 repository: "https://charts.bitnami.com/bitnami/" engine: gotpl diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml index 7f371f72..5000c2b6 100644 --- a/kubernetes/values.yaml +++ b/kubernetes/values.yaml @@ -34,6 +34,8 @@ securityContext: # See https://github.com/bitnami/charts/tree/master/bitnami/postgresql postgresql: + image: + tag: 13 auth: username: kemal password: kemal diff --git a/locales/ar.json b/locales/ar.json index fbe88b03..55dea5f3 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -1,11 +1,11 @@ { "LIVE": "مُباشِر", - "Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`", + "Shared `x` ago": "تمَّ الرفع مُنذ `x`", "Unsubscribe": "إلغاء الاشتراك", - "Subscribe": "الإشتراك", - "View channel on YouTube": "زيارة القناة على موقع يوتيوب", - "View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب", - "newest": "الأجدد", + "Subscribe": "الاشتراك", + "View channel on YouTube": "زيارة القناة على يوتيوب", + "View playlist on YouTube": "عرض قائمة التشغيل على يوتيوب", + "newest": "الأحدث", "oldest": "الأقدم", "popular": "الأكثر شعبية", "last": "الأخيرة", @@ -96,8 +96,8 @@ "`x` is live": "`x` في بث مباشر", "preferences_category_data": "إعدادات التفضيلات", "Clear watch history": "حذف سجل المشاهدة", - "Import/export data": "إضافة\\استخراج البيانات", - "Change password": "غير كلمة السر", + "Import/export data": "إستيراد و تصدير البيانات", + "Change password": "تغير كلمة السر", "Manage subscriptions": "إدارة الاشتراكات", "Manage tokens": "إدارة الرموز", "Watch history": "سجل المشاهدة", @@ -137,7 +137,7 @@ "Title": "العنوان", "Playlist privacy": "إعدادات الخصوصية", "Editing playlist `x`": "تعديل قائمة التشغيل `x`", - "Show more": "إظهار المزيد", + "Show more": "عرض المزيد", "Show less": "عرض اقل", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Switch Invidious Instance": "تبديل المثيل Invidious", @@ -147,20 +147,20 @@ "License: ": "التراخيص: ", "Family friendly? ": "محتوى عائلي؟ ", "Wilson score: ": "درجة ويلسون: ", - "Engagement: ": "نسبة المشاركة: ", + "Engagement: ": "نسبة التفاعل: ", "Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ", "Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ", - "Shared `x`": "شارك منذ `x`", + "Shared `x`": "تمت المشاركة في `x`", "Premieres in `x`": "يعرض فى `x`", "Premieres `x`": "يعرض `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "أهلًا! يبدو أن جافاسكريبت معطلٌ لديك. اضغط هنا لعرض التعليقات، وَضَع في اعتبارك أنها ستأخذ وقتًا أطول للتحميل.", "View YouTube comments": "عرض تعليقات اليوتيوب", - "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", + "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات", "": "عرض `x` تعليقات" }, - "View Reddit comments": "عرض تعليقات ريدإت Reddit", + "View Reddit comments": "عرض تعليقات ريديت", "Hide replies": "إخفاء الردود", "Show replies": "عرض الردود", "Incorrect password": "كلمة السر غير صحيحة", @@ -182,20 +182,20 @@ "channel:`x`": "قناة:`x`", "Deleted or invalid channel": "قناة ممسوحة او غير صالحة", "This channel does not exist.": "هذه القناة غير موجودة.", - "Could not get channel info.": "لم يستطع الحصول على معلومات القناة.", - "Could not fetch comments": "لم يتمكن من إحضار التعليقات", + "Could not get channel info.": "لم يتمكن الحصول على معلومات القناة.", + "Could not fetch comments": "لا يتمكن إحضار التعليقات", "`x` ago": "`x` منذ", - "Load more": "عرض المزيد", + "Load more": "تحميل المزيد", "Could not create mix.": "تعذر إنشاء مزيج.", "Empty playlist": "قائمة التشغيل فارغة", "Not a playlist.": "قائمة التشغيل غير صالحة.", "Playlist does not exist.": "قائمة التشغيل غير موجودة.", - "Could not pull trending pages.": "لم يستطع عرض الصفحات الراجئة.", - "Hidden field \"challenge\" is a required field": "مكان مخفي \"تحدي\" مكان مطلوب", - "Hidden field \"token\" is a required field": "مكان مخفي \"رمز\" مكان مطلوب", - "Erroneous challenge": "تحدي غير صالح", + "Could not pull trending pages.": "لا يتمكن عرض الصفحات الراجئة.", + "Hidden field \"challenge\" is a required field": "الحقل المخفي \"تحدي\" حقل مطلوب", + "Hidden field \"token\" is a required field": "الحقل المخفي \"رمز\" حقل مطلوب", + "Erroneous challenge": "تحدي خاطئ", "Erroneous token": "رمز مميز خاطئ", - "No such user": "مستخدم غير صالح", + "No such user": "مستخدم غير موجود", "Token is expired, please try again": "الرمز منتهى الصلاحية، الرجاء المحاولة مرة اخرى", "English": "إنجليزي", "English (auto-generated)": "إنجليزي (تم إنشائه تلقائيًا)", @@ -325,15 +325,15 @@ "`x` marked it with a ❤": "`x` أعجب بهذا", "Audio mode": "الوضع الصوتي", "Video mode": "وضع الفيديو", - "Videos": "الفيديوهات", + "channel_tab_videos_label": "الفيديوهات", "Playlists": "قوائم التشغيل", - "Community": "المجتمع", - "search_filters_sort_option_relevance": "ملاؤم", + "channel_tab_community_label": "المجتمع", + "search_filters_sort_option_relevance": "ملائمة", "search_filters_sort_option_rating": "تقييم", "search_filters_sort_option_date": "التاريخ", "search_filters_sort_option_views": "مشاهدات", "search_filters_type_label": "نوع المحتوى", - "search_filters_duration_label": "المدة الزمنية", + "search_filters_duration_label": "المدة", "search_filters_features_label": "الميزات", "search_filters_sort_label": "فرز", "search_filters_date_option_hour": "آخر ساعة", @@ -351,8 +351,8 @@ "search_filters_features_option_c_commons": "المشاع الإبداعي", "search_filters_features_option_three_d": "ثلاثي الأبعاد", "search_filters_features_option_live": "مباشر", - "search_filters_features_option_four_k": "4k", - "search_filters_features_option_location": "الأماكن", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "المكان", "search_filters_features_option_hdr": "وضع التباين العالي", "Current version: ": "الإصدار الحالي: ", "next_steps_error_message": "بعد ذلك يجب أن تحاول: ", @@ -360,10 +360,10 @@ "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب", "search_filters_duration_option_short": "قصير (< 4 دقائق)", "search_filters_duration_option_long": "طويل (> 20 دقيقة)", - "footer_source_code": "شفرة المصدر", - "footer_original_source_code": "كود المصدر الأصلي", - "footer_modfied_source_code": "شفرة المصدر المعدلة", - "adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة", + "footer_source_code": "الكود المصدر", + "footer_original_source_code": "الكود المصدر الأصلي", + "footer_modfied_source_code": "الكود المصدر المعدل", + "adminprefs_modified_source_code_url_label": "URL إلى مستودع الكود المصدر المعدل", "footer_documentation": "التوثيق", "footer_donate_page": "تبرّع", "preferences_region_label": "بلد المحتوى: ", @@ -398,31 +398,31 @@ "invidious": "الخيالي", "preferences_save_player_pos_label": "حفظ موضع التشغيل: ", "crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!", - "generic_videos_count_0": "لا فيديوهات", + "generic_videos_count_0": "لا يوجد فيديوهات", "generic_videos_count_1": "فيديو واحد", "generic_videos_count_2": "فيديوهين", "generic_videos_count_3": "{{count}} فيديوهات", "generic_videos_count_4": "{{count}} فيديو", "generic_videos_count_5": "{{count}} فيديو", - "generic_subscribers_count_0": "لا مشتركين", + "generic_subscribers_count_0": "لا يوجد مشترك", "generic_subscribers_count_1": "مشترك واحد", "generic_subscribers_count_2": "مشتركان", "generic_subscribers_count_3": "{{count}} مشتركين", "generic_subscribers_count_4": "{{count}} مشترك", "generic_subscribers_count_5": "{{count}} مشترك", - "generic_views_count_0": "لا مشاهدات", + "generic_views_count_0": "لا يوجد مشاهدة", "generic_views_count_1": "مشاهدة واحدة", "generic_views_count_2": "مشاهدتان", "generic_views_count_3": "{{count}} مشاهدات", "generic_views_count_4": "{{count}} مشاهدة", "generic_views_count_5": "{{count}} مشاهدة", - "generic_subscriptions_count_0": "لا اشتراكات", + "generic_subscriptions_count_0": "لا يوجد اشتراك", "generic_subscriptions_count_1": "اشتراك واحد", "generic_subscriptions_count_2": "اشتراكان", "generic_subscriptions_count_3": "{{count}} اشتراكات", "generic_subscriptions_count_4": "{{count}} اشتراك", "generic_subscriptions_count_5": "{{count}} اشتراك", - "generic_playlists_count_0": "لا قوائم تشغيل", + "generic_playlists_count_0": "لا يوجد قوائم تشغيل", "generic_playlists_count_1": "قائمة تشغيل واحدة", "generic_playlists_count_2": "قائمتا تشغيل", "generic_playlists_count_3": "{{count}} قوائم تشغيل", @@ -463,10 +463,10 @@ "search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.", "search_filters_date_label": "تاريخ الرفع", "generic_count_weeks_0": "{{count}} أسبوع", - "generic_count_weeks_1": "{{count}} أسبوع", - "generic_count_weeks_2": "{{count}} أسبوع", - "generic_count_weeks_3": "{{count}} أسبوع", - "generic_count_weeks_4": "{{count}} أسابيع", + "generic_count_weeks_1": "أسبوع واحد", + "generic_count_weeks_2": "أسبوعين", + "generic_count_weeks_3": "{{count}} أسابيع", + "generic_count_weeks_4": "{{count}} أسبوع", "generic_count_weeks_5": "{{count}} أسبوع", "Popular enabled: ": "تم تمكين الشعبية: ", "search_filters_duration_option_medium": "متوسط (4-20 دقيقة)", @@ -474,16 +474,16 @@ "search_filters_type_option_all": "أي نوع", "search_filters_features_option_vr180": "VR180", "generic_count_minutes_0": "{{count}} دقيقة", - "generic_count_minutes_1": "{{count}} دقيقة", - "generic_count_minutes_2": "{{count}} دقيقة", - "generic_count_minutes_3": "{{count}} دقيقة", - "generic_count_minutes_4": "{{count}} دقائق", + "generic_count_minutes_1": "دقيقة واحدة", + "generic_count_minutes_2": "دقيقتين", + "generic_count_minutes_3": "{{count}} دقائق", + "generic_count_minutes_4": "{{count}} دقيقة", "generic_count_minutes_5": "{{count}} دقيقة", "generic_count_hours_0": "{{count}} ساعة", - "generic_count_hours_1": "{{count}} ساعة", - "generic_count_hours_2": "{{count}} ساعة", - "generic_count_hours_3": "{{count}} ساعة", - "generic_count_hours_4": "{{count}} ساعات", + "generic_count_hours_1": "ساعة واحدة", + "generic_count_hours_2": "ساعتين", + "generic_count_hours_3": "{{count}} ساعات", + "generic_count_hours_4": "{{count}} ساعة", "generic_count_hours_5": "{{count}} ساعة", "comments_view_x_replies_0": "عرض رد {{count}}", "comments_view_x_replies_1": "عرض رد {{count}}", @@ -493,10 +493,10 @@ "comments_view_x_replies_5": "عرض رد {{count}}", "search_message_use_another_instance": " يمكنك أيضًا البحث عن في مثيل آخر .", "comments_points_count_0": "{{count}} نقطة", - "comments_points_count_1": "{{count}} نقطة", - "comments_points_count_2": "{{count}} نقطة", - "comments_points_count_3": "{{count}} نقطة", - "comments_points_count_4": "{{count}} نقاط", + "comments_points_count_1": "نقطة واحدة", + "comments_points_count_2": "نقطتان", + "comments_points_count_3": "{{count}} نقط", + "comments_points_count_4": "{{count}} نقطة", "comments_points_count_5": "{{count}} نقطة", "generic_count_years_0": "{{count}} السنة", "generic_count_years_1": "{{count}} السنة", @@ -512,17 +512,17 @@ "tokens_count_5": "الرمز المميز {{count}}", "search_filters_apply_button": "تطبيق الفلاتر المحددة", "search_filters_duration_option_none": "أي مدة", - "subscriptions_unseen_notifs_count_0": "{{count}} إشعار غير مرئي", - "subscriptions_unseen_notifs_count_1": "{{count}} إشعار غير مرئي", - "subscriptions_unseen_notifs_count_2": "{{count}} إشعار غير مرئي", - "subscriptions_unseen_notifs_count_3": "{{count}} إشعار غير مرئي", - "subscriptions_unseen_notifs_count_4": "{{count}} إشعارات غير مرئية", - "subscriptions_unseen_notifs_count_5": "{{count}} إشعار غير مرئي", + "subscriptions_unseen_notifs_count_0": "{{count}} إشعار جديد", + "subscriptions_unseen_notifs_count_1": "إشعار واحد جديد", + "subscriptions_unseen_notifs_count_2": "إشعارين جديدين", + "subscriptions_unseen_notifs_count_3": "{{count}} إشعارات جديدة", + "subscriptions_unseen_notifs_count_4": "{{count}} إشعارا جديد", + "subscriptions_unseen_notifs_count_5": "{{count}} إشعار جديد", "generic_count_days_0": "{{count}} يوم", - "generic_count_days_1": "{{count}} يوم", - "generic_count_days_2": "{{count}} يوم", - "generic_count_days_3": "{{count}} يوم", - "generic_count_days_4": "{{count}} أيام", + "generic_count_days_1": "يوم واحد", + "generic_count_days_2": "يومين", + "generic_count_days_3": "{{count}} أيام", + "generic_count_days_4": "{{count}} يوم", "generic_count_days_5": "{{count}} يوم", "generic_count_months_0": "{{count}} شهر", "generic_count_months_1": "{{count}} شهر", @@ -531,10 +531,14 @@ "generic_count_months_4": "{{count}} شهور", "generic_count_months_5": "{{count}} شهر", "generic_count_seconds_0": "{{count}} ثانية", - "generic_count_seconds_1": "{{count}} ثانية", - "generic_count_seconds_2": "{{count}} ثانية", - "generic_count_seconds_3": "{{count}} ثانية", - "generic_count_seconds_4": "{{count}} ثوانٍ", + "generic_count_seconds_1": "ثانية واحدة", + "generic_count_seconds_2": "ثانيتين", + "generic_count_seconds_3": "{{count}} ثوانٍ", + "generic_count_seconds_4": "{{count}} ثانية", "generic_count_seconds_5": "{{count}} ثانية", - "error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. " + "error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. ", + "channel_tab_shorts_label": "الفيديوهات القصيرة", + "channel_tab_streams_label": "البث المباشر", + "channel_tab_playlists_label": "قوائم التشغيل", + "channel_tab_channels_label": "القنوات" } diff --git a/locales/ca.json b/locales/ca.json index 741414d2..2ba6ae39 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -51,7 +51,7 @@ "Movies": "Películes", "Download": "Descarrega", "Download as: ": "Descarrega com: ", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "search_filters_type_label": "Tipus", "search_filters_duration_label": "Duració", "search_filters_sort_label": "Ordena per", diff --git a/locales/cs.json b/locales/cs.json index 7538365a..7502de0b 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -63,7 +63,7 @@ "reddit": "Reddit", "preferences_captions_label": "Výchozí titulky: ", "Fallback captions: ": "Záložní titulky: ", - "preferences_related_videos_label": "Zobrazit podobné videa: ", + "preferences_related_videos_label": "Zobrazit podobná videa: ", "preferences_annotations_label": "Zobrazovat poznámky ve výchozím nastavení: ", "preferences_extend_desc_label": "Rozšířit automaticky popis u videa: ", "preferences_category_visual": "Nastavení vzhledu", @@ -260,8 +260,8 @@ "`x` marked it with a ❤": "`x` to označil(a) se ❤", "Audio mode": "Audiový režim", "Video mode": "Videový režim", - "Videos": "Videa", - "Community": "Komunita", + "channel_tab_videos_label": "Videa", + "channel_tab_community_label": "Komunita", "search_filters_sort_option_rating": "Hodnocení", "search_filters_sort_option_date": "Datum nahrání", "search_filters_sort_option_views": "Počet zhlédnutí", @@ -488,5 +488,9 @@ "search_filters_sort_option_relevance": "Relevantnost", "search_filters_apply_button": "Použít vybrané filtry", "Popular enabled: ": "Populární povoleno: ", - "error_video_not_in_playlist": "Požadované video v tomto playlistu neexistuje. Klikněte sem pro navštívení domovské stránky playlistu." + "error_video_not_in_playlist": "Požadované video v tomto playlistu neexistuje. Klikněte sem pro navštívení domovské stránky playlistu.", + "channel_tab_shorts_label": "Shorts", + "channel_tab_playlists_label": "Playlisty", + "channel_tab_channels_label": "Kanály", + "channel_tab_streams_label": "Živé přenosy" } diff --git a/locales/da.json b/locales/da.json index 4816c2c9..2bee6c80 100644 --- a/locales/da.json +++ b/locales/da.json @@ -187,7 +187,7 @@ "Esperanto": "Esperanto", "Czech": "Tjekkisk", "Danish": "Dansk", - "Community": "Samfund", + "channel_tab_community_label": "Samfund", "Afrikaans": "Afrikansk", "Portuguese": "Portugisisk", "Ukrainian": "Ukrainsk", @@ -267,7 +267,7 @@ "search_filters_sort_option_rating": "Bedømmelse", "Yoruba": "Yoruba", "Erroneous token": "Fejlagtig token", - "Videos": "Videoer", + "channel_tab_videos_label": "Videoer", "search_filters_type_option_show": "Vis", "Luxembourgish": "Luxemboursk", "Vietnamese": "Vietnamesisk", diff --git a/locales/de.json b/locales/de.json index 3ac32a31..55c40905 100644 --- a/locales/de.json +++ b/locales/de.json @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "`x` markierte es mit einem ❤", "Audio mode": "Audiomodus", "Video mode": "Videomodus", - "Videos": "Videos", + "channel_tab_videos_label": "Videos", "Playlists": "Wiedergabelisten", - "Community": "Gemeinschaft", + "channel_tab_community_label": "Gemeinschaft", "search_filters_sort_option_relevance": "Relevanz", "search_filters_sort_option_rating": "Bewertung", "search_filters_sort_option_date": "Datum", @@ -471,5 +471,6 @@ "search_filters_apply_button": "Ausgewählte Filter anwenden", "search_filters_duration_option_none": "Beliebige Länge", "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. Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen." } diff --git a/locales/el.json b/locales/el.json index d91d64fc..3448a4dc 100644 --- a/locales/el.json +++ b/locales/el.json @@ -315,9 +315,9 @@ "`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤", "Audio mode": "Λειτουργία ήχου", "Video mode": "Λειτουργία βίντεο", - "Videos": "Βίντεο", + "channel_tab_videos_label": "Βίντεο", "Playlists": "Λίστες Αναπαραγωγής", - "Community": "Κοινότητα", + "channel_tab_community_label": "Κοινότητα", "Current version: ": "Τρέχουσα έκδοση: ", "generic_playlists_count": "{{count}} λίστα αναπαραγωγής", "generic_playlists_count_plural": "{{count}} λίστες αναπαραγωγής", diff --git a/locales/en-US.json b/locales/en-US.json index 5554b928..a5c16fd7 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -188,6 +188,9 @@ "Engagement: ": "Engagement: ", "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", + "Music in this video": "Music in this video", + "Artist: ": "Artist: ", + "Album: ": "Album: ", "Shared `x`": "Shared `x`", "Premieres in `x`": "Premieres in `x`", "Premieres `x`": "Premieres `x`", @@ -404,9 +407,7 @@ "`x` marked it with a ❤": "`x` marked it with a ❤", "Audio mode": "Audio mode", "Video mode": "Video mode", - "Videos": "Videos", "Playlists": "Playlists", - "Community": "Community", "search_filters_title": "Filters", "search_filters_date_label": "Upload date", "search_filters_date_option_none": "Any date", @@ -472,5 +473,11 @@ "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", "crash_page_search_issue": "searched for existing issues on GitHub", "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (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. Click here for the playlist home page." + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page.", + "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" } diff --git a/locales/eo.json b/locales/eo.json index fb5bb69c..56e718f2 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -5,8 +5,8 @@ "Subscribe": "Abonu", "View channel on YouTube": "Vidu kanalon en JuTubo", "View playlist on YouTube": "Vidu ludliston en JuTubo", - "newest": "pli novaj", - "oldest": "pli malnovaj", + "newest": "plej novaj", + "oldest": "plej malnovaj", "popular": "popularaj", "last": "lasta", "Next page": "Sekva paĝo", @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "`x` markis ĝin per ❤", "Audio mode": "Aŭda reĝimo", "Video mode": "Videa reĝimo", - "Videos": "Filmetoj", + "channel_tab_videos_label": "Videoj", "Playlists": "Ludlistoj", - "Community": "Komunumo", + "channel_tab_community_label": "Komunumo", "search_filters_sort_option_relevance": "rilateco", "search_filters_sort_option_rating": "takso", "search_filters_sort_option_date": "dato", @@ -472,5 +472,9 @@ "generic_subscribers_count_plural": "{{count}} abonantoj", "generic_count_months": "{{count}} monato", "generic_count_months_plural": "{{count}} monatoj", - "preferences_save_player_pos_label": "Konservi ludadan pozicion: " + "preferences_save_player_pos_label": "Konservi ludadan pozicion: ", + "channel_tab_streams_label": "Tujelsendoj", + "channel_tab_playlists_label": "Ludlistoj", + "channel_tab_channels_label": "Kanaloj", + "channel_tab_shorts_label": "Mallongaj" } diff --git a/locales/es.json b/locales/es.json index 8603e9fe..59d6b145 100644 --- a/locales/es.json +++ b/locales/es.json @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "Audio mode": "Modo de audio", "Video mode": "Modo de vídeo", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "Playlists": "Listas de reproducción", - "Community": "Comunidad", + "channel_tab_community_label": "Comunidad", "search_filters_sort_option_relevance": "relevancia", "search_filters_sort_option_rating": "valoración", "search_filters_sort_option_date": "fecha", @@ -472,5 +472,9 @@ "search_message_use_another_instance": " También puede buscar en otra instancia.", "search_filters_duration_option_medium": "Medio (4 - 20 minutes)", "Popular enabled: ": "¿Habilitar la sección popular? ", - "error_video_not_in_playlist": "El vídeo solicitado no existe en esta lista de reproducción. Haga clic aquí para acceder a la página de inicio de la lista de reproducción." + "error_video_not_in_playlist": "El vídeo solicitado no existe en esta lista de reproducción. Haga clic aquí para acceder a la página de inicio de la lista de reproducción.", + "channel_tab_streams_label": "Directos", + "channel_tab_channels_label": "Canales", + "channel_tab_shorts_label": "Cortos", + "channel_tab_playlists_label": "Listas de reproducción" } diff --git a/locales/et.json b/locales/et.json index 7beb1749..74338aba 100644 --- a/locales/et.json +++ b/locales/et.json @@ -296,8 +296,8 @@ "Corsican": "Korsika", "Javanese": "Jaava", "Lithuanian": "Leedu", - "Videos": "Videod", - "Community": "Kogukond", + "channel_tab_videos_label": "Videod", + "channel_tab_community_label": "Kogukond", "CAPTCHA is a required field": "CAPTCHA on kohustuslik väli", "comments_points_count": "{{count}} punkt", "comments_points_count_plural": "{{count}} punkti", diff --git a/locales/fa.json b/locales/fa.json index 5ea976f5..f2ca2745 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` نشان گذاری شده با یک ❤", "Audio mode": "حالت صدا", "Video mode": "حالت ویدیو", - "Videos": "ویدیو ها", + "channel_tab_videos_label": "ویدیو ها", "Playlists": "سیاهه‌های پخش", - "Community": "اجتماع", + "channel_tab_community_label": "اجتماع", "search_filters_sort_option_relevance": "مرتبط بودن", "search_filters_sort_option_rating": "امتیاز", "search_filters_sort_option_date": "تاریخ بارگذاری", @@ -411,5 +411,18 @@ "search_filters_duration_option_long": "بلند (> 20 دقیقه)", "adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده", "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)": "آلمانی (تولید خودکار)" } diff --git a/locales/fi.json b/locales/fi.json index cbb18825..366a2739 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -324,9 +324,9 @@ "`x` marked it with a ❤": "`x` merkkasi ❤:llä", "Audio mode": "Äänitila", "Video mode": "Videotila", - "Videos": "Videot", + "channel_tab_videos_label": "Videot", "Playlists": "Soittolistat", - "Community": "Yhteisö", + "channel_tab_community_label": "Yhteisö", "search_filters_sort_option_relevance": "Osuvuus", "search_filters_sort_option_rating": "Arvostelu", "search_filters_sort_option_date": "Latauspäivämäärä", @@ -471,5 +471,6 @@ "search_message_use_another_instance": " Voit myös hakea toisella instanssilla.", "search_filters_date_option_none": "Milloin tahansa", "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. Klikkaa tähän päästäksesi soittolistan etusivulle." } diff --git a/locales/fr.json b/locales/fr.json index 2f384eb1..9d3e117f 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -358,9 +358,9 @@ "`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "Audio mode": "Mode audio", "Video mode": "Mode vidéo", - "Videos": "Vidéos", + "channel_tab_videos_label": "Vidéos", "Playlists": "Listes de lecture", - "Community": "Communauté", + "channel_tab_community_label": "Communauté", "search_filters_sort_option_relevance": "Pertinence", "search_filters_sort_option_rating": "Notation", "search_filters_sort_option_date": "Date d'ajout", @@ -472,5 +472,9 @@ "search_filters_date_label": "Date d'ajout", "search_filters_features_option_vr180": "VR180", "search_filters_duration_option_none": "Toutes les durées", - "error_video_not_in_playlist": "La vidéo demandée n'existe pas dans cette liste de lecture. Cliquez ici pour retourner à la liste de lecture." + "error_video_not_in_playlist": "La vidéo demandée n'existe pas dans cette liste de lecture. Cliquez ici pour retourner à la liste de lecture.", + "channel_tab_shorts_label": "Clips", + "channel_tab_streams_label": "En direct", + "channel_tab_playlists_label": "Listes de lecture", + "channel_tab_channels_label": "Chaînes" } diff --git a/locales/he.json b/locales/he.json index 384b2657..ab42313b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -271,9 +271,9 @@ "`x` marked it with a ❤": "סומנה ב־❤ על ידי `x`", "Audio mode": "Audio mode", "Video mode": "Video mode", - "Videos": "סרטונים", + "channel_tab_videos_label": "סרטונים", "Playlists": "פלייליסטים", - "Community": "קהילה", + "channel_tab_community_label": "קהילה", "search_filters_sort_option_relevance": "רלוונטיות", "search_filters_sort_option_rating": "דירוג", "search_filters_sort_option_date": "תאריך העלאה", diff --git a/locales/hi.json b/locales/hi.json index 32ae7823..e576080f 100644 --- a/locales/hi.json +++ b/locales/hi.json @@ -401,12 +401,12 @@ "(edited)": "(संपादित)", "YouTube comment permalink": "YouTube पर टिप्पणी की स्थायी कड़ी", "permalink": "स्थायी कड़ी", - "Videos": "वीडियो", + "channel_tab_videos_label": "वीडियो", "`x` marked it with a ❤": "`x` ने इसे एक ❤ से चिह्नित किया", "Audio mode": "ऑडियो मोड", "Playlists": "प्लेलिस्ट्स", "Video mode": "वीडियो मोड", - "Community": "समुदाय", + "channel_tab_community_label": "समुदाय", "search_filters_title": "फ़िल्टर", "search_filters_date_label": "अपलोड करने का समय", "search_filters_date_option_none": "कोई भी समय", diff --git a/locales/hr.json b/locales/hr.json index e42cc4f5..7914ab16 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -7,8 +7,8 @@ "View playlist on YouTube": "Prikaži zbirku na YouTubeu", "newest": "najnovije", "oldest": "najstarije", - "popular": "popularni", - "last": "zadnji", + "popular": "popularne", + "last": "zadnje", "Next page": "Sljedeća stranica", "Previous page": "Prethodna stranica", "Clear watch history?": "Izbrisati povijest gledanja?", @@ -43,9 +43,9 @@ "Time (h:mm:ss):": "Vrijeme (h:mm:ss):", "Text CAPTCHA": "Tekstualni CAPTCHA", "Image CAPTCHA": "Slikovni CAPTCHA", - "Sign In": "Prijava", + "Sign In": "Prijavi se", "Register": "Registriraj se", - "E-mail": "E-mail", + "E-mail": "E-mail adresa", "Google verification code": "Googleov potvrdni kod", "Preferences": "Postavke", "preferences_category_player": "Postavke playera", @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "Označeno sa ❤ od `x`", "Audio mode": "Audio modus", "Video mode": "Videomodus", - "Videos": "Videa", + "channel_tab_videos_label": "Videa", "Playlists": "Zbirke", - "Community": "Zajednica", + "channel_tab_community_label": "Zajednica", "search_filters_sort_option_relevance": "Značaj", "search_filters_sort_option_rating": "Ocjena", "search_filters_sort_option_date": "Datum prijenosa", @@ -488,5 +488,9 @@ "search_filters_apply_button": "Primijeni odabrane filtre", "search_filters_type_option_all": "Bilo koja vrsta", "Popular enabled: ": "Popularni aktivirani: ", - "error_video_not_in_playlist": "Traženi video ne postoji u ovoj zbirci. Pritisni ovdje za početnu stranicu zbirke." + "error_video_not_in_playlist": "Traženi video ne postoji u ovoj zbirci. Pritisni ovdje za početnu stranicu zbirke.", + "channel_tab_streams_label": "Prijenosi uživo", + "channel_tab_playlists_label": "Zbirke", + "channel_tab_channels_label": "Kanali", + "channel_tab_shorts_label": "Kratka videa" } diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 50e505dc..f93930e0 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -348,9 +348,9 @@ "`x` marked it with a ❤": "`x` ❤ jelet adott a hozzászóláshoz", "Audio mode": "Csak hanggal", "Video mode": "Hanggal és képpel", - "Videos": "Videói", + "channel_tab_videos_label": "Videói", "Playlists": "Lejátszási listái", - "Community": "Közösség", + "channel_tab_community_label": "Közösség", "Current version: ": "Jelenlegi verzió: ", "preferences_quality_option_medium": "Közepes", "preferences_quality_dash_option_auto": "Automatikus", @@ -470,5 +470,7 @@ "search_filters_duration_option_none": "Mindegy", "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_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. Kattintson ide a lejátszási listához jutáshoz." } diff --git a/locales/id.json b/locales/id.json index a30f0ad4..51d6d55c 100644 --- a/locales/id.json +++ b/locales/id.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` telah ditandai dengan ❤", "Audio mode": "Mode audio", "Video mode": "Mode video", - "Videos": "Video", + "channel_tab_videos_label": "Video", "Playlists": "Daftar putar", - "Community": "Komunitas", + "channel_tab_community_label": "Komunitas", "search_filters_sort_option_relevance": "Relevansi", "search_filters_sort_option_rating": "Penilaian", "search_filters_sort_option_date": "Tanggal Unggah", diff --git a/locales/is.json b/locales/is.json index 99bd6574..3282eb50 100644 --- a/locales/is.json +++ b/locales/is.json @@ -315,9 +315,9 @@ "`x` marked it with a ❤": "`x` merkti það með ❤", "Audio mode": "Hljóð ham", "Video mode": "Myndband ham", - "Videos": "Myndbönd", + "channel_tab_videos_label": "Myndbönd", "Playlists": "Spilunarlistar", - "Community": "Samfélag", + "channel_tab_community_label": "Samfélag", "Current version: ": "Núverandi útgáfa: ", "preferences_watch_history_label": "Virkja áhorfssögu: " } diff --git a/locales/it.json b/locales/it.json index 63a8e8d4..f47b032e 100644 --- a/locales/it.json +++ b/locales/it.json @@ -290,7 +290,7 @@ "Southern Sotho": "Sotho del Sud", "Spanish": "Spagnolo", "Spanish (Latin America)": "Spagnolo (America latina)", - "Sundanese": "Sudanese", + "Sundanese": "Sundanese", "Swahili": "Swahili", "Swedish": "Svedese", "Tajik": "Tagico", @@ -344,9 +344,8 @@ "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "Audio mode": "Modalità audio", "Video mode": "Modalità video", - "Videos": "Video", + "channel_tab_videos_label": "Video", "Playlists": "Playlist", - "Community": "Comunità", "search_filters_sort_option_relevance": "Pertinenza", "search_filters_sort_option_rating": "Valutazione", "search_filters_sort_option_date": "Data di caricamento", @@ -472,5 +471,10 @@ "search_filters_features_option_vr180": "VR180", "search_filters_apply_button": "Applica filtri selezionati", "crash_page_refresh": "provato a ricaricare la pagina", - "error_video_not_in_playlist": "Il video richiesto non esiste in questa playlist. Fai clic qui per la pagina iniziale della playlist." + "error_video_not_in_playlist": "Il video richiesto non esiste in questa playlist. Fai clic qui per la pagina iniziale della playlist.", + "channel_tab_shorts_label": "Short", + "channel_tab_playlists_label": "Playlist", + "channel_tab_channels_label": "Canali", + "channel_tab_streams_label": "Livestream", + "channel_tab_community_label": "Comunità" } diff --git a/locales/ja.json b/locales/ja.json index 7918fe95..a392abfe 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` が❤を込めてマークしました", "Audio mode": "オーディオモード", "Video mode": "ビデオモード", - "Videos": "動画", + "channel_tab_videos_label": "動画", "Playlists": "プレイリスト", - "Community": "コミュニティ", + "channel_tab_community_label": "コミュニティ", "search_filters_sort_option_relevance": "関連", "search_filters_sort_option_rating": "評価", "search_filters_sort_option_date": "時刻", @@ -403,7 +403,7 @@ "none": "なし", "download_subtitles": "字幕 - `x` (.vtt)", "search_filters_features_option_purchased": "購入済み", - "preferences_quality_option_dash": "DASH (適切な品質)", + "preferences_quality_option_dash": "DASH (適応品質)", "preferences_quality_dash_option_worst": "最悪", "preferences_quality_dash_option_best": "最高", "videoinfo_started_streaming_x_ago": "`x`分前に配信を開始", @@ -438,5 +438,20 @@ "search_message_no_results": "一致する検索結果はありませんでした", "English (United States)": "英語 (アメリカ)", "search_filters_date_label": "アップロード日", - "search_filters_features_option_vr180": "VR180" + "search_filters_features_option_vr180": "VR180", + "crash_page_switch_instance": "別のインスタンスを使用しようとしました", + "crash_page_read_the_faq": "よくある質問 (FAQ) を読む", + "Popular enabled: ": "人気動画を有効化 ", + "search_message_use_another_instance": " 別のインスタンスで検索することもできます。", + "search_filters_apply_button": "選択したフィルターを適用", + "user_saved_playlists": "`x` 個の保存済みプレイリスト", + "crash_page_you_found_a_bug": "Invidious でバグを見つけたようです。", + "crash_page_refresh": "ページを更新しようとしました", + "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": "バグを報告する前に、次のことを確認してください。" } diff --git a/locales/ko.json b/locales/ko.json index 8d79c456..d4f3a711 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2,7 +2,7 @@ "preferences_sort_label": "동영상 정렬 기준: ", "preferences_max_results_label": "피드에 표시된 동영상 수: ", "Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ", - "preferences_annotations_subscribed_label": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ", + "preferences_annotations_subscribed_label": "구독한 채널에 기본으로 주석 표시: ", "preferences_category_subscription": "구독 설정", "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", "preferences_thin_mode_label": "단순 모드: ", @@ -11,7 +11,7 @@ "preferences_dark_mode_label": "테마: ", "Dark mode: ": "다크 모드: ", "preferences_player_style_label": "플레이어 스타일: ", - "preferences_category_visual": "시각 설정", + "preferences_category_visual": "환경 설정", "preferences_vr_mode_label": "VR 영상 활성화(WebGL 필요): ", "preferences_extend_desc_label": "자동으로 비디오 설명을 확장: ", "preferences_annotations_label": "기본으로 주석 표시: ", @@ -25,8 +25,8 @@ "preferences_quality_label": "선호하는 비디오 품질: ", "preferences_speed_label": "기본 속도: ", "preferences_local_label": "비디오를 프록시: ", - "preferences_listen_label": "라디오 모드 활성화: ", - "preferences_continue_autoplay_label": "다음 동영상 자동재생 ", + "preferences_listen_label": "라디오 모드: ", + "preferences_continue_autoplay_label": "다음 동영상 자동재생: ", "preferences_continue_label": "다음 동영상으로 이동: ", "preferences_autoplay_label": "자동재생: ", "preferences_video_loop_label": "항상 반복: ", @@ -37,8 +37,8 @@ "Register": "회원가입", "Sign In": "로그인", "preferences_category_misc": "기타 설정", - "Image CAPTCHA": "이미지 CAPTCHA", - "Text CAPTCHA": "텍스트 CAPTCHA", + "Image CAPTCHA": "이미지 캡차", + "Text CAPTCHA": "텍스트 캡차", "Time (h:mm:ss):": "시각 (h:mm:ss):", "Password": "비밀번호", "User ID": "사용자 ID", @@ -50,15 +50,15 @@ "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", "History": "역사", "Delete account?": "계정을 삭제 하시겠습니까?", - "Export data as JSON": "데이터를 JSON으로 내보내기", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "구독을 OPML로 내보내기 (NewPipe 및 FreeTube 용)", - "Export subscriptions as OPML": "구독을 OPML로 내보내기", + "Export data as JSON": "JSON으로 데이터 내보내기", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)", + "Export subscriptions as OPML": "OPML로 구독 내보내기", "Export": "내보내기", - "Import NewPipe data (.zip)": "NewPipe 데이터 가져오기 (.zip)", - "Import NewPipe subscriptions (.json)": "NewPipe 구독을 가져오기 (.json)", - "Import FreeTube subscriptions (.db)": "FreeTube 구독 가져오기 (.db)", + "Import NewPipe data (.zip)": "뉴파이프 데이터 가져오기 (.zip)", + "Import NewPipe subscriptions (.json)": "뉴파이프 구독 가져오기 (.json)", + "Import FreeTube subscriptions (.db)": "프리튜브 구독 가져오기 (.db)", "Import YouTube subscriptions": "유튜브 구독 가져오기", - "Import Invidious data": "인비디어스 JSON 데이터 가져오기", + "Import Invidious data": "인비디어스 데이터 가져오기 (.json)", "Import": "가져오기", "Import and Export Data": "데이터 가져오기 및 내보내기", "No": "아니요", @@ -150,9 +150,9 @@ "Subscription manager": "구독 관리자", "Save preferences": "설정 저장", "Report statistics: ": "통계 보고: ", - "Registration enabled: ": "등록 활성화: ", + "Registration enabled: ": "회원가입 활성화: ", "Login enabled: ": "로그인 활성화: ", - "CAPTCHA enabled: ": "CAPTCHA 활성화: ", + "CAPTCHA enabled: ": "캡차 활성화: ", "Top enabled: ": "Top 활성화: ", "preferences_show_nick_label": "상단에 닉네임 표시: ", "preferences_feed_menu_label": "피드 메뉴: ", @@ -187,8 +187,8 @@ "Polish": "폴란드어", "Persian": "페르시아어", "Pashto": "파슈토어", - "Nyanja": "체와어", - "Norwegian Bokmål": "보크몰", + "Nyanja": "냔자어", + "Norwegian Bokmål": "노르웨이 부크몰어", "Nepali": "네팔어", "Mongolian": "몽골어", "Marathi": "마라티어", @@ -284,10 +284,10 @@ "Password cannot be empty": "비밀번호는 비워둘 수 없습니다", "Please sign in using 'Log in with Google'": "'구글로 로그인'을 사용하여 로그인하세요", "Wrong username or password": "잘못된 사용자 이름 또는 비밀번호", - "Password is a required field": "비밀번호는 필수 필드입니다", - "User ID is a required field": "사용자 ID는 필수 필드입니다", - "CAPTCHA is a required field": "CAPTCHA는 필수 필드입니다", - "Erroneous CAPTCHA": "잘못된 CAPTCHA", + "Password is a required field": "비밀번호는 필수 입력란입니다", + "User ID is a required field": "사용자 ID는 필수 입력란입니다", + "CAPTCHA is a required field": "캡차는 필수 입력란입니다", + "Erroneous CAPTCHA": "잘못된 캡차", "Login failed. This may be because two-factor authentication is not turned on for your account.": "로그인 실패. 계정에 이중 인증이 설정되어 있지 않기 때문일 수 있습니다.", "Blacklisted regions: ": "차단된 지역: ", "Playlists": "재생목록", @@ -297,7 +297,7 @@ "Empty playlist": "재생목록 비어 있음", "Show annotations": "주석 보이기", "Hide annotations": "주석 숨기기", - "Switch Invidious Instance": "Invidious 인스턴스 변경", + "Switch Invidious Instance": "인비디어스 인스턴스 변경", "Spanish": "스페인어", "Southern Sotho": "소토어", "Somali": "소말리어", @@ -347,8 +347,8 @@ "search_filters_sort_option_date": "업로드 날짜", "search_filters_sort_option_rating": "평점", "search_filters_sort_option_relevance": "관련성", - "Community": "커뮤니티", - "Videos": "동영상", + "channel_tab_community_label": "커뮤니티", + "channel_tab_videos_label": "동영상", "Video mode": "비디오 모드", "Audio mode": "오디오 모드", "permalink": "퍼머링크", @@ -383,7 +383,7 @@ "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL", "search_filters_title": "필터", "preferences_quality_dash_option_4320p": "4320p", - "Popular enabled: ": "인기 급상승 활성화: ", + "Popular enabled: ": "인기 활성화: ", "Dutch (auto-generated)": "네덜란드어 (자동 생성됨)", "Chinese (Hong Kong)": "중국어 (홍콩)", "Chinese (Taiwan)": "중국어 (대만)", @@ -415,7 +415,7 @@ "Spanish (auto-generated)": "스페인어 (자동 생성됨)", "preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_worst": "최저", - "preferences_watch_history_label": "시청 기록 활성화: ", + "preferences_watch_history_label": "시청 기록 저장: ", "invidious": "인비디어스", "preferences_quality_option_small": "낮음", "preferences_quality_dash_option_auto": "자동", @@ -439,10 +439,10 @@ "footer_donate_page": "기부하기", "preferences_quality_option_dash": "DASH (다양한 화질)", "preferences_quality_dash_option_360p": "360p", - "preferences_save_player_pos_label": "이어서 보기 활성화: ", + "preferences_save_player_pos_label": "이어서 보기: ", "none": "없음", "videoinfo_started_streaming_x_ago": "`x` 전에 스트리밍을 시작했습니다", - "crash_page_you_found_a_bug": "Invidious에서 버그를 찾은 것 같습니다!", + "crash_page_you_found_a_bug": "인비디어스에서 버그를 찾은 것 같습니다!", "download_subtitles": "자막 - `x`(.vtt)", "user_saved_playlists": "`x`개의 저장된 재생목록", "crash_page_before_reporting": "버그를 보고하기 전에 다음 사항이 있는지 확인합니다:", @@ -456,5 +456,9 @@ "crash_page_report_issue": "위의 방법 중 어느 것도 도움이 되지 않았다면, 깃허브에서 새 이슈를 열고(가능하면 영어로) 메시지에 다음 텍스트를 포함하세요(해당 텍스트를 번역하지 마십시오):", "videoinfo_youTube_embed_link": "임베드", "videoinfo_invidious_embed_link": "임베드 링크", - "error_video_not_in_playlist": "요청한 동영상이 이 재생목록에 없습니다. 재생목록 목록을 보려면 여기를 클릭하십시오." + "error_video_not_in_playlist": "요청한 동영상이 이 재생목록에 없습니다. 재생목록 목록을 보려면 여기를 클릭하십시오.", + "channel_tab_shorts_label": "쇼츠", + "channel_tab_streams_label": "실시간 스트리밍", + "channel_tab_channels_label": "채널", + "channel_tab_playlists_label": "재생목록" } diff --git a/locales/lt.json b/locales/lt.json index 35ababee..9bfcfdba 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "`x` pažymėjo tai su ❤", "Audio mode": "Garso rėžimas", "Video mode": "Vaizdo rėžimas", - "Videos": "Vaizdo įrašai", + "channel_tab_videos_label": "Vaizdo įrašai", "Playlists": "Grojaraiščiai", - "Community": "Bendruomenė", + "channel_tab_community_label": "Bendruomenė", "search_filters_sort_option_relevance": "Aktualumas", "search_filters_sort_option_rating": "Reitingas", "search_filters_sort_option_date": "Įkėlimo data", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index f4c2021b..d29cca43 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "`x` levnet et ❤", "Audio mode": "Lydmodus", "Video mode": "Video-modus", - "Videos": "Videoer", + "channel_tab_videos_label": "Videoer", "Playlists": "Spillelister", - "Community": "Gemenskap", + "channel_tab_community_label": "Gemenskap", "search_filters_sort_option_relevance": "relevans", "search_filters_sort_option_rating": "vurdering", "search_filters_sort_option_date": "dato", diff --git a/locales/nl.json b/locales/nl.json index 17057553..dfc68671 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -320,9 +320,9 @@ "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤", "Audio mode": "Audiomodus", "Video mode": "Videomodus", - "Videos": "Video's", + "channel_tab_videos_label": "Video's", "Playlists": "Afspeellijsten", - "Community": "Gemeenschap", + "channel_tab_community_label": "Gemeenschap", "search_filters_sort_option_relevance": "relevantie", "search_filters_sort_option_rating": "beoordeling", "search_filters_sort_option_date": "datum", diff --git a/locales/or.json b/locales/or.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/or.json @@ -0,0 +1 @@ +{} diff --git a/locales/pl.json b/locales/pl.json index f1a07490..b9c2a638 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -324,9 +324,9 @@ "`x` marked it with a ❤": "`x` oznaczonych ❤", "Audio mode": "Tryb audio", "Video mode": "Tryb wideo", - "Videos": "Filmy", + "channel_tab_videos_label": "Wideo", "Playlists": "Playlisty", - "Community": "Społeczność", + "channel_tab_community_label": "Społeczność", "search_filters_sort_option_relevance": "Trafność", "search_filters_sort_option_rating": "Ocena", "search_filters_sort_option_date": "Data przesłania", @@ -488,5 +488,9 @@ "search_message_use_another_instance": " Możesz także wyszukać w innej instancji.", "search_filters_type_option_all": "Dowolny typ", "search_filters_duration_option_none": "Dowolna długość", - "search_filters_duration_option_medium": "Średnia (4-20 minut)" + "search_filters_duration_option_medium": "Średnia (4-20 minut)", + "channel_tab_streams_label": "Na żywo", + "channel_tab_channels_label": "Kanały", + "channel_tab_playlists_label": "Playlisty", + "channel_tab_shorts_label": "Shorts" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 9576d646..112ed4b7 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` foi marcado como ❤", "Audio mode": "Modo de áudio", "Video mode": "Modo de vídeo", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "Playlists": "Listas de reprodução", - "Community": "Comunidade", + "channel_tab_community_label": "Comunidade", "search_filters_sort_option_relevance": "relevância", "search_filters_sort_option_rating": "avaliação", "search_filters_sort_option_date": "data", @@ -471,5 +471,6 @@ "Turkish (auto-generated)": "Turco (gerado automaticamente)", "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", "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. Clique aqui para acessar a página inicial da playlist." } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 5313915b..1788deb1 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -22,14 +22,14 @@ "Import and Export Data": "Importar e exportar dados", "Import": "Importar", "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 NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Export": "Exportar", "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 data as JSON": "Exportar dados do Invidious como JSON", + "Export data as JSON": "Exportar dados Invidious como JSON", "Delete account?": "Eliminar conta?", "History": "Histórico", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` foi marcado como ❤", "Audio mode": "Modo de áudio", "Video mode": "Modo de vídeo", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "Playlists": "Listas de reprodução", - "Community": "Comunidade", + "channel_tab_community_label": "Comunidade", "search_filters_sort_option_relevance": "Relevância", "search_filters_sort_option_rating": "Avaliação", "search_filters_sort_option_date": "Data de envio", @@ -379,24 +379,24 @@ "generic_videos_count_plural": "{{count}} vídeos", "generic_playlists_count": "{{count}} lista de reprodução", "generic_playlists_count_plural": "{{count}} listas de reprodução", - "generic_subscriptions_count": "{{count}} subscrição", - "generic_subscriptions_count_plural": "{{count}} subscrições", + "generic_subscriptions_count": "{{count}} inscrição", + "generic_subscriptions_count_plural": "{{count}} inscrições", "generic_views_count": "{{count}} visualização", "generic_views_count_plural": "{{count}} visualizações", - "generic_subscribers_count": "{{count}} subscritor", - "generic_subscribers_count_plural": "{{count}} subscritores", + "generic_subscribers_count": "{{count}} inscrito", + "generic_subscribers_count_plural": "{{count}} inscritos", "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", - "subscriptions_unseen_notifs_count": "{{count}} notificação por ver", - "subscriptions_unseen_notifs_count_plural": "{{count}} notificações por ver", - "Popular enabled: ": "Página \"Popular\" ativada: ", + "subscriptions_unseen_notifs_count": "{{count}} notificação não vista", + "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas", + "Popular enabled: ": "Página \"popular\" ativada: ", "search_message_no_results": "Nenhum resultado encontrado.", - "preferences_quality_dash_option_auto": "Automática", - "preferences_region_label": "País para o conteúdo: ", + "preferences_quality_dash_option_auto": "Automático", + "preferences_region_label": "País do conteúdo: ", "preferences_quality_dash_option_1440p": "1440p", "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_worst": "Pior", "preferences_quality_dash_option_144p": "144p", @@ -404,13 +404,13 @@ "preferences_quality_option_hd720": "HD720", "preferences_quality_option_dash": "DASH (qualidade adaptativa)", "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_480p": "480p", "preferences_quality_dash_option_360p": "360p", "preferences_quality_dash_option_240p": "240p", - "Video unavailable": "Vídeo indisponível", - "Russian (auto-generated)": "Russo (geradas automaticamente)", + "Video unavailable": "Vídeo não disponível", + "Russian (auto-generated)": "Russo (gerado automaticamente)", "comments_view_x_replies": "Ver {{count}} resposta", "comments_view_x_replies_plural": "Ver {{count}} respostas", "comments_points_count": "{{count}} ponto", @@ -418,18 +418,18 @@ "English (United Kingdom)": "Inglês (Reino Unido)", "Chinese (Hong Kong)": "Chinês (Hong Kong)", "Chinese (Taiwan)": "Chinês (Taiwan)", - "Dutch (auto-generated)": "Holandês (geradas automaticamente)", - "French (auto-generated)": "Francês (geradas automaticamente)", - "German (auto-generated)": "Alemão (geradas automaticamente)", - "Indonesian (auto-generated)": "Indonésio (geradas automaticamente)", - "Interlingue": "Interlingue", - "Italian (auto-generated)": "Italiano (geradas automaticamente)", - "Japanese (auto-generated)": "Japonês (geradas automaticamente)", - "Korean (auto-generated)": "Coreano (geradas automaticamente)", - "Portuguese (auto-generated)": "Português (geradas automaticamente)", + "Dutch (auto-generated)": "Holandês (gerado automaticamente)", + "French (auto-generated)": "Francês (gerado automaticamente)", + "German (auto-generated)": "Alemão (gerado automaticamente)", + "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)", + "Interlingue": "Interlíngua", + "Italian (auto-generated)": "Italiano (gerado automaticamente)", + "Japanese (auto-generated)": "Japonês (gerado automaticamente)", + "Korean (auto-generated)": "Coreano (gerado automaticamente)", + "Portuguese (auto-generated)": "Português (gerado automaticamente)", "Portuguese (Brazil)": "Português (Brasil)", "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_duration_option_none": "Qualquer duração", "search_filters_duration_option_short": "Curto (< 4 minutos)", @@ -438,29 +438,39 @@ "search_filters_features_option_purchased": "Comprado", "search_filters_apply_button": "Aplicar filtros selecionados", "videoinfo_watch_on_youTube": "Ver no YouTube", - "videoinfo_youTube_embed_link": "Embutir", - "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte modificado", - "videoinfo_invidious_embed_link": "Ligação embutida", + "videoinfo_youTube_embed_link": "Incorporar", + "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado", + "videoinfo_invidious_embed_link": "Incorporar hiperligação", "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)", "user_created_playlists": "`x` listas de reprodução criadas", "user_saved_playlists": "`x` listas de reprodução guardadas", - "preferences_save_player_pos_label": "Guardar posição de reprodução: ", - "Turkish (auto-generated)": "Turco (geradas automaticamente)", + "preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ", + "Turkish (auto-generated)": "Turco (gerado automaticamente)", "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", "Chinese (China)": "Chinês (China)", - "Spanish (auto-generated)": "Espanhol (geradas automaticamente)", + "Spanish (auto-generated)": "Espanhol (gerado automaticamente)", "Spanish (Mexico)": "Espanhol (México)", "English (United States)": "Inglês (Estados Unidos)", "footer_donate_page": "Doar", "footer_documentation": "Documentação", "footer_source_code": "Código-fonte", "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", - "search_filters_date_label": "Data de carregamento", + "search_filters_date_label": "Data de publicação", "search_filters_date_option_none": "Qualquer data", "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 pesquisar noutra instância.", + "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 Perguntas frequentes (FAQ)", + "crash_page_search_issue": "procurou se o erro já foi reportado no GitHub", + "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor abra um novo problema no Github (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 recarregar a página", + "crash_page_switch_instance": "tentou usar outra instância", + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. Clique aqui para a página inicial da lista de reprodução." } diff --git a/locales/pt.json b/locales/pt.json index b550bc87..2facba94 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -267,9 +267,9 @@ "Next page": "Próxima página", "last": "últimos", "Current version: ": "Versão atual: ", - "Community": "Comunidade", + "channel_tab_community_label": "Comunidade", "Playlists": "Listas de reprodução", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "Video mode": "Modo de vídeo", "Audio mode": "Modo de áudio", "`x` marked it with a ❤": "`x` foi marcado como ❤", diff --git a/locales/ro.json b/locales/ro.json index 342f5f37..0f6407d6 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -315,9 +315,9 @@ "`x` marked it with a ❤": "`x` l-a marcat cu o ❤", "Audio mode": "Mod audio", "Video mode": "Mod video", - "Videos": "Videoclipuri", + "channel_tab_videos_label": "Videoclipuri", "Playlists": "Liste de redare", - "Community": "Comunitate", + "channel_tab_community_label": "Comunitate", "Current version: ": "Versiunea actuală: ", "crash_page_read_the_faq": "citit lista Întrebărilor Frecvente (FAQ)", "generic_count_days_0": "{{count}} zi", diff --git a/locales/ru.json b/locales/ru.json index 93c9cbec..e54937a6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -325,9 +325,9 @@ "`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "Audio mode": "Аудио режим", "Video mode": "Видео режим", - "Videos": "Видео", + "channel_tab_videos_label": "Видео", "Playlists": "Плейлисты", - "Community": "Сообщество", + "channel_tab_community_label": "Сообщество", "search_filters_sort_option_relevance": "по актуальности", "search_filters_sort_option_rating": "по рейтингу", "search_filters_sort_option_date": "по дате загрузки", diff --git a/locales/sl.json b/locales/sl.json index 5994ca1a..f27bb20d 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -222,7 +222,7 @@ "About": "O aplikaciji", "%A %B %-d, %Y": "%A %-d %B %Y", "Audio mode": "Avdio način", - "Videos": "Videoposnetki", + "channel_tab_videos_label": "Videoposnetki", "search_filters_date_label": "Datum nalaganja", "search_filters_date_option_today": "Danes", "search_filters_date_option_week": "Ta teden", @@ -455,7 +455,7 @@ "Download": "Prenesi", "permalink": "stalna povezava", "`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°", "Video mode": "Video način", "search_filters_features_option_c_commons": "Creative Commons", diff --git a/locales/sq.json b/locales/sq.json index 76f1eaa3..b8651316 100644 --- a/locales/sq.json +++ b/locales/sq.json @@ -259,10 +259,10 @@ "YouTube comment permalink": "Permalidhje komenti YouTube", "Audio mode": "Mënyrë për audion", "Playlists": "Luajlista", - "Community": "Bashkësi", + "channel_tab_community_label": "Bashkësi", "search_filters_sort_option_relevance": "Rëndësi", "Video mode": "Mënyrë video", - "Videos": "Video", + "channel_tab_videos_label": "Video", "search_filters_sort_option_rating": "Vlerësim", "search_filters_sort_option_date": "Datë ngarkimi", "search_filters_sort_option_views": "Numër parjesh", @@ -446,6 +446,22 @@ "Import YouTube subscriptions": "Importoni pajtime YouTube/OPML", "Export data as JSON": "Eksportoji të dhënat Invidious si JSON", "preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ", - "Shared `x`": "Ndau me të tjerë `x`", - "search_filters_title": "Filtra" + "Shared `x`": "Ndarë me të tjerë më `x`", + "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ë. Klikoni këtu për faqen hyrëse të luajlistës.", + "search_message_use_another_instance": " Mundeni edhe të kërkoni në një instancë tjetër.", + "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" } diff --git a/locales/sr.json b/locales/sr.json index d2f990ae..fd19c493 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -257,7 +257,7 @@ "preferences_volume_label": "Jačina zvuka: ", "preferences_locale_label": "Jezik: ", "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", "Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ", "Private": "Privatno", @@ -289,7 +289,7 @@ "Erroneous token": "Pogrešan žeton", "Czech": "Češki", "Latin": "Latinski", - "Videos": "Video klipovi", + "channel_tab_videos_label": "Video klipovi", "search_filters_features_option_four_k": "4К", "footer_donate_page": "Doniraj", "English": "Engleski", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index c0f1224f..bef9915d 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -245,7 +245,7 @@ "(edited)": "(измењено)", "`x` marked it with a ❤": "`x` је означио/ла ово са ❤", "Audio mode": "Аудио мод", - "Videos": "Видео клипови", + "channel_tab_videos_label": "Видео клипови", "search_filters_sort_option_views": "Број прегледа", "search_filters_features_label": "Карактеристике", "search_filters_date_option_today": "Данас", @@ -298,7 +298,7 @@ "Ukrainian": "Украјински", "permalink": "трајна веза", "Pashto": "Паштунски", - "Community": "Заједница", + "channel_tab_community_label": "Заједница", "Sindhi": "Синди", "Could not fetch comments": "Узимање коментара није успело", "Bangla": "Бангла/Бенгалски", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 777899d0..39e94fd3 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -323,9 +323,9 @@ "`x` marked it with a ❤": "`x` lämnade ett ❤", "Audio mode": "Ljudläge", "Video mode": "Videoläge", - "Videos": "Videor", + "channel_tab_videos_label": "Videor", "Playlists": "Spellistor", - "Community": "Gemenskap", + "channel_tab_community_label": "Gemenskap", "search_filters_sort_option_relevance": "Relevans", "search_filters_sort_option_rating": "Rankning", "search_filters_sort_option_date": "Datum", diff --git a/locales/tr.json b/locales/tr.json index 77aacb40..76cce15a 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -1,126 +1,126 @@ { "LIVE": "CANLI", - "Shared `x` ago": "`x` önce paylaşıldı", - "Unsubscribe": "Abonelikten çık", - "Subscribe": "Abone ol", - "View channel on YouTube": "Kanalı YouTube'da görüntüle", - "View playlist on YouTube": "Oynatma listesini YouTube'da görüntüle", - "newest": "en yeni", - "oldest": "en eski", - "popular": "popüler", - "last": "son", - "Next page": "Sonraki sayfa", - "Previous page": "Önceki sayfa", + "Shared `x` ago": "`x` Önce Paylaşıldı", + "Unsubscribe": "Abonelikten Çık", + "Subscribe": "Abone Ol", + "View channel on YouTube": "Kanalı YouTube'da Görüntüle", + "View playlist on YouTube": "Oynatma Listesini YouTube'da Görüntüle", + "newest": "En Yeni", + "oldest": "En Eski", + "popular": "Popüler", + "last": "Son", + "Next page": "Sonraki Sayfa", + "Previous page": "Önceki Sayfa", "Clear watch history?": "İzleme geçmişi temizlensin mi?", - "New password": "Yeni parola", - "New passwords must match": "Yeni parolalar eşleşmek zorunda", - "Cannot change password for Google accounts": "Google hesapları için parola değiştirilemez", + "New password": "Yeni Parola", + "New passwords must match": "Yeni Parolalar Eşleşmek Zorunda", + "Cannot change password for Google accounts": "Google Hesapları İçin Parola Değiştirilemez", "Authorize token?": "Belirteç yetkilendirilsin mi?", "Authorize token for `x`?": "`x` için belirteç yetkilendirilsin mi?", "Yes": "Evet", "No": "Hayır", "Import and Export Data": "Verileri İçe ve Dışa Aktar", - "Import": "İçe aktar", - "Import Invidious data": "İnvidious JSON verilerini içe aktar", - "Import YouTube subscriptions": "YouTube/OPML aboneliklerini içe aktar", - "Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)", - "Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)", - "Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)", - "Export": "Dışa aktar", - "Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)", - "Export data as JSON": "Invidious verilerini JSON olarak dışa aktar", + "Import": "İçe Aktar", + "Import Invidious data": "Invidious JSON Verilerini İçe Aktar", + "Import YouTube subscriptions": "YouTube/OPML Aboneliklerini İçe Aktar", + "Import FreeTube subscriptions (.db)": "FreeTube Aboneliklerini İçe Aktar (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe Aboneliklerini İçe Aktar (.json)", + "Import NewPipe data (.zip)": "NewPipe Verilerini İçe Aktar (.zip)", + "Export": "Dışa Aktar", + "Export subscriptions as OPML": "Abonelikleri OPML Olarak Dışa Aktar", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML Olarak Dışa Aktar (NewPipe ve FreeTube İçin)", + "Export data as JSON": "İnvidious Verilerini JSON Olarak Dışa Aktar", "Delete account?": "Hesap silinsin mi?", "History": "Geçmiş", - "An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz", - "JavaScript license information": "JavaScript lisans bilgileri", - "source": "kaynak", - "Log in": "Oturum aç", - "Log in/register": "Oturum aç/kayıt ol", - "Log in with Google": "Google ile oturum aç", - "User ID": "Kullanıcı kimliği", + "An alternative front-end to YouTube": "YouTube İçin Alternatif Bir Ön-Yüz", + "JavaScript license information": "JavaScript Lisans Bilgileri", + "source": "Kaynak", + "Log in": "Oturum Aç", + "Log in/register": "Oturum Aç/Kayıt Ol", + "Log in with Google": "Google İle Oturum Aç", + "User ID": "Kullanıcı Kimliği", "Password": "Parola", "Time (h:mm:ss):": "Zaman (h:mm:ss):", "Text CAPTCHA": "Metin CAPTCHA", "Image CAPTCHA": "Resim CAPTCHA", "Sign In": "Oturum Aç", "Register": "Kayıt Ol", - "E-mail": "E-posta", - "Google verification code": "Google doğrulama kodu", + "E-mail": "E-Posta", + "Google verification code": "Google Doğrulama Kodu", "Preferences": "Tercihler", - "preferences_category_player": "Oynatıcı tercihleri", - "preferences_video_loop_label": "Sürekli döngü: ", - "preferences_autoplay_label": "Otomatik oynat: ", - "preferences_continue_label": "Öntanımlı olarak sonrakini oynat: ", - "preferences_continue_autoplay_label": "Sonraki videoyu otomatik oynat: ", - "preferences_listen_label": "Öntanımlı olarak dinle: ", - "preferences_local_label": "Videoları proxy'le: ", - "preferences_speed_label": "Öntanımlı hız: ", - "preferences_quality_label": "Tercih edilen video kalitesi: ", - "preferences_volume_label": "Oynatıcı ses seviyesi: ", - "preferences_comments_label": "Öntanımlı yorumlar: ", + "preferences_category_player": "Oynatıcı Tercihleri", + "preferences_video_loop_label": "Sürekli Döngü: ", + "preferences_autoplay_label": "Otomatik Oynat: ", + "preferences_continue_label": "Öntanımlı Olarak Sonrakini Oynat: ", + "preferences_continue_autoplay_label": "Sonraki Videoyu Otomatik Oynat: ", + "preferences_listen_label": "Öntanımlı Olarak Dinle: ", + "preferences_local_label": "Videolara Proxy Uygula: ", + "preferences_speed_label": "Öntanımlı Hız: ", + "preferences_quality_label": "Tercih Edilen Video Kalitesi: ", + "preferences_volume_label": "Oynatıcı Ses Seviyesi: ", + "preferences_comments_label": "Öntanımlı Yorumlar: ", "youtube": "YouTube", "reddit": "Reddit", - "preferences_captions_label": "Öntanımlı altyazılar: ", - "Fallback captions: ": "Yedek altyazılar: ", - "preferences_related_videos_label": "İlgili videoları 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_vr_mode_label": "Etkileşimli 360 derece videolar (WebGL gerektirir): ", - "preferences_category_visual": "Görsel tercihler", - "preferences_player_style_label": "Oynatıcı biçimi: ", - "Dark mode: ": "Karanlık mod: ", + "preferences_captions_label": "Öntanımlı Altyazılar: ", + "Fallback captions: ": "Yedek Altyazılar: ", + "preferences_related_videos_label": "İlgili Videoları 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_vr_mode_label": "Etkileşimli 360 Derece Videolar (WebGL Gerektirir): ", + "preferences_category_visual": "Görsel Tercihler", + "preferences_player_style_label": "Oynatıcı Biçimi: ", + "Dark mode: ": "Koyu Mod: ", "preferences_dark_mode_label": "Tema: ", - "dark": "karanlık", - "light": "aydınlık", - "preferences_thin_mode_label": "İnce mod: ", - "preferences_category_misc": "Çeşitli tercihler", - "preferences_automatic_instance_redirect_label": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", - "preferences_category_subscription": "Abonelik tercihleri", - "preferences_annotations_subscribed_label": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", - "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", - "preferences_max_results_label": "Akışta gösterilen video sayısı: ", - "preferences_sort_label": "Videoları sıralama kriteri: ", - "published": "yayınlandı", - "published - reverse": "yayınlandı - ters", - "alphabetically": "alfabetik olarak", - "alphabetically - reverse": "alfabetik olarak - ters", - "channel name": "kanal adı", - "channel name - reverse": "kanal adı - ters", - "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: ", - "preferences_unseen_only_label": "Sadece izlenmemişleri göster: ", - "preferences_notifications_only_label": "Sadece bildirimleri göster (eğer varsa): ", - "Enable web notifications": "Ağ bildirimlerini etkinleştir", - "`x` uploaded a video": "`x` bir video yükledi", - "`x` is live": "`x` canlı yayında", - "preferences_category_data": "Veri tercihleri", - "Clear watch history": "İzleme geçmişini temizle", - "Import/export data": "Verileri içe/dışa aktar", - "Change password": "Parolayı değiştir", - "Manage subscriptions": "Abonelikleri yönet", - "Manage tokens": "Belirteçleri yönet", - "Watch history": "İzleme geçmişi", - "Delete account": "Hesap silme", - "preferences_category_admin": "Yönetici tercihleri", - "preferences_default_home_label": "Öntanımlı ana sayfa: ", - "preferences_feed_menu_label": "Akış menüsü: ", - "preferences_show_nick_label": "Takma adı üstte göster: ", - "Top enabled: ": "Top etkin: ", - "CAPTCHA enabled: ": "CAPTCHA etkin: ", - "Login enabled: ": "Oturum açma etkin: ", - "Registration enabled: ": "Kayıt olma etkin: ", - "Report statistics: ": "Rapor istatistikleri: ", - "Save preferences": "Tercihleri kaydet", - "Subscription manager": "Abonelik yöneticisi", - "Token manager": "Belirteç yöneticisi", + "dark": "Koyu", + "light": "Açık", + "preferences_thin_mode_label": "İnce Mod: ", + "preferences_category_misc": "Çeşitli Tercihler", + "preferences_automatic_instance_redirect_label": "Otomatik Örnek Yeniden Yönlendirmesi (Yedek: redirect.invidious.io): ", + "preferences_category_subscription": "Abonelik Tercihleri", + "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: ", + "preferences_max_results_label": "Akışta Gösterilen Video Sayısı: ", + "preferences_sort_label": "Videoları Sıralama Kriteri: ", + "published": "Yayınlandı", + "published - reverse": "Yayınlandı - Ters", + "alphabetically": "Alfabetik Olarak", + "alphabetically - reverse": "Alfabetik Olarak - Ters", + "channel name": "Kanal Adı", + "channel name - reverse": "Kanal Adı - Ters", + "Only show latest video from channel: ": "Sadece Kanaldaki En Son Videoyu Göster: ", + "Only show latest unwatched video from channel: ": "Sadece Kanaldaki En Son İzlenmemiş Videoyu Göster: ", + "preferences_unseen_only_label": "Sadece İzlenmemişleri Göster: ", + "preferences_notifications_only_label": "Sadece Bildirimleri Göster (Eğer Varsa): ", + "Enable web notifications": "Ağ Bildirimlerini Etkinleştir", + "`x` uploaded a video": "`x` Bir Video Yükledi", + "`x` is live": "`x` Canlı Yayında", + "preferences_category_data": "Veri Tercihleri", + "Clear watch history": "İzleme Geçmişini Temizle", + "Import/export data": "Verileri İçe/Dışa Aktar", + "Change password": "Parolayı Değiştir", + "Manage subscriptions": "Abonelikleri Yönet", + "Manage tokens": "Belirteçleri Yönet", + "Watch history": "İzleme Geçmişi", + "Delete account": "Hesap Silme", + "preferences_category_admin": "Yönetici Tercihleri", + "preferences_default_home_label": "Öntanımlı Ana Sayfa: ", + "preferences_feed_menu_label": "Akış Menüsü: ", + "preferences_show_nick_label": "Takma Adı Üstte Göster: ", + "Top enabled: ": "Top Etkin: ", + "CAPTCHA enabled: ": "CAPTCHA Etkin: ", + "Login enabled: ": "Oturum Açma Etkin: ", + "Registration enabled: ": "Kayıt Olma Etkin: ", + "Report statistics: ": "Rapor İstatistikleri: ", + "Save preferences": "Tercihleri Kaydet", + "Subscription manager": "Abonelik Yöneticisi", + "Token manager": "Belirteç Yöneticisi", "Token": "Belirteç", - "Import/export": "İçe/dışa aktar", - "unsubscribe": "abonelikten çık", - "revoke": "geri al", + "Import/export": "İçe/Dışa Aktar", + "unsubscribe": "Abonelikten Çık", + "revoke": "Geri Al", "Subscriptions": "Abonelikler", - "search": "ara", - "Log out": "Çıkış yap", + "search": "Ara", + "Log out": "Çıkış Yap", "Released under the AGPLv3 on Github.": "GitHub'da AGPLv3 altında yayınlandı.", "Source available here.": "Kaynak kodları burada bulunabilir.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", @@ -129,76 +129,76 @@ "Public": "Genel", "Unlisted": "Listelenmemiş", "Private": "Özel", - "View all playlists": "Tüm oynatma listelerini görüntüle", - "Updated `x` ago": "`x` önce güncellendi", + "View all playlists": "Tüm Oynatma Listelerini Görüntüle", + "Updated `x` ago": "`x` Önce Güncellendi", "Delete playlist `x`?": "`x` oynatma listesi silinsin mi?", - "Delete playlist": "Oynatma listesini sil", - "Create playlist": "Oynatma listesi oluştur", + "Delete playlist": "Oynatma Listesini Sil", + "Create playlist": "Oynatma Listesi Oluştur", "Title": "Başlık", - "Playlist privacy": "Oynatma listesi gizliliği", - "Editing playlist `x`": "`x` oynatma listesi düzenleniyor", - "Show more": "Daha fazla göster", - "Show less": "Daha az göster", - "Watch on YouTube": "YouTube'da izle", + "Playlist privacy": "Oynatma Listesi Gizliliği", + "Editing playlist `x`": "`x` Oynatma Listesi Düzenleniyor", + "Show more": "Daha Fazla Göster", + "Show less": "Daha Az Göster", + "Watch on YouTube": "YouTube'da İzle", "Switch Invidious Instance": "Invidious Örneğini Değiştir", - "Hide annotations": "Ek açıklamaları gizle", - "Show annotations": "Ek açıklamaları göster", + "Hide annotations": "Ek Açıklamaları Gizle", + "Show annotations": "Ek Açıklamaları Göster", "Genre: ": "Tür: ", "License: ": "Lisans: ", "Family friendly? ": "Aile için uygun mu? ", - "Wilson score: ": "Wilson puanı: ", - "Engagement: ": "İzleyenlerin oy verme oranı: ", - "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", - "Blacklisted regions: ": "Kara listeye alınan bölgeler: ", - "Shared `x`": "`x` paylaşıldı", - "Premieres in `x`": "`x`içinde ilk gösterim", - "Premieres `x`": "`x` ilk gösterim", + "Wilson score: ": "Wilson Puanı: ", + "Engagement: ": "İzleyenlerin Oy Verme Oranı: ", + "Whitelisted regions: ": "Beyaz Listeye Alınan Bölgeler: ", + "Blacklisted regions: ": "Kara Listeye Alınan Bölgeler: ", + "Shared `x`": "`x` Paylaşıldı", + "Premieres in `x`": "`x`İçinde İlk 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.", - "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 YouTube comments": "YouTube Yorumlarını Görüntüle", + "View more comments on Reddit": "Reddit'te Daha Fazla Yorum Görüntüle", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle", - "": "`x` yorumu görüntüle" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Yorumu Görüntüle", + "": "`x` Yorumu Görüntüle" }, - "View Reddit comments": "Reddit yorumlarını görüntüle", - "Hide replies": "Cevapları gizle", - "Show replies": "Cevapları göster", - "Incorrect password": "Yanlış parola", - "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.", - "Invalid TFA code": "Geçersiz TFA kodu", + "View Reddit comments": "Reddit Yorumlarını Görüntüle", + "Hide replies": "Cevapları Gizle", + "Show replies": "Cevapları Göster", + "Incorrect password": "Yanlış Parola", + "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 (Kimlik Doğrulayıcı ya da SMS) açık olduğundan emin olun.", + "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.", - "Wrong answer": "Yanlış cevap", + "Wrong answer": "Yanlış Cevap", "Erroneous CAPTCHA": "Hatalı CAPTCHA", - "CAPTCHA is a required field": "CAPTCHA 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", - "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", - "Password cannot be empty": "Parola boş olamaz", - "Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz", - "Please log in": "Lütfen oturum açın", - "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", - "channel:`x`": "kanal:`x`", - "Deleted or invalid channel": "Silinmiş ya da geçersiz kanal", + "CAPTCHA is a required field": "CAPTCHA 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", + "Wrong username or password": "Yanlış Kullanıcı Adı ya da Parola", + "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 longer than 55 characters": "Parola 55 Karakterden Uzun Olamaz", + "Please log in": "Lütfen Oturum Açın", + "Invidious Private Feed for `x`": "`x` İçin Invidious Özel Akışı", + "channel:`x`": "Kanal:`x`", + "Deleted or invalid channel": "Silinmiş ya da Geçersiz Kanal", "This channel does not exist.": "Bu kanal mevcut değil.", "Could not get channel info.": "Kanal bilgisi alınamadı.", - "Could not fetch comments": "Yorumlar alınamadı", - "`x` ago": "`x` önce", - "Load more": "Daha fazla yükle", + "Could not fetch comments": "Yorumlar Alınamadı", + "`x` ago": "`x` Önce", + "Load more": "Daha Fazla Yükle", "Could not create mix.": "Mix oluşturulamadı.", - "Empty playlist": "Boş oynatma listesi", + "Empty playlist": "Boş Oynatma Listesi", "Not a playlist.": "Oynatma listesi değil.", "Playlist does not exist.": "Oynatma listesi mevcut değil.", "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 \"token\" is a required field": "\"belirteç\" gizli alanı zorunlu bir alandır", - "Erroneous challenge": "Hatalı challenge", - "Erroneous token": "Hatalı belirteç", - "No such user": "Böyle bir kullanıcı yok", - "Token is expired, please try again": "Belirtecin süresi doldu, lütfen tekrar deneyin", + "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", + "Erroneous challenge": "Hatalı Challenge", + "Erroneous token": "Hatalı Belirteç", + "No such user": "Böyle Bir Kullanıcı Yok", + "Token is expired, please try again": "Belirtecin Süresi Doldu, Lütfen Tekrar Deneyin", "English": "İngilizce", - "English (auto-generated)": "İngilizce (otomatik oluşturuldu)", + "English (auto-generated)": "İngilizce (Otomatik Oluşturuldu)", "Afrikaans": "Afrikanca", "Albanian": "Arnavutça", "Amharic": "Amharca", @@ -230,9 +230,9 @@ "German": "Almanca", "Greek": "Yunanca", "Gujarati": "Guceratça", - "Haitian Creole": "Haiti Creole dili", + "Haitian Creole": "Haiti Creole Dili", "Hausa": "Hausaca", - "Hawaiian": "Hawaii dili", + "Hawaiian": "Hawaii Dili", "Hebrew": "İbranice", "Hindi": "Hintçe", "Hmong": "Hmong", @@ -244,7 +244,7 @@ "Italian": "İtalyanca", "Japanese": "Japonca", "Javanese": "Cava dili", - "Kannada": "Kannada dili", + "Kannada": "Kannada Dili", "Kazakh": "Kazakça", "Khmer": "Kmerce", "Korean": "Korece", @@ -258,10 +258,10 @@ "Macedonian": "Makedonca", "Malagasy": "Malgaşça", "Malay": "Malayca", - "Malayalam": "Malayalam dili", + "Malayalam": "Malayalam Dili", "Maltese": "Maltaca", - "Maori": "Maori dili", - "Marathi": "Marati dili", + "Maori": "Maori Dili", + "Marathi": "Marati Dili", "Mongolian": "Moğolca", "Nepali": "Nepalce", "Norwegian Bokmål": "Norveççe Bokmål", @@ -270,19 +270,19 @@ "Persian": "Farsça", "Polish": "Lehçe", "Portuguese": "Portekizce", - "Punjabi": "Pencap dili", + "Punjabi": "Pencap Dili", "Romanian": "Rumence", "Russian": "Rusça", - "Samoan": "Samoa dili", + "Samoan": "Samoa Dili", "Scottish Gaelic": "İskoç Galcesi", "Serbian": "Sırpça", - "Shona": "Şona dili", + "Shona": "Şona Dili", "Sindhi": "Sintçe", "Sinhala": "Seylanca", "Slovak": "Slovakça", "Slovenian": "Slovence", "Somali": "Somalice", - "Southern Sotho": "Güney Sotho dili", + "Southern Sotho": "Güney Sotho Dili", "Spanish": "İspanyolca", "Spanish (Latin America)": "İspanyolca (Latin Amerika)", "Sundanese": "Sundaca", @@ -290,7 +290,7 @@ "Swedish": "İsveççe", "Tajik": "Tacikçe", "Tamil": "Tamilce", - "Telugu": "Telugu dili", + "Telugu": "Telugu Dili", "Thai": "Tayca", "Turkish": "Türkçe", "Ukrainian": "Ukraynaca", @@ -299,178 +299,182 @@ "Vietnamese": "Vietnamca", "Welsh": "Galce", "Western Frisian": "Batı Frizcesi", - "Xhosa": "Xhosa dili", + "Xhosa": "Xhosa Dili", "Yiddish": "Yiddiş", - "Yoruba": "Yoruba dili", + "Yoruba": "Yoruba Dili", "Zulu": "Zuluca", - "Fallback comments: ": "Yedek yorumlar: ", + "Fallback comments: ": "Yedek Yorumlar: ", "Popular": "Popüler", "Search": "Ara", "Top": "Enler", "About": "Hakkında", "Rating: ": "Değerlendirme: ", "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ı", "Music": "Müzik", "Gaming": "Oyun", "News": "Haberler", "Movies": "Filmler", "Download": "İndir", - "Download as: ": "Şu şekilde indir: ", + "Download as: ": "Şu Şekilde İndir: ", "%A %B %-d, %Y": "%A %B %-d, %Y", - "(edited)": "(düzenlendi)", - "YouTube comment permalink": "YouTube yorumu kalıcı linki", - "permalink": "kalıcı link", - "`x` marked it with a ❤": "`x` ❤ ile işaretledi", - "Audio mode": "Ses modu", - "Video mode": "Video modu", - "Videos": "Videolar", - "Playlists": "Oynatma listeleri", - "Community": "Topluluk", + "(edited)": "(Düzenlendi)", + "YouTube comment permalink": "YouTube Yorumu Kalıcı Linki", + "permalink": "Kalıcı Link", + "`x` marked it with a ❤": "`x` ❤ İle İşaretledi", + "Audio mode": "Ses Modu", + "Video mode": "Video Modu", + "channel_tab_videos_label": "Videolar", + "Playlists": "Oynatma Listeleri", + "channel_tab_community_label": "Topluluk", "search_filters_sort_option_relevance": "İlgi", "search_filters_sort_option_rating": "Değerlendirme", - "search_filters_sort_option_date": "Yükleme tarihi", - "search_filters_sort_option_views": "Görüntüleme sayısı", + "search_filters_sort_option_date": "Yükleme Tarihi", + "search_filters_sort_option_views": "Görüntüleme Sayısı", "search_filters_type_label": "Tür", "search_filters_duration_label": "Süre", "search_filters_features_label": "Özellikler", "search_filters_sort_label": "Sıralama Ölçütü", "search_filters_date_option_hour": "Son Saat", "search_filters_date_option_today": "Bugün", - "search_filters_date_option_week": "Bu hafta", - "search_filters_date_option_month": "Bu ay", - "search_filters_date_option_year": "Bu yıl", + "search_filters_date_option_week": "Bu Hafta", + "search_filters_date_option_month": "Bu Ay", + "search_filters_date_option_year": "Bu Yıl", "search_filters_type_option_video": "Video", "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_show": "Gösteri", "search_filters_features_option_hd": "HD", - "search_filters_features_option_subtitles": "Alt yazılar", - "search_filters_features_option_c_commons": "Creative Commons", - "search_filters_features_option_three_d": "3B", + "search_filters_features_option_subtitles": "Alt Yazılar", + "search_filters_features_option_c_commons": "Yaratıcı", + "search_filters_features_option_three_d": "3D", "search_filters_features_option_live": "Canlı", "search_filters_features_option_four_k": "4K", "search_filters_features_option_location": "Konum", "search_filters_features_option_hdr": "HDR", - "Current version: ": "Şu anki sürüm: ", - "next_steps_error_message": "Bundan sonra şunları denemelisiniz: ", + "Current version: ": "Şu Anki Sürüm: ", + "next_steps_error_message": "Bundan Sonra Şunları Denemelisiniz: ", "next_steps_error_message_refresh": "Yenile", - "next_steps_error_message_go_to_youtube": "YouTube'a git", - "search_filters_duration_option_short": "Kısa (4 dakikadan az)", - "search_filters_duration_option_long": "Uzun (20 dakikadan fazla)", + "next_steps_error_message_go_to_youtube": "YouTube'a Git", + "search_filters_duration_option_short": "Kısa (4 Dakikadan Az)", + "search_filters_duration_option_long": "Uzun (20 Dakikadan Fazla)", "footer_documentation": "Belgelendirme", - "footer_source_code": "Kaynak kodları", - "footer_original_source_code": "Orijinal 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", - "footer_donate_page": "Bağış yap", - "preferences_region_label": "İçerik ülkesi: ", - "preferences_quality_dash_label": "Tercih edilen DASH video kalitesi: ", + "footer_source_code": "Kaynak Kodları", + "footer_original_source_code": "Orijinal 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", + "footer_donate_page": "Bağış Yap", + "preferences_region_label": "İçerik Ülkesi: ", + "preferences_quality_dash_label": "Tercih Edilen DASH Video Kalitesi: ", "preferences_quality_option_hd720": "HD720", - "preferences_quality_dash_option_best": "En iyi", - "preferences_quality_dash_option_worst": "En kötü", - "preferences_quality_dash_option_4320p": "4320p", - "preferences_quality_dash_option_2160p": "2160p", - "preferences_quality_dash_option_480p": "480p", - "preferences_quality_dash_option_360p": "360p", - "preferences_quality_dash_option_240p": "240p", - "preferences_quality_dash_option_144p": "144p", + "preferences_quality_dash_option_best": "En İyi", + "preferences_quality_dash_option_worst": "En Kötü", + "preferences_quality_dash_option_4320p": "4320P", + "preferences_quality_dash_option_2160p": "2160P", + "preferences_quality_dash_option_480p": "480P", + "preferences_quality_dash_option_360p": "360P", + "preferences_quality_dash_option_240p": "240P", + "preferences_quality_dash_option_144p": "144P", "invidious": "Invidious", - "none": "yok", - "videoinfo_started_streaming_x_ago": "`x` önce yayına başladı", - "videoinfo_youTube_embed_link": "Göm", - "videoinfo_invidious_embed_link": "Bağlantıyı Göm", - "user_created_playlists": "`x` oluşturulan oynatma listeleri", - "user_saved_playlists": "`x` kaydedilen oynatma listeleri", + "none": "Yok", + "videoinfo_started_streaming_x_ago": "`x` Önce Yayına Başladı", + "videoinfo_youTube_embed_link": "Entegre Et", + "videoinfo_invidious_embed_link": "Bağlantıyı Entegre Et", + "user_created_playlists": "`x` Oluşturulan Oynatma Listeleri", + "user_saved_playlists": "`x` Kaydedilen Oynatma Listeleri", "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_dash_option_1440p": "1440p", - "preferences_quality_dash_option_1080p": "1080p", - "Video unavailable": "Video kullanılamıyor", - "preferences_quality_option_dash": "DASH (uyarlanabilir kalite)", + "preferences_quality_dash_option_1440p": "1440P", + "preferences_quality_dash_option_1080p": "1080P", + "Video unavailable": "Video Kullanılamıyor", + "preferences_quality_option_dash": "DASH (Uyarlanabilir Kalite)", "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°", - "videoinfo_watch_on_youTube": "YouTube'da izle", - "download_subtitles": "Alt yazılar - `x` (.vtt)", - "preferences_save_player_pos_label": "Oynatma konumunu kaydet: ", - "generic_views_count": "{{count}} görüntüleme", - "generic_views_count_plural": "{{count}} görüntüleme", - "generic_subscribers_count": "{{count}} abone", - "generic_subscribers_count_plural": "{{count}} abone", - "generic_subscriptions_count": "{{count}} abonelik", - "generic_subscriptions_count_plural": "{{count}} abonelik", - "subscriptions_unseen_notifs_count": "{{count}} okunmamış bildirim", - "subscriptions_unseen_notifs_count_plural": "{{count}} okunmamış bildirim", - "comments_points_count": "{{count}} puan", - "comments_points_count_plural": "{{count}} puan", - "generic_count_hours": "{{count}} saat", - "generic_count_hours_plural": "{{count}} saat", - "generic_count_minutes": "{{count}} dakika", - "generic_count_minutes_plural": "{{count}} dakika", - "generic_count_seconds": "{{count}} saniye", - "generic_count_seconds_plural": "{{count}} saniye", - "generic_playlists_count": "{{count}} oynatma listesi", - "generic_playlists_count_plural": "{{count}} oynatma listesi", - "tokens_count": "{{count}} belirteç", - "tokens_count_plural": "{{count}} belirteç", - "comments_view_x_replies": "{{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_plural": "{{count}} yıl", - "generic_count_months": "{{count}} ay", - "generic_count_months_plural": "{{count}} ay", - "generic_count_days": "{{count}} gün", - "generic_count_days_plural": "{{count}} gün", - "generic_videos_count": "{{count}} video", - "generic_videos_count_plural": "{{count}} video", - "generic_count_weeks": "{{count}} hafta", - "generic_count_weeks_plural": "{{count}} hafta", + "videoinfo_watch_on_youTube": "YouTube'da İzle", + "download_subtitles": "Alt Yazılar - `x` (.vtt)", + "preferences_save_player_pos_label": "Oynatma Konumunu Kaydet: ", + "generic_views_count": "{{count}} Görüntüleme", + "generic_views_count_plural": "{{count}} Görüntüleme", + "generic_subscribers_count": "{{count}} Abone", + "generic_subscribers_count_plural": "{{count}} Abone", + "generic_subscriptions_count": "{{count}} Abonelik", + "generic_subscriptions_count_plural": "{{count}} Abonelik", + "subscriptions_unseen_notifs_count": "{{count}} Okunmamış Bildirim", + "subscriptions_unseen_notifs_count_plural": "{{count}} Okunmamış Bildirim", + "comments_points_count": "{{count}} Puan", + "comments_points_count_plural": "{{count}} Puan", + "generic_count_hours": "{{count}} Saat", + "generic_count_hours_plural": "{{count}} Saat", + "generic_count_minutes": "{{count}} Dakika", + "generic_count_minutes_plural": "{{count}} Dakika", + "generic_count_seconds": "{{count}} Saniye", + "generic_count_seconds_plural": "{{count}} Saniye", + "generic_playlists_count": "{{count}} Oynatma Listesi", + "generic_playlists_count_plural": "{{count}} Oynatma Listesi", + "tokens_count": "{{count}} Belirteç", + "tokens_count_plural": "{{count}} Belirteç", + "comments_view_x_replies": "{{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_plural": "{{count}} Yıl", + "generic_count_months": "{{count}} Ay", + "generic_count_months_plural": "{{count}} Ay", + "generic_count_days": "{{count}} Gün", + "generic_count_days_plural": "{{count}} Gün", + "generic_videos_count": "{{count}} Video", + "generic_videos_count_plural": "{{count}} Video", + "generic_count_weeks": "{{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_before_reporting": "Bir hatayı bildirmeden önce, şunları yaptığınızdan emin olun:", - "crash_page_refresh": "sayfayı yenilemeye çalıştınız", - "crash_page_switch_instance": "başka bir örnek kullanmaya çalıştınız", - "crash_page_read_the_faq": "Sık Sorulan Soruları (SSS) okudunuz", - "crash_page_search_issue": "GitHub'daki sorunlarda aradınız", - "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen GitHub'da yeni bir sorun açın (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):", + "crash_page_refresh": "Sayfayı Yenilemeye Çalıştınız", + "crash_page_switch_instance": "Başka Bir Örnek Kullanmaya Çalıştınız", + "crash_page_read_the_faq": "Sık Sorulan Soruları (SSS) Okudunuz", + "crash_page_search_issue": "GitHub'daki Sorunlarda Aradınız", + "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen GitHub'da yeni bir sorun açın (Tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (Bu metni ÇEVİRMEYİN):", "English (United Kingdom)": "İngilizce (Birleşik Krallık)", "Chinese": "Çince", "Interlingue": "İnterlingue", - "Italian (auto-generated)": "İtalyanca (otomatik oluşturuldu)", - "Japanese (auto-generated)": "Japonca (otomatik oluşturuldu)", + "Italian (auto-generated)": "İtalyanca (Otomatik Oluşturuldu)", + "Japanese (auto-generated)": "Japonca (Otomatik Oluşturuldu)", "Portuguese (Brazil)": "Portekizce (Brezilya)", - "Russian (auto-generated)": "Rusça (otomatik oluşturuldu)", - "Spanish (auto-generated)": "İspanyolca (otomatik oluşturuldu)", + "Russian (auto-generated)": "Rusça (Otomatik Oluşturuldu)", + "Spanish (auto-generated)": "İspanyolca (Otomatik Oluşturuldu)", "Spanish (Mexico)": "İspanyolca (Meksika)", "English (United States)": "İngilizce (ABD)", "Cantonese (Hong Kong)": "Kantonca (Hong Kong)", "Chinese (Taiwan)": "Çince (Tayvan)", - "Dutch (auto-generated)": "Felemenkçe (otomatik oluşturuldu)", - "Indonesian (auto-generated)": "Endonezyaca (otomatik oluşturuldu)", + "Dutch (auto-generated)": "Felemenkçe (Otomatik Oluşturuldu)", + "Indonesian (auto-generated)": "Endonezyaca (Otomatik Oluşturuldu)", "Chinese (Hong Kong)": "Çince (Hong Kong)", - "French (auto-generated)": "Fransızca (otomatik oluşturuldu)", - "Korean (auto-generated)": "Korece (otomatik oluşturuldu)", - "Turkish (auto-generated)": "Türkçe (otomatik oluşturuldu)", + "French (auto-generated)": "Fransızca (Otomatik Oluşturuldu)", + "Korean (auto-generated)": "Korece (Otomatik Oluşturuldu)", + "Turkish (auto-generated)": "Türkçe (Otomatik Oluşturuldu)", "Chinese (China)": "Çince (Çin)", - "German (auto-generated)": "Almanca (otomatik oluşturuldu)", - "Portuguese (auto-generated)": "Portekizce (otomatik oluşturuldu)", + "German (auto-generated)": "Almanca (Otomatik Oluşturuldu)", + "Portuguese (auto-generated)": "Portekizce (Otomatik Oluşturuldu)", "Spanish (Spain)": "İspanyolca (İspanya)", - "Vietnamese (auto-generated)": "Vietnamca (otomatik oluşturuldu)", - "preferences_watch_history_label": "İzleme geçmişini etkinleştir: ", + "Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)", + "preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ", "search_message_use_another_instance": " Ayrıca başka bir örnekte arayabilirsiniz.", - "search_filters_type_option_all": "Herhangi bir tür", - "search_filters_duration_option_none": "Herhangi bir süre", + "search_filters_type_option_all": "Herhangi Bir Tür", + "search_filters_duration_option_none": "Herhangi Bir Süre", "search_message_no_results": "Sonuç bulunamadı.", - "search_filters_date_label": "Yükleme tarihi", - "search_filters_apply_button": "Seçili filtreleri uygula", - "search_filters_date_option_none": "Herhangi bir tarih", - "search_filters_duration_option_medium": "Orta (4 - 20 dakika)", + "search_filters_date_label": "Yükleme Tarihi", + "search_filters_apply_button": "Seçili Filtreleri Uygula", + "search_filters_date_option_none": "Herhangi Bir Tarih", + "search_filters_duration_option_medium": "Orta (4 - 20 Dakika)", "search_filters_features_option_vr180": "VR180", "search_filters_title": "Filtreler", "search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.", - "Popular enabled: ": "Popüler etkin: ", - "error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. Oynatma listesi ana sayfası için buraya tıklayın." + "Popular enabled: ": "Popüler Etkin: ", + "error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. Oynatma listesi ana sayfası için buraya tıklayın.", + "channel_tab_channels_label": "Kanallar", + "channel_tab_shorts_label": "Kısa Çekimler", + "channel_tab_streams_label": "Canlı Yayınlar", + "channel_tab_playlists_label": "Oynatma Listeleri" } diff --git a/locales/uk.json b/locales/uk.json index b6994c56..ae2fb5bd 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -54,7 +54,7 @@ "preferences_continue_label": "Завжди вмикати наступне відео: ", "preferences_continue_autoplay_label": "Автовідтворення наступного відео: ", "preferences_listen_label": "Режим «тільки звук» як усталений: ", - "preferences_local_label": "Програвати відео через проксі? ", + "preferences_local_label": "Відтворення відео через проксі: ", "preferences_speed_label": "Усталена швидкість відео: ", "preferences_quality_label": "Пріорітетна якість відео: ", "preferences_volume_label": "Гучність відео: ", @@ -63,13 +63,13 @@ "reddit": "Reddit", "preferences_captions_label": "Основна мова субтитрів: ", "Fallback captions: ": "Запасна мова субтитрів: ", - "preferences_related_videos_label": "Показувати схожі відео? ", - "preferences_annotations_label": "Завжди показувати анотації? ", + "preferences_related_videos_label": "Показувати схожі відео: ", + "preferences_annotations_label": "Завжди показувати анотації: ", "preferences_category_visual": "Налаштування сайту", "preferences_player_style_label": "Стиль програвача: ", - "Dark mode: ": "Темне оформлення: ", + "Dark mode: ": "Темний режим: ", "preferences_dark_mode_label": "Тема: ", - "dark": "темна", + "dark": "Темна", "light": "Світла", "preferences_thin_mode_label": "Полегшене оформлення: ", "preferences_category_subscription": "Налаштування підписок", @@ -101,11 +101,11 @@ "preferences_category_admin": "Адміністраторські налаштування", "preferences_default_home_label": "Усталена домашня сторінка: ", "preferences_feed_menu_label": "Меню потоку з відео: ", - "Top enabled: ": "Увімкнути топ відео? ", - "CAPTCHA enabled: ": "Увімкнути капчу? ", - "Login enabled: ": "Увімкнути авторизацію? ", - "Registration enabled: ": "Увімкнути реєстрацію? ", - "Report statistics: ": "Повідомляти статистику? ", + "Top enabled: ": "Увімкнути топ відео: ", + "CAPTCHA enabled: ": "Увімкнути CAPTCHA: ", + "Login enabled: ": "Увімкнути вхід: ", + "Registration enabled: ": "Увімкнути реєстрацію: ", + "Report statistics: ": "Повідомляти статистику: ", "Save preferences": "Зберегти налаштування", "Subscription manager": "Менеджер підписок", "Token manager": "Менеджер токенів", @@ -125,7 +125,7 @@ "Private": "Особистий", "View all playlists": "Переглянути всі списки відтворення", "Updated `x` ago": "Оновлено `x` тому", - "Delete playlist `x`?": "Видалити список відтворення \"x\"?", + "Delete playlist `x`?": "Видалити список відтворення `x`?", "Delete playlist": "Видалити список відтворення", "Create playlist": "Створити список відтворення", "Title": "Заголовок", @@ -315,9 +315,9 @@ "`x` marked it with a ❤": "❤ цьому від каналу `x`", "Audio mode": "Аудіорежим", "Video mode": "Відеорежим", - "Videos": "Відео", + "channel_tab_videos_label": "Відео", "Playlists": "Плейлисти", - "Community": "Спільнота", + "channel_tab_community_label": "Спільнота", "Current version: ": "Поточна версія: ", "generic_views_count_0": "{{count}} перегляд", "generic_views_count_1": "{{count}} перегляди", @@ -386,12 +386,12 @@ "Spanish (Mexico)": "Іспанська (Мексика)", "Spanish (Spain)": "Іспанська (Іспанія)", "next_steps_error_message_go_to_youtube": "Перейти до YouTube", - "footer_donate_page": "Пожертвувати", + "footer_donate_page": "Підтримати", "footer_documentation": "Документація", - "footer_source_code": "Вихідний код", - "footer_original_source_code": "Оригінал вихідного коду", - "footer_modfied_source_code": "Змінений вихідний код", - "adminprefs_modified_source_code_url_label": "URL-адреса репозиторію зміненого вихідного коду", + "footer_source_code": "Джерельний код", + "footer_original_source_code": "Оригінал джерельного коду", + "footer_modfied_source_code": "Змінений джерельний код", + "adminprefs_modified_source_code_url_label": "URL-адреса репозиторію зміненого джерельного коду", "none": "нема", "videoinfo_started_streaming_x_ago": "Трансляцію розпочато `x` тому", "crash_page_you_found_a_bug": "Схоже, ви знайшли ваду в Invidious!", @@ -408,7 +408,7 @@ "next_steps_error_message": "Після чого спробуйте: ", "next_steps_error_message_refresh": "Оновити сторінку", "Search": "Пошук", - "preferences_extend_desc_label": "Автоматично розширювати опис відео: ", + "preferences_extend_desc_label": "Автоматично розгортати опис відео: ", "preferences_category_misc": "Різноманітні параметри", "Show less": "Коротше", "preferences_quality_option_small": "Низька", @@ -488,5 +488,9 @@ "search_filters_sort_option_rating": "Рейтингові", "search_filters_sort_option_views": "Популярні", "Popular enabled: ": "Популярне ввімкнено: ", - "error_video_not_in_playlist": "Запитуваного відео в цьому списку відтворення не існує. Клацніть тут, щоб переглянути домашню сторінку списку відтворення." + "error_video_not_in_playlist": "Запитуваного відео в цьому списку відтворення не існує. Клацніть тут, щоб переглянути домашню сторінку списку відтворення.", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Прямі трансляції", + "channel_tab_playlists_label": "Добірки", + "channel_tab_channels_label": "Канали" } diff --git a/locales/vi.json b/locales/vi.json index 07fcf52f..3f7125c4 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -311,9 +311,9 @@ "`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤", "Audio mode": "Chế độ âm thanh", "Video mode": "Chế độ quay", - "Videos": "Video", + "channel_tab_videos_label": "Video", "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_rating": "Xếp hạng", "search_filters_sort_option_date": "ngày", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 7e749dc9..385f16bd 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` 为此加 ❤", "Audio mode": "音频模式", "Video mode": "视频模式", - "Videos": "视频", + "channel_tab_videos_label": "视频", "Playlists": "播放列表", - "Community": "社区", + "channel_tab_community_label": "社区", "search_filters_sort_option_relevance": "相关度", "search_filters_sort_option_rating": "评分", "search_filters_sort_option_date": "上传日期", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 54933701..3b51721d 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -341,9 +341,9 @@ "`x` marked it with a ❤": "`x` 為此標記 ❤", "Audio mode": "音訊模式", "Video mode": "視訊模式", - "Videos": "影片", + "channel_tab_videos_label": "影片", "Playlists": "播放清單", - "Community": "社群", + "channel_tab_community_label": "社群", "search_filters_sort_option_relevance": "關聯", "search_filters_sort_option_rating": "評分", "search_filters_sort_option_date": "日期", @@ -456,5 +456,9 @@ "search_filters_type_option_all": "任何類型", "search_filters_date_option_none": "任何日期", "Popular enabled: ": "已啟用人氣: ", - "error_video_not_in_playlist": "此播放清單不存在請求的影片。點擊此處檢視播放清單首頁。" + "error_video_not_in_playlist": "此播放清單不存在請求的影片。點擊此處檢視播放清單首頁。", + "channel_tab_shorts_label": "短片", + "channel_tab_playlists_label": "播放清單", + "channel_tab_channels_label": "頻道", + "channel_tab_streams_label": "直播" } diff --git a/scripts/deploy-database.sh b/scripts/deploy-database.sh old mode 100644 new mode 100755 diff --git a/scripts/fetch-player-dependencies.cr b/scripts/fetch-player-dependencies.cr old mode 100644 new mode 100755 index ed658b51..813e4ce4 --- a/scripts/fetch-player-dependencies.cr +++ b/scripts/fetch-player-dependencies.cr @@ -129,7 +129,7 @@ dependencies_to_install.each do |dep| dep = "videojs.markers" if dep == "videojs-markers" 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` else `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` diff --git a/scripts/install-dependencies.sh b/scripts/install-dependencies.sh old mode 100644 new mode 100755 diff --git a/shard.lock b/shard.lock index cdce1160..235e4c25 100644 --- a/shard.lock +++ b/shard.lock @@ -34,7 +34,7 @@ shards: protodec: git: https://github.com/iv-org/protodec.git - version: 0.1.4 + version: 0.1.5 radix: git: https://github.com/luislavena/radix.git diff --git a/shard.yml b/shard.yml index 9c9b0d37..7ee0bb2a 100644 --- a/shard.yml +++ b/shard.yml @@ -24,7 +24,7 @@ dependencies: version: ~> 0.6.1 protodec: github: iv-org/protodec - version: ~> 0.1.4 + version: ~> 0.1.5 lsquic: github: iv-org/lsquic.cr version: ~> 2.18.1-2 diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/hashtag_spec.cr index 77676878..266ec57b 100644 --- a/spec/invidious/hashtag_spec.cr +++ b/spec/invidious/hashtag_spec.cr @@ -4,7 +4,7 @@ Spectator.describe Invidious::Hashtag do it "parses richItemRenderer containers (test 1)" do # Enable mock 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(videos.size).to eq(60) @@ -57,7 +57,7 @@ Spectator.describe Invidious::Hashtag do it "parses richItemRenderer containers (test 2)" do # Enable mock 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(videos.size).to eq(60) diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index ab361770..f81cd29a 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -23,12 +23,6 @@ Spectator.describe "Helper" do 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 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") diff --git a/src/invidious.cr b/src/invidious.cr index 2874cc71..d4f8e0fb 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -34,6 +34,7 @@ require "protodec/utils" require "./invidious/database/*" require "./invidious/database/migrations/*" +require "./invidious/http_server/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" @@ -48,6 +49,13 @@ require "./invidious/search/*" require "./invidious/routes/**" 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 HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) @@ -172,7 +180,7 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) 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::ClearExpiredItemsJob.new diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 4c442959..0054f8f2 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -16,12 +16,6 @@ record AboutChannel, tabs : Array(String), verified : Bool -record AboutRelatedChannel, - ucid : String, - author : String, - author_url : String, - author_thumbnail : String - def get_about_info(ucid, locale) : AboutChannel begin # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} @@ -100,34 +94,46 @@ def get_about_info(ucid, locale) : AboutChannel total_views = 0_i64 joined = Time.unix(0) - tabs = [] of String + tab_names = [] of String - tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? - if !tabs_json.nil? - # Retrieve information from the tabs array. The index we are looking for varies between channels. - tabs_json.each do |node| - # 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 tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? + # Get the name of the tabs available on this channel + tab_names = tabs_json.as_a.compact_map do |entry| + name = entry.dig?("tabRenderer", "title").try &.as_s.downcase - if !channel_about_meta.nil? - total_views = channel_about_meta["viewCountText"]?.try &.["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 = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, nd| acc + nd["text"].as_s } - .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"] - if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && - (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" - auto_generated = true - end - end + # This is a small fix to not add extra code on the HTML side + # I.e, the URL for the "live" tab is .../streams, so use "streams" + # everywhere for the sake of simplicity + (name == "live") ? "streams" : name + end + + # Get the currently active tab ("About") + about_tab = extract_selected_tab(tabs_json) + + # Try to find the about metadata section + channel_about_meta = about_tab.dig?( + "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "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 - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end sub_count = initdata @@ -148,46 +154,20 @@ def get_about_info(ucid, locale) : AboutChannel joined: joined, is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, - tabs: tabs, + tabs: tab_names, verified: author_verified || false, ) end -def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedChannel) - # params is {"2:string":"channels"} encoded - channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") - - tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any - tab = tabs.find(&.dig?("tabRenderer", "title").try(&.as_s?).try(&.== "Channels")) - - 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, - ) +def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?} + if continuation.nil? + # params is {"2:string":"channels"} encoded + initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") + else + initial_data = YoutubeAPI.browse(continuation) end - return related + items, continuation = extract_items(initial_data) + + return items.select(SearchChannel), continuation end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e3d3d9ee..63dd2194 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -180,11 +180,16 @@ def fetch_channel(ucid, pull_all_videos : Bool) 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") - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) + videos, continuation = IV::Channel::Tabs.get_videos(channel) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") 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 ||= 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 ||= 0 @@ -228,58 +235,56 @@ def fetch_channel(ucid, pull_all_videos : Bool) if was_insert 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 LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end end if pull_all_videos - page += 1 - - ids = [] of String - loop do - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) + # Keep fetching videos using the continuation token retrieved earlier + videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation) - count = videos.size - videos = videos.map { |video| ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) } - - videos.each do |video| - ids << video.id + count = 0 + videos.select(SearchVideo).each do |video| + count += 1 + video = ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) # 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. if Time.utc - video.published > 1.minute 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 break if count < 25 - page += 1 + sleep 500.milliseconds end end - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) - + channel.updated = Time.utc return channel end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 7cedcdae..fef55af4 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -69,7 +69,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) next if !post content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]?.try &.["simpleText"]? || "" + author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || "" json.object do json.field "author", author @@ -189,6 +189,32 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) # when .has_key?("pollRenderer") # attachment = attachment["pollRenderer"] # json.field "type", "poll" + when .has_key?("postMultiImageRenderer") + attachment = attachment["postMultiImageRenderer"] + json.field "type", "multiImage" + json.field "images" do + json.array do + attachment["images"].as_a.each do |image| + json.array do + thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + qualities = {320, 560, 640, 1280, 2000} + + qualities.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end else json.field "type", "unknown" json.field "error", "Unrecognized attachment type." diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index a58642ba..8dc824b2 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -1,93 +1,28 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation - response_json = 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 + initial_data = YoutubeAPI.browse(continuation) 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 - 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":"(?[^"]+)"/).try &.["continuation"]? + initial_data = YoutubeAPI.browse(ucid, params: params || "") end - return items, continuation -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=JP&hl=en" + return extract_items(initial_data, author, ucid) end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index c05ab446..7180c24d 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -16,6 +16,14 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so .try { |i| Base64.urlsafe_encode(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 = { "110: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, "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 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 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) return "/browse_ajax?continuation=#{continuation}&gl=JP&hl=en" 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 diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 39bca8c7..1c3ecf39 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -110,6 +110,8 @@ class Config property hsts : Bool? = true # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' 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 # Will display in the footer, next to the main source code link diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index f62b43ea..0a4a4fd8 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -154,6 +154,16 @@ module Invidious::Database::Users # 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) request = <<-SQL UPDATE users diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 425c08da..690db907 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -33,3 +33,8 @@ end class VideoNotAvailableException < Exception 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 diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr new file mode 100644 index 00000000..53745dd5 --- /dev/null +++ b/src/invidious/frontend/channel_page.cr @@ -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 << %(
\n) + + if tab == selected_tab + str << "\t" + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "\n" + else + # Video tab doesn't have the last path component + url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" + + str << %(\t) + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "\n" + end + + str << "
" + end + end + end + end +end diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr index afe31a36..bc329205 100644 --- a/src/invidious/hashtag.cr +++ b/src/invidious/hashtag.cr @@ -8,7 +8,8 @@ module Invidious::Hashtag client_config = YoutubeAPI::ClientConfig.new(region: region) response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) - return extract_items(response) + items, _ = extract_items(response) + return items end def generate_continuation(hashtag : String, cursor : Int) diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr index b8e8f96d..3f4080ba 100644 --- a/src/invidious/helpers/json_filter.cr +++ b/src/invidious/helpers/json_filter.cr @@ -20,7 +20,7 @@ module JSONFilter /^\(|\(\(|\/\(/ end - def self.parse_fields(fields_text : String) : Nil + def self.parse_fields(fields_text : String, &) : Nil if fields_text.empty? raise FieldsParser::ParseError.new "Fields is empty" end @@ -42,7 +42,7 @@ module JSONFilter parse_nest_groups(fields_text) { |nest_list| yield nest_list } 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) if !single_nests.empty? @@ -60,7 +60,7 @@ module JSONFilter 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) bracket_pairs = get_bracket_pairs(fields_text, true) diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index c52e2a0d..635f0984 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -265,4 +265,11 @@ class Category end end +struct Continuation + getter token + + def initialize(@token : String) + end +end + alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ed0cca38..500a2582 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -162,7 +162,7 @@ def number_with_separator(number) end def short_text_to_number(short_text : String) : Int64 - matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB])?/.match(short_text) + matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB]?)/.match(short_text) number = matches.try &.["number"].to_f || 0.0 case matches.try &.["suffix"].downcase @@ -259,7 +259,7 @@ def get_referer(env, fallback = "/", unroll = true) end referer = referer.request_target - referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\") + referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,0-9a-zA-Z]/, "").lstrip("/\\") if referer == env.request.path referer = fallback diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr new file mode 100644 index 00000000..e3f1fa0f --- /dev/null +++ b/src/invidious/http_server/utils.cr @@ -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 diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr index 2f525e08..b445107b 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,12 +1,12 @@ 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 def initialize(@connection_channel, @pg_url) end def begin - connections = [] of Channel(PQ::Notification) + connections = [] of ::Channel(PQ::Notification) PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 92681408..80812a63 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -8,7 +8,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob max_fibers = CONFIG.channel_threads lim_fibers = max_fibers active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new backoff = 2.minutes loop do diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 4b52c959..4f8130df 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -7,7 +7,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob def begin max_fibers = CONFIG.feed_threads active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index a431a48a..8584fb9c 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -12,7 +12,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob end active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 642789aa..a2b1a35c 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -3,7 +3,7 @@ require "json" module Invidious::JSONify::APIv1 extend self - def video(video : Video, json : JSON::Builder, *, locale : String?) + def video(video : Video, json : JSON::Builder, *, locale : String?, proxy : Bool = false) json.object do json.field "type", video.video_type @@ -89,7 +89,14 @@ module Invidious::JSONify::APIv1 # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - json.field "url", fmt["url"] + 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" diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9bb73136..e6a70ed2 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -203,7 +203,7 @@ module Invidious::Routes::Account referer = get_referer(env) if !user - return env.redirect referer + return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" end user = user.as(User) diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index ae65f10d..662d1002 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -29,7 +29,7 @@ module Invidious::Routes::API::Manifest if local uri = URI.parse(url) - url = "#{uri.request_target}host/#{uri.host}/" + url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/" end "#{url}" @@ -42,7 +42,7 @@ module Invidious::Routes::API::Manifest if local 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 diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 6b81c546..ca2b2734 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,13 +1,7 @@ module Invidious::Routes::API::V1::Channels - def self.home(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - + # Macro to avoid duplicating some code below + # This sets the `channel` variable, or handles Exceptions. + private macro get_channel begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect @@ -18,17 +12,25 @@ module Invidious::Routes::API::V1::Channels rescue ex return error_json(500, ex) end + end - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end + def self.home(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 "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 JSON.build do |json| @@ -100,31 +102,13 @@ module Invidious::Routes::API::V1::Channels json.array do # Fetch related channels begin - related_channels = fetch_related_channels(channel) + related_channels, _ = fetch_related_channels(channel) rescue ex - related_channels = [] of AboutRelatedChannel + related_channels = [] of SearchChannel end related_channels.each do |related_channel| - json.object do - 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 + related_channel.to_json(locale, json) end end end # relatedChannels @@ -134,61 +118,112 @@ module Invidious::Routes::API::V1::Channels end 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" - - 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 + return self.videos(env) end def self.videos(env) locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] env.response.content_type = "application/json" - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve some URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - 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) + videos, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) rescue ex return error_json(500, ex) end - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) + 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.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 @@ -204,16 +239,9 @@ module Invidious::Routes::API::V1::Channels env.params.query["sort_by"]?.try &.downcase || "last" - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - 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 + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) @@ -255,6 +283,37 @@ module Invidious::Routes::API::V1::Channels 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) locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index a6b2eb4e..f312211e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -6,6 +6,7 @@ module Invidious::Routes::API::V1::Videos id = env.params.url["id"] region = env.params.query["region"]? + proxy = {"1", "true"}.any? &.== env.params.query["local"]? begin video = get_video(id, region: region) @@ -15,7 +16,9 @@ module Invidious::Routes::API::V1::Videos return error_json(500, ex) end - video.to_json(locale, nil) + return JSON.build do |json| + Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) + end end def self.captions(env) @@ -90,45 +93,50 @@ module Invidious::Routes::API::V1::Videos # as well as some other markup that makes it cumbersome, so we try to fix that here if caption.name.includes? "auto-generated" caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.language_code} + if caption_xml.starts_with?(" i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE end end else @@ -138,7 +146,12 @@ module Invidious::Routes::API::V1::Videos # # See: https://github.com/iv-org/invidious/issues/2391 webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") + if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + end end if title = env.params.query["title"]? diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index c6e02cbd..d3969d29 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -7,21 +7,19 @@ module Invidious::Routes::Channels def self.videos(env) data = self.fetch_basic_information(env) - if !data.is_a?(Tuple) - return data - end - locale, user, subscriptions, continuation, ucid, channel = data + return data if !data.is_a?(Tuple) - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + locale, user, subscriptions, continuation, ucid, channel = data sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated 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| if item.responds_to?(:title) item.title @@ -33,34 +31,85 @@ module Invidious::Routes::Channels items.each(&.author = "") else 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 + 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" end def self.playlists(env) data = self.fetch_basic_information(env) - if !data.is_a?(Tuple) - return data - end + return data if !data.is_a?(Tuple) + locale, user, subscriptions, continuation, ucid, channel = data sort_options = {"last", "oldest", "newest"} sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "last" if channel.auto_generated return env.redirect "/channel/#{channel.ucid}" 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.each(&.author = "") - templated "playlists" + selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists + templated "channel" end def self.community(env) @@ -74,12 +123,15 @@ module Invidious::Routes::Channels thin_mode = thin_mode == "true" continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase if !channel.tabs.includes? "community" return env.redirect "/channel/#{channel.ucid}" end + # TODO: support sort options for community posts + sort_by = "" + sort_options = [] of String + begin items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) rescue ex : InfoException @@ -95,6 +147,26 @@ module Invidious::Routes::Channels templated "community" 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) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) @@ -125,7 +197,7 @@ module Invidious::Routes::Channels end 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}" else url = "/channel/#{ucid}" diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 289d87c9..266f7ba4 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -147,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) # end - if notifications && notifications.includes? id + if CONFIG.enable_user_notifications && notifications && notifications.includes? id Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b601db94..fb482e33 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -96,12 +96,14 @@ module Invidious::Routes::Feeds videos, notifications = get_subscription_feed(user, max_results, page) - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. - Invidious::Database::Users.clear_notifications(user) - user.notifications = [] of String + if CONFIG.enable_user_notifications + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + Invidious::Database::Users.clear_notifications(user) + user.notifications = [] of String + end env.set "user", user templated "feeds/subscriptions" @@ -404,13 +406,15 @@ module Invidious::Routes::Feeds video = get_video(id, force_refresh: true) - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => video.ucid, - "videoId" => video.id, - "published" => published.to_unix, - }.to_json - PG_DB.exec("NOTIFY notifications, E'#{payload}'") + if CONFIG.enable_user_notifications + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + end video = ChannelVideo.new({ id: id, @@ -426,7 +430,13 @@ module Invidious::Routes::Feeds }) 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 diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index c0570522..caaff5b9 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -6,14 +6,14 @@ module Invidious::Routes::Login user = env.get? "user" - return env.redirect "/feed/subscriptions" if user + referer = get_referer(env, "/feed/subscriptions") + + return env.redirect referer if user if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end - referer = get_referer(env, "/feed/subscriptions") - email = nil password = nil captcha = nil diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 560f9c19..1e932d11 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -35,6 +35,13 @@ module Invidious::Routes::VideoPlayback 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) response = HTTP::Client::Response.new(500) error = "" @@ -70,6 +77,9 @@ module Invidious::Routes::VideoPlayback end end + # Remove the Range header added previously. + headers.delete("Range") if range_header.nil? + if response.status_code >= 400 env.response.content_type = "text/plain" haltf env, response.status_code @@ -91,14 +101,8 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}" - - if region - location += "®ion=#{region}" - end - - return env.redirect location + url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) + return env.redirect url end IO.copy(resp.body_io, env.response) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 5f481557..5d3845c3 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -80,7 +80,7 @@ module Invidious::Routes::Watch Invidious::Database::Users.mark_watched(user.as(User), id) end - if notifications && notifications.includes? id + if CONFIG.enable_user_notifications && notifications && notifications.includes? id Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index f409f13c..157e6de7 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -37,7 +37,9 @@ module Invidious::Routing get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get 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 %} self.register_image_routes @@ -115,18 +117,23 @@ module Invidious::Routing get "/channel/:ucid", Routes::Channels, :home get "/channel/:ucid/home", Routes::Channels, :home 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/community", Routes::Channels, :community + get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/about", Routes::Channels, :about get "/channel/:ucid/live", Routes::Channels, :live get "/user/: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 get "/c/:user#{path}", Routes::Channels, :brand_redirect # /user/linustechtips | Not always the same as /c/ get "/user/:user#{path}", Routes::Channels, :brand_redirect + # /@LinusTechTips | Handle + get "/@:user#{path}", Routes::Channels, :brand_redirect # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow get "/attribution_link#{path}", Routes::Channels, :brand_redirect # /profile?user=linustechtips @@ -220,6 +227,10 @@ module Invidious::Routing # Channels 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"} %} get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} @@ -260,8 +271,10 @@ module Invidious::Routing post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token - get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + if CONFIG.enable_user_notifications + get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + end # Misc get "/api/v1/stats", {{namespace}}::Misc, :stats diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index d1409c06..7e909590 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -9,7 +9,8 @@ module Invidious::Search client_config = YoutubeAPI::ClientConfig.new(region: query.region) 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 # Search a youtube channel @@ -30,16 +31,7 @@ module Invidious::Search continuation = produce_channel_search_continuation(ucid, query.text, query.page) response_json = YoutubeAPI.browse(continuation) - continuation_items = response_json["onResponseReceivedActions"]? - .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 - + items, _ = extract_items(response_json, "", ucid) return items end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d626c7d1..436ac82d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -247,6 +247,12 @@ struct Video info["reason"]?.try &.as_s end + def music : Array(VideoMusic) + info["music"].as_a.map { |music_json| + VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + } + end + # Macros defining getters/setters for various types of data private macro getset_string(name) diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 4642c1a7..13f81a31 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -31,6 +31,72 @@ module Invidious::Videos return captions_list end + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + # In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") + cues << cue + end + end + break + end + end + result = String.build do |result| + result << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || @language_code} + + + END_VTT + + result << "\n\n" + + cues.each_with_index do |node, i| + start_time = node["t"].to_f.milliseconds + + duration = node["d"]?.try &.to_f.milliseconds + + duration ||= start_time + + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end + + # start_time + result << start_time.hours.to_s.rjust(2, '0') + result << ':' << start_time.minutes.to_s.rjust(2, '0') + result << ':' << start_time.seconds.to_s.rjust(2, '0') + result << '.' << start_time.milliseconds.to_s.rjust(3, '0') + + result << " --> " + + # end_time + result << end_time.hours.to_s.rjust(2, '0') + result << ':' << end_time.minutes.to_s.rjust(2, '0') + result << ':' << end_time.seconds.to_s.rjust(2, '0') + result << '.' << end_time.milliseconds.to_s.rjust(3, '0') + + result << "\n" + + node.children.each do |s| + result << s.content + end + result << "\n" + result << "\n" + end + end + return result + end + # List of all caption languages available on Youtube. LANGUAGES = { "", diff --git a/src/invidious/videos/music.cr b/src/invidious/videos/music.cr new file mode 100644 index 00000000..402ae46f --- /dev/null +++ b/src/invidious/videos/music.cr @@ -0,0 +1,12 @@ +require "json" + +struct VideoMusic + include JSON::Serializable + + property album : String + property artist : String + property license : String + + def initialize(@album : String, @artist : String, @license : String) + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 5df49286..cf43f1be 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -66,8 +66,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) 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 - if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) + # 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), @@ -309,6 +311,33 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any end end + # Music section + + music_list = [] of VideoMusic + music_desclist = player_response.dig?( + "engagementPanels", 1, "engagementPanelSectionListRenderer", + "content", "structuredDescriptionContentRenderer", "items", 2, + "videoDescriptionMusicSectionRenderer", "carouselLockups" + ) + + music_desclist.try &.as_a.each do |music_desc| + artist = nil + album = nil + music_license = nil + + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| + desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) + if desc_title == "ARTIST" + artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "ALBUM" + album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "LICENSES" + music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + end + end + music_list << VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) + end + # Author infos author = video_details["author"]?.try &.as_s @@ -359,6 +388,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "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 || ""), + # Music section + "music" => JSON.parse(music_list.to_json), # Author infos "author" => JSON::Any.new(author || ""), "ucid" => JSON::Any.new(ucid || ""), diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index dea86abe..a29315ef 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,8 +1,24 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> -<% channel_profile_pic = URI.parse(channel.author_thumbnail).request_target %> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target + + relative_url = + case selected_tab + when .shorts? then "/channel/#{ucid}/shorts" + when .streams? then "/channel/#{ucid}/streams" + when .playlists? then "/channel/#{ucid}/playlists" + when .channels? then "/channel/#{ucid}/channels" + else + "/channel/#{ucid}" + end + + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) +-%> <% content_for "header" do %> +<%- if selected_tab.videos? -%> @@ -14,91 +30,14 @@ - -<%= author %> - Invidious +<%- end -%> + + +<%= author %> - Invidious <% end %> -<% if channel.banner %> -
- "> -
- -
-
-
-<% end %> - -
-
-
- - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
-
-
-

- -

-
-
- -
-
-

<%= channel.description_html %>

-
-
- -
- <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
- -
-
- <%= translate(locale, "View channel on YouTube") %> -
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
- <% if !channel.auto_generated %> -
- <%= translate(locale, "Videos") %> -
- <% end %> -
- <% if channel.auto_generated %> - <%= translate(locale, "Playlists") %> - <% else %> - <%= translate(locale, "Playlists") %> - <% end %> -
-
- <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
-
-
-
-
- <% sort_options.each do |sort| %> -
- <% if sort_by == sort %> - <%= translate(locale, sort) %> - <% else %> - - <%= translate(locale, sort) %> - - <% end %> -
- <% end %> -
-
-
+<%= rendered "components/channel_info" %>

@@ -111,17 +50,10 @@
- -
+
- <% if count == 60 %> - &sort_by=<%= URI.encode_www_form(sort_by) %><% end %>"> + <% if next_continuation %> + <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3bc29e55..9e11d562 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,71 +1,21 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target + + relative_url = "/channel/#{ucid}/community" + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) + + selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community +-%> <% content_for "header" do %> + <%= author %> - Invidious <% end %> -<% if channel.banner %> -
- "> -
- -
-
-
-<% end %> - -
-
-
- - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
-
-
-

- -

-
-
- -
-
-

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %>

-
-
- -
- <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
- -
-
- <%= translate(locale, "View channel on YouTube") %> -
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
- <% if !channel.auto_generated %> - - <% end %> - -
- <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
-
-
-
+<%= rendered "components/channel_info" %>

diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr new file mode 100644 index 00000000..f216359f --- /dev/null +++ b/src/invidious/views/components/channel_info.ecr @@ -0,0 +1,60 @@ +<% if channel.banner %> +
+ "> +
+ +
+
+
+<% end %> + +
+
+
+ + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> +
+
+
+

+ +

+
+
+ +
+
+

<%= channel.description_html %>

+
+
+ +
+ <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
+ +
+
+ + + + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> +
+
+
+ <% sort_options.each do |sort| %> +
+ <% if sort_by == sort %> + <%= translate(locale, sort) %> + <% else %> + <%= translate(locale, sort) %> + <% end %> +
+ <% end %> +
+
+
diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 8d56ad14..76f2f2bd 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -23,6 +23,8 @@
+<% if CONFIG.enable_user_notifications %> +
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
@@ -39,6 +41,8 @@ <% end %>
+<% end %> +

diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr deleted file mode 100644 index c8718e7b..00000000 --- a/src/invidious/views/playlists.ecr +++ /dev/null @@ -1,108 +0,0 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> - -<% content_for "header" do %> -<%= author %> - Invidious -<% end %> - -<% if channel.banner %> -
- "> -
- -
-
-
-<% end %> - -
-
-
- - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
-
-
-

- -

-
-
- -
-
-

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %>

-
-
- -
- <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
- -
-
- - -
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
- - -
- <% if !channel.auto_generated %> - <%= translate(locale, "Playlists") %> - <% end %> -
-
- <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
-
-
-
-
- <% {"last", "oldest", "newest"}.each do |sort| %> -
- <% if sort_by == sort %> - <%= translate(locale, sort) %> - <% else %> - - <%= translate(locale, sort) %> - - <% end %> -
- <% end %> -
-
-
- -
-
-
- -
-<% items.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- - diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 8ad75358..3bfb2be6 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -55,7 +55,7 @@
" href="/feed/subscriptions" class="pure-menu-heading"> <% notification_count = env.get("user").as(Invidious::User).notifications.size %> - <% if notification_count > 0 %> + <% if CONFIG.enable_user_notifications && notification_count > 0 %> <%= notification_count %> <% else %> @@ -175,7 +175,9 @@ }.to_pretty_json %> + <% if CONFIG.enable_user_notifications %> + <% end %> <% end %> diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr index 74ccc06c..a451159f 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -14,7 +14,7 @@
+ <%= translate(locale, "Import YouTube subscriptions") %> diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index d841982c..dfda1434 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -244,6 +244,7 @@ checked<% end %>>
+ <% if CONFIG.enable_user_notifications %>
checked<% end %>> @@ -255,6 +256,7 @@ <%= translate(locale, "Enable web notifications") %>
<% end %> + <% end %> <% end %> <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index a6f2e524..666eb3b0 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -235,6 +235,28 @@ we're going to need to do it here in order to allow for translations.
+ <% if !video.music.empty? %> + + + +
+ <% video.music.each do |music| %> +
+

<%= translate(locale, "Artist: ") %><%= music.artist %>

+

<%= translate(locale, "Album: ") %><%= music.album %>

+

<%= translate(locale, "License: ") %><%= music.license %>

+
+ <% end %> +
+
+ + <% end %>
<% if nojs %> <%= comment_html %> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index edc722cf..b14ad7b9 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -7,7 +7,7 @@ require "../helpers/serialized_yt_data" private ITEM_CONTAINER_EXTRACTOR = { Extractors::YouTubeTabs, Extractors::SearchResults, - Extractors::Continuation, + Extractors::ContinuationContent, } private ITEM_PARSERS = { @@ -18,8 +18,11 @@ private ITEM_PARSERS = { Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, + Parsers::ContinuationItemRendererParser, } +private alias InitialData = Hash(String, JSON::Any) + record AuthorFallback, name : String, id : String # Namespace for logic relating to parsing InnerTube data into various datastructs. @@ -169,7 +172,17 @@ private module Parsers # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText # TODO change default value to nil + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + + # Since youtube added channel handles, `VideoCountText` holds the number of + # subscribers and `subscriberCountText` holds the handle, except when the + # channel doesn't have a handle (e.g: some topic music channels). + # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 + if !subscriber_count || !subscriber_count.as_s.includes? " subscriber" + subscriber_count = item_contents.dig?("videoCountText", "simpleText") + end + subscriber_count = subscriber_count .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText @@ -345,14 +358,9 @@ private module Parsers content_container = item_contents["contents"] end - raw_contents = content_container["items"]?.try &.as_a - if !raw_contents.nil? - raw_contents.each do |item| - result = extract_item(item) - if !result.nil? - contents << result - end - end + content_container["items"]?.try &.as_a.each do |item| + result = parse_item(item, author_fallback.name, author_fallback.id) + contents << result if result.is_a?(SearchItem) end Category.new({ @@ -384,7 +392,9 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - return VideoRendererParser.process(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + child ||= ReelItemRendererParser.process(item_contents, author_fallback) + return child end def self.parser_name @@ -408,9 +418,19 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - video_details_container = item_contents.dig( + reel_player_overlay = item_contents.dig( "navigationEndpoint", "reelWatchEndpoint", - "overlay", "reelPlayerOverlayRenderer", + "overlay", "reelPlayerOverlayRenderer" + ) + + # Sometimes, the "reelPlayerOverlayRenderer" object is missing the + # important part of the response. We use this exception to tell + # the calling function to fetch the content again. + if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers") + raise RetryOnceException.new + end + + video_details_container = reel_player_overlay.dig( "reelPlayerHeaderSupportedRenderers", "reelPlayerHeaderRenderer" ) @@ -436,9 +456,9 @@ private module Parsers # View count - view_count_text = video_details_container.dig?("viewCountText", "simpleText") - view_count_text ||= video_details_container - .dig?("viewCountText", "accessibility", "accessibilityData", "label") + # View count used to be in the reelWatchEndpoint, but that changed? + view_count_text = item_contents.dig?("viewCountText", "simpleText") + view_count_text ||= video_details_container.dig?("viewCountText", "simpleText") view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 @@ -450,8 +470,8 @@ private module Parsers regex_match = /- (?\d+ minutes? )?(?\d+ seconds?)+ -/.match(a11y_data) - minutes = regex_match.try &.["min"].to_i(strict: false) || 0 - seconds = regex_match.try &.["sec"].to_i(strict: false) || 0 + minutes = regex_match.try &.["min"]?.try &.to_i(strict: false) || 0 + seconds = regex_match.try &.["sec"]?.try &.to_i(strict: false) || 0 duration = (minutes*60 + seconds) @@ -475,6 +495,35 @@ private module Parsers return {{@type.name}} end end + + # Parses an InnerTube continuationItemRenderer into a Continuation. + # Returns nil when the given object isn't a continuationItemRenderer. + # + # continuationItemRenderer contains various metadata ued to load more + # content (i.e when the user scrolls down). The interesting bit is the + # protobuf object known as the "continutation token". Previously, those + # were generated from sratch, but recent (as of 11/2022) Youtube changes + # are forcing us to extract them from replies. + # + module ContinuationItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["continuationItemRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + token = item_contents + .dig?("continuationEndpoint", "continuationCommand", "token") + .try &.as_s + + return Continuation.new(token) if token + end + + def self.parser_name + return {{@type.name}} + end + end end # The following are the extractors for extracting an array of items from @@ -510,7 +559,7 @@ private module Extractors # }] # module YouTubeTabs - def self.process(initial_data : Hash(String, JSON::Any)) + def self.process(initial_data : InitialData) if target = initial_data["twoColumnBrowseResultsRenderer"]? self.extract(target) end @@ -575,7 +624,7 @@ private module Extractors # } # module SearchResults - def self.process(initial_data : Hash(String, JSON::Any)) + def self.process(initial_data : InitialData) if target = initial_data["twoColumnSearchResultsRenderer"]? self.extract(target) end @@ -608,8 +657,8 @@ private module Extractors # The way they are structured is too varied to be accurately written down here. # However, they all eventually lead to an array of parsable items after traversing # through the JSON structure. - module Continuation - def self.process(initial_data : Hash(String, JSON::Any)) + module ContinuationContent + def self.process(initial_data : InitialData) if target = initial_data["continuationContents"]? self.extract(target) elsif target = initial_data["appendContinuationItemsAction"]? @@ -643,7 +692,11 @@ module HelperExtractors # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 if box = container["videoCountText"]? - return extract_text(box).try &.gsub(/\D/, "").to_i || 0 + if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" + return extracted_text.gsub(/\D/, "").to_i + else + return 0 + end elsif box = container["videoCount"]? return box.as_s.to_i else @@ -691,8 +744,7 @@ end # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. -def extract_item(item : JSON::Any, author_fallback : String? = "", - author_id_fallback : String? = "") +def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallback : String? = "") # We "allow" nil values but secretly use empty strings instead. This is to save us the # hassle of modifying every author_fallback and author_id_fallback arg usage # which is more often than not nil. @@ -702,24 +754,23 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Each parser automatically validates the data given to see if the data is # applicable to itself. If not nil is returned and the next parser is attempted. ITEM_PARSERS.each do |parser| - LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + LOGGER.trace("parse_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") if result = parser.process(item, author_fallback) - LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") - + LOGGER.debug("parse_item: Successfully parsed via #{parser.parser_name}") return result else - LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") + LOGGER.trace("parse_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") end end end # Parses multiple items from YouTube's initial JSON response into a more usable structure. # The end result is an array of SearchItem. -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, - author_id_fallback : String? = nil) : Array(SearchItem) - items = [] of SearchItem - +# +# This function yields the container so that items can be parsed separately. +# +def extract_items(initial_data : InitialData, &block) if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h @@ -727,24 +778,37 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri unpackaged_data = initial_data end - # This is identical to the parser cycling of extract_item(). + # This is identical to the parser cycling of parse_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") if container = extractor.process(unpackaged_data) LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") # Extract items in container - container.each do |item| - if parsed_result = extract_item(item, author_fallback, author_id_fallback) - items << parsed_result - end - end - - break + container.each { |item| yield item } else LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") end end - - return items +end + +# Wrapper using the block function above +def extract_items( + initial_data : InitialData, + author_fallback : String? = nil, + author_id_fallback : String? = nil +) : {Array(SearchItem), String?} + items = [] of SearchItem + continuation = nil + + extract_items(initial_data) do |item| + parsed = parse_item(item, author_fallback, author_id_fallback) + + case parsed + when .is_a?(Continuation) then continuation = parsed.token + when .is_a?(SearchItem) then items << parsed + end + end + + return items, continuation end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index f8245160..0cb3c079 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,10 +68,10 @@ rescue ex return false end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extracted = extract_items(initial_data, author_fallback, author_id_fallback) +def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo) + extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback) - target = [] of SearchItem + target = [] of (SearchItem | Continuation) extracted.each do |i| if i.is_a?(Category) i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } @@ -79,28 +79,11 @@ def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : Str target << i end end - return target.select(SearchVideo).map(&.as(SearchVideo)) + + return target.select(SearchVideo) end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] end - -def fetch_continuation_token(items : Array(JSON::Any)) - # Fetches the continuation token from an array of items - return items.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s -end - -def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) - # Fetches the continuation token from initial data - if initial_data["onResponseReceivedActions"]? - continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] - else - tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) - continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] - end - - return fetch_continuation_token(continuation_items.as_a) -end