summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assets/css/default.css4
-rw-r--r--config/config.example.yml11
-rw-r--r--docker/Dockerfile3
-rw-r--r--docker/Dockerfile.arm643
-rw-r--r--kubernetes/Chart.lock6
-rw-r--r--kubernetes/Chart.yaml2
-rw-r--r--kubernetes/values.yaml2
-rw-r--r--locales/ar.json130
-rw-r--r--locales/ca.json2
-rw-r--r--locales/cs.json4
-rw-r--r--locales/da.json4
-rw-r--r--locales/de.json7
-rw-r--r--locales/el.json4
-rw-r--r--locales/en-US.json10
-rw-r--r--locales/eo.json8
-rw-r--r--locales/es.json4
-rw-r--r--locales/et.json4
-rw-r--r--locales/fa.json19
-rw-r--r--locales/fi.json7
-rw-r--r--locales/fr.json4
-rw-r--r--locales/he.json4
-rw-r--r--locales/hi.json4
-rw-r--r--locales/hr.json4
-rw-r--r--locales/hu-HU.json8
-rw-r--r--locales/id.json4
-rw-r--r--locales/is.json4
-rw-r--r--locales/it.json6
-rw-r--r--locales/ja.json23
-rw-r--r--locales/ko.json46
-rw-r--r--locales/lt.json4
-rw-r--r--locales/nb-NO.json4
-rw-r--r--locales/nl.json4
-rw-r--r--locales/or.json1
-rw-r--r--locales/pl.json4
-rw-r--r--locales/pt-BR.json7
-rw-r--r--locales/pt-PT.json86
-rw-r--r--locales/pt.json4
-rw-r--r--locales/ro.json4
-rw-r--r--locales/ru.json4
-rw-r--r--locales/sl.json4
-rw-r--r--locales/sq.json24
-rw-r--r--locales/sr.json4
-rw-r--r--locales/sr_Cyrl.json4
-rw-r--r--locales/sv-SE.json4
-rw-r--r--locales/tr.json558
-rw-r--r--locales/uk.json4
-rw-r--r--locales/vi.json4
-rw-r--r--locales/zh-CN.json4
-rw-r--r--locales/zh-TW.json4
-rwxr-xr-x[-rw-r--r--]scripts/deploy-database.sh0
-rwxr-xr-x[-rw-r--r--]scripts/fetch-player-dependencies.cr2
-rwxr-xr-x[-rw-r--r--]scripts/install-dependencies.sh0
-rw-r--r--shard.lock2
-rw-r--r--shard.yml2
-rw-r--r--spec/invidious/hashtag_spec.cr4
-rw-r--r--spec/invidious/helpers_spec.cr6
-rw-r--r--src/invidious.cr10
-rw-r--r--src/invidious/channels/about.cr118
-rw-r--r--src/invidious/channels/channels.cr81
-rw-r--r--src/invidious/channels/playlists.cr109
-rw-r--r--src/invidious/channels/videos.cr160
-rw-r--r--src/invidious/config.cr2
-rw-r--r--src/invidious/database/users.cr10
-rw-r--r--src/invidious/exceptions.cr5
-rw-r--r--src/invidious/frontend/channel_page.cr44
-rw-r--r--src/invidious/hashtag.cr3
-rw-r--r--src/invidious/helpers/json_filter.cr6
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr7
-rw-r--r--src/invidious/http_server/utils.cr20
-rw-r--r--src/invidious/jobs/notification_job.cr4
-rw-r--r--src/invidious/jobs/refresh_channels_job.cr2
-rw-r--r--src/invidious/jobs/refresh_feeds_job.cr2
-rw-r--r--src/invidious/jobs/subscribe_to_feeds_job.cr2
-rw-r--r--src/invidious/jsonify/api_v1/video_json.cr11
-rw-r--r--src/invidious/routes/api/manifest.cr4
-rw-r--r--src/invidious/routes/api/v1/channels.cr207
-rw-r--r--src/invidious/routes/api/v1/videos.cr75
-rw-r--r--src/invidious/routes/channels.cr108
-rw-r--r--src/invidious/routes/embed.cr2
-rw-r--r--src/invidious/routes/feeds.cr38
-rw-r--r--src/invidious/routes/video_playback.cr20
-rw-r--r--src/invidious/routes/watch.cr2
-rw-r--r--src/invidious/routing.cr19
-rw-r--r--src/invidious/search/processors.cr14
-rw-r--r--src/invidious/videos/caption.cr66
-rw-r--r--src/invidious/videos/parser.cr6
-rw-r--r--src/invidious/views/channel.ecr120
-rw-r--r--src/invidious/views/community.ecr76
-rw-r--r--src/invidious/views/components/channel_info.ecr60
-rw-r--r--src/invidious/views/feeds/subscriptions.ecr4
-rw-r--r--src/invidious/views/playlists.ecr108
-rw-r--r--src/invidious/views/template.ecr4
-rw-r--r--src/invidious/views/user/preferences.ecr2
-rw-r--r--src/invidious/yt_backend/extractors.cr130
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr27
95 files changed, 1549 insertions, 1232 deletions
diff --git a/assets/css/default.css b/assets/css/default.css
index ab2b79e6..80bf6a20 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -509,6 +509,10 @@ hr {
margin-top: 20px;
}
+label[for="descexpansionbutton"]:hover {
+ cursor: pointer;
+}
+
/* Bidi (bidirectional text) support */
h1,
h2,
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..e31a0e28 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": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
"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,10 @@
"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": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. <a href=\"`x`\"> انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. </a>"
}
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..466a3058 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -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í",
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. <a href=\"`x`\">Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen.</a>"
}
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..12955665 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -404,9 +404,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 +470,11 @@
"crash_page_read_the_faq": "read the <a href=\"`x`\">Frequently Asked Questions (FAQ)</a>",
"crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on GitHub</a>",
"crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):",
- "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>"
+ "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>",
+ "channel_tab_videos_label": "Videos",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_streams_label": "Livestreams",
+ "channel_tab_playlists_label": "Playlists",
+ "channel_tab_community_label": "Community",
+ "channel_tab_channels_label": "Channels"
}
diff --git a/locales/eo.json b/locales/eo.json
index fb5bb69c..1a5d9938 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": "Filmetoj",
"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",
diff --git a/locales/es.json b/locales/es.json
index 8603e9fe..dc63619e 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",
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 <a href=\"`x`\">hakea toisella instanssilla</a>.",
"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. <a href=\"`x`\">Klikkaa tähän päästäksesi soittolistan etusivulle.</a>"
}
diff --git a/locales/fr.json b/locales/fr.json
index 2f384eb1..59a960d0 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",
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..c8414322 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -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",
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. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>"
}
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..1a0d8efc 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,9 @@
"`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à",
+ "channel_tab_community_label": "Comunità",
"search_filters_sort_option_relevance": "Pertinenza",
"search_filters_sort_option_rating": "Valutazione",
"search_filters_sort_option_date": "Data di caricamento",
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": "<a href=\"`x`\">別のインスタンスを使用</a>しようとしました",
+ "crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
+ "Popular enabled: ": "人気動画を有効化 ",
+ "search_message_use_another_instance": " <a href=\"`x`\">別のインスタンスで検索</a>することもできます。",
+ "search_filters_apply_button": "選択したフィルターを適用",
+ "user_saved_playlists": "`x` 個の保存済みプレイリスト",
+ "crash_page_you_found_a_bug": "Invidious でバグを見つけたようです。",
+ "crash_page_refresh": "<a href=\"`x`\">ページを更新</a>しようとしました",
+ "preferences_watch_history_label": "視聴履歴を有効化 ",
+ "search_filters_date_option_none": "任意の日付",
+ "search_filters_type_option_all": "いかなるタイプ",
+ "search_filters_duration_option_none": "任意の期間",
+ "search_filters_duration_option_medium": "ミディアム (4 ~ 20 分)",
+ "preferences_save_player_pos_label": "再生位置を保存: ",
+ "crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。"
}
diff --git a/locales/ko.json b/locales/ko.json
index 8d79c456..af19fd02 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": "단순 모드: ",
@@ -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": "아니요",
@@ -152,7 +152,7 @@
"Report statistics: ": "통계 보고: ",
"Registration enabled: ": "등록 활성화: ",
"Login enabled: ": "로그인 활성화: ",
- "CAPTCHA enabled: ": "CAPTCHA 활성화: ",
+ "CAPTCHA enabled: ": "캡차 활성화: ",
"Top enabled: ": "Top 활성화: ",
"preferences_show_nick_label": "상단에 닉네임 표시: ",
"preferences_feed_menu_label": "피드 메뉴: ",
@@ -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,7 +439,7 @@
"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에서 버그를 찾은 것 같습니다!",
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..6c642475 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": "Filmy",
"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",
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. <a href=\"`x`\">Clique aqui para acessar a página inicial da playlist.</a>"
}
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 <a href=\"`x`\">pesquisar noutra instância</a>.",
+ "crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
+ "crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
+ "crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
+ "crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>",
+ "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
+ "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
+ "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
+ "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
+ "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>"
}
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 <a href=\"`x`\">Întrebărilor Frecvente (FAQ)</a>",
"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ë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
+ "search_message_use_another_instance": " Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
+ "search_filters_date_label": "Datë ngarkimi",
+ "preferences_watch_history_label": "Aktivizo historik parjesh: ",
+ "Top enabled: ": "Me kryesueset të aktivizuara: ",
+ "preferences_video_loop_label": "Përsërite gjithmonë: ",
+ "search_message_no_results": "S’u gjetën përfundime.",
+ "Could not pull trending pages.": "S’u morën dot faqet në modë.",
+ "search_filters_date_option_none": "Çfarëdo date",
+ "search_message_change_filters_or_query": "Provoni të zgjeroni kërkesën tuaj të kërkimit dhe/ose të ndryshoni filtrat.",
+ "search_filters_type_option_all": "Çfarëdo lloji",
+ "search_filters_duration_option_none": "Çfarëdo kohëzgjatjeje",
+ "search_filters_duration_option_medium": "Mesatare (4 - 20 minuta)",
+ "search_filters_features_option_vr180": "VR180",
+ "search_filters_apply_button": "Apliko filtrat e përzgjedhur"
}
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..7dc256a9 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,178 @@
"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": "<a href=\"`x`\">sayfayı yenilemeye</a> çalıştınız",
- "crash_page_switch_instance": "<a href=\"`x`\">başka bir örnek kullanmaya</a> çalıştınız",
- "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> okudunuz",
- "crash_page_search_issue": "<a href=\"`x`\">GitHub'daki sorunlarda</a> aradınız",
- "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):",
+ "crash_page_refresh": "<a href=\"`x`\">Sayfayı Yenilemeye</a> Çalıştınız",
+ "crash_page_switch_instance": "<a href=\"`x`\">Başka Bir Örnek Kullanmaya</a> Çalıştınız",
+ "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> Okudunuz",
+ "crash_page_search_issue": "<a href=\"`x`\">GitHub'daki Sorunlarda</a> Aradınız",
+ "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (Tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (Bu metni ÇEVİRMEYİN):",
"English (United Kingdom)": "İngilizce (Birleşik Krallık)",
"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 <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
- "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: ",
+ "Popular enabled: ": "Popüler Etkin: ",
"error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. <a href=\"`x`\">Oynatma listesi ana sayfası için buraya tıklayın.</a>"
}
diff --git a/locales/uk.json b/locales/uk.json
index b6994c56..d063799e 100644
--- a/locales/uk.json
+++ b/locales/uk.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_views_count_0": "{{count}} перегляд",
"generic_views_count_1": "{{count}} перегляди",
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..584d4a0a 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": "日期",
diff --git a/scripts/deploy-database.sh b/scripts/deploy-database.sh
index fa24b8f0..fa24b8f0 100644..100755
--- a/scripts/deploy-database.sh
+++ b/scripts/deploy-database.sh
diff --git a/scripts/fetch-player-dependencies.cr b/scripts/fetch-player-dependencies.cr
index ed658b51..813e4ce4 100644..100755
--- 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
index 1e67bdaf..1e67bdaf 100644..100755
--- a/scripts/install-dependencies.sh
+++ b/scripts/install-dependencies.sh
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
-
- 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 !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
+ tab_names = [] of String
+
+ 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
+
+ # 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)
-
- 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
+ # Keep fetching videos using the continuation token retrieved earlier
+ videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation)
+
+ 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/playlists.cr b/src/invidious/channels/playlists.cr
index d5628f6a..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
- else
- url = "/channel/#{ucid}/playlists?flow=list&view=1"
-
- 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":"(?<continuation>[^"]+)"/).try &.["continuation"]?
- 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
+ initial_data = YoutubeAPI.browse(continuation)
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
+ 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
+
+ initial_data = YoutubeAPI.browse(ucid, params: params || "")
end
- object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
- object["80226972:embedded"].delete("3:base64")
-
- continuation = object.try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
+ return extract_items(initial_data, author, ucid)
end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index b495e597..befec03d 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)
+# 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=US&hl=en"
end
-def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
- videos = [] of SearchVideo
+module Invidious::Channel::Tabs
+ extend self
- # 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
+ # -------------------
+ # Regular videos
+ # -------------------
- return videos.size, videos
-end
+ def make_initial_video_ctoken(ucid, sort_by) : String
+ return produce_channel_videos_continuation(ucid, sort_by: sort_by)
+ end
-def get_latest_videos(ucid)
- initial_data = get_channel_videos_response(ucid)
- author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s
+ # 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
- return extract_videos(initial_data, author, ucid)
-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
-# 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=US&hl=en"
+ 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 c9bf43a4..9fc58409 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 << %(<div class="pure-u-1 pure-md-1-3">\n)
+
+ if tab == selected_tab
+ str << "\t<b>"
+ str << translate(locale, "channel_tab_#{tab_name}_label")
+ str << "</b>\n"
+ else
+ # Video tab doesn't have the last path component
+ url = tab.videos? ? base_url : "#{base_url}/#{tab_name}"
+
+ str << %(\t<a href=") << url << %(">)
+ str << translate(locale, "channel_tab_#{tab_name}_label")
+ str << "</a>\n"
+ end
+
+ str << "</div>"
+ end
+ end
+ end
+ end
+end
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/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/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
"<BaseURL>#{url}</BaseURL>"
@@ -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)
+ # 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")
+
+ 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"]
+ # 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
- videos = get_latest_videos(ucid)
+ videos, next_continuation = Channel::Tabs.get_60_videos(
+ channel, continuation: continuation, sort_by: 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.videos(env)
+ def self.shorts(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 continuation from URL parameters
+ 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_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
- count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
+ videos, next_continuation = Channel::Tabs.get_60_livestreams(
+ channel, continuation: continuation
+ )
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
@@ -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?("<?xml")
+ webvtt = caption.timedtext_to_vtt(caption_xml, tlang)
+ else
+ caption_xml = XML.parse(caption_xml)
+ webvtt = String.build do |str|
+ str << <<-END_VTT
+ WEBVTT
+ Kind: captions
+ Language: #{tlang || caption.language_code}
- END_VTT
- caption_nodes = caption_xml.xpath_nodes("//transcript/text")
- caption_nodes.each_with_index do |node, i|
- start_time = node["start"].to_f.seconds
- duration = node["dur"]?.try &.to_f.seconds
- duration ||= start_time
+ END_VTT
- if caption_nodes.size > i + 1
- end_time = caption_nodes[i + 1]["start"].to_f.seconds
- else
- end_time = start_time + duration
- end
+ caption_nodes = caption_xml.xpath_nodes("//transcript/text")
+ caption_nodes.each_with_index do |node, i|
+ start_time = node["start"].to_f.seconds
+ duration = node["dur"]?.try &.to_f.seconds
+ duration ||= start_time
- 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')}"
+ if caption_nodes.size > i + 1
+ end_time = caption_nodes[i + 1]["start"].to_f.seconds
+ else
+ end_time = start_time + duration
+ end
- text = HTML.unescape(node.content)
- text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
- text = text.gsub(/<\/font>/, "")
- if md = text.match(/(?<name>.*) : (?<text>.*)/)
- text = "<v #{md["name"]}>#{md["text"]}</v>"
- 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(/<font color="#[a-fA-F0-9]{6}">/, "")
+ text = text.gsub(/<\/font>/, "")
+ if md = text.match(/(?<name>.*) : (?<text>.*)/)
+ text = "<v #{md["name"]}>#{md["text"]}</v>"
+ end
- str << <<-END_CUE
- #{start_time} --> #{end_time}
- #{text}
+ str << <<-END_CUE
+ #{start_time} --> #{end_time}
+ #{text}
- END_CUE
+ END_CUE
+ end
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?("<?xml")
+ webvtt = caption.timedtext_to_vtt(webvtt)
+ else
+ webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
+ .gsub(/([0-9:.]{12} --> [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.playlists(env)
+ def self.shorts(env)
data = self.fetch_basic_information(env)
- if !data.is_a?(Tuple)
- return data
+ 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)
+ 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/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 += "&region=#{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..491022a5 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,14 +117,17 @@ 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/
@@ -220,6 +225,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 +269,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/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/parser.cr b/src/invidious/videos/parser.cr
index 5df49286..5c323975 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),
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? -%>
<meta name="description" content="<%= channel.description %>">
<meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
@@ -14,91 +30,14 @@
<meta name="twitter:title" content="<%= author %>">
<meta name="twitter:description" content="<%= channel.description %>">
<meta name="twitter:image" content="/ggpht<%= channel_profile_pic %>">
-<link rel="alternate" href="https://www.youtube.com/channel/<%= ucid %>">
-<title><%= author %> - Invidious</title>
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
-<% end %>
-
-<% if channel.banner %>
- <div class="h-box">
- <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>">
- </div>
+<%- end -%>
- <div class="h-box">
- <hr>
- </div>
+<link rel="alternate" href="<%= youtube_url %>">
+<title><%= author %> - Invidious</title>
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
- <div class="channel-profile">
- <img src="/ggpht<%= channel_profile_pic %>">
- <span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
- </div>
- </div>
- <div class="pure-u-1-3">
- <h3 style="text-align:right">
- <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a>
- </h3>
- </div>
-</div>
-
-<div class="h-box">
- <div id="descriptionWrapper">
- <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
- </div>
-</div>
-
-<div class="h-box">
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
-</div>
-
-<div class="pure-g h-box">
- <div class="pure-u-1-3">
- <a href="https://www.youtube.com/channel/<%= ucid %>"><%= translate(locale, "View channel on YouTube") %></a>
- <div class="pure-u-1 pure-md-1-3">
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% else %>
- <a href="https://redirect.invidious.io<%= env.request.path %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% end %>
- </div>
- <% if !channel.auto_generated %>
- <div class="pure-u-1 pure-md-1-3">
- <b><%= translate(locale, "Videos") %></b>
- </div>
- <% end %>
- <div class="pure-u-1 pure-md-1-3">
- <% if channel.auto_generated %>
- <b><%= translate(locale, "Playlists") %></b>
- <% else %>
- <a href="/channel/<%= ucid %>/playlists"><%= translate(locale, "Playlists") %></a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-md-1-3">
- <% if channel.tabs.includes? "community" %>
- <a href="/channel/<%= ucid %>/community"><%= translate(locale, "Community") %></a>
- <% end %>
- </div>
- </div>
- <div class="pure-u-1-3"></div>
- <div class="pure-u-1-3">
- <div class="pure-g" style="text-align:right">
- <% sort_options.each do |sort| %>
- <div class="pure-u-1 pure-md-1-3">
- <% if sort_by == sort %>
- <b><%= translate(locale, sort) %></b>
- <% else %>
- <a href="/channel/<%= ucid %>?page=<%= page %>&sort_by=<%= sort %>">
- <%= translate(locale, sort) %>
- </a>
- <% end %>
- </div>
- <% end %>
- </div>
- </div>
-</div>
+<%= rendered "components/channel_info" %>
<div class="h-box">
<hr>
@@ -111,17 +50,10 @@
</div>
<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/channel/<%= ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
+ <div class="pure-u-1 pure-u-md-4-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if count == 60 %>
- <a href="/channel/<%= ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
+ <% if next_continuation %>
+ <a href="<%= relative_url %>?continuation=<%= next_continuation %><% if sort_options.any? sort_by %>&sort_by=<%= sort_by %><% end %>">
<%= translate(locale, "Next page") %>
</a>
<% 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
-<% content_for "header" do %>
-<title><%= author %> - Invidious</title>
-<% end %>
+ relative_url = "/channel/#{ucid}/community"
+ youtube_url = "https://www.youtube.com#{relative_url}"
+ redirect_url = Invidious::Frontend::Misc.redirect_url(env)
-<% if channel.banner %>
- <div class="h-box">
- <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>">
- </div>
+ selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community
+-%>
- <div class="h-box">
- <hr>
- </div>
+<% content_for "header" do %>
+<link rel="alternate" href="<%= youtube_url %>">
+<title><%= author %> - Invidious</title>
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
- <div class="channel-profile">
- <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>">
- <span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
- </div>
- </div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3 style="text-align:right">
- <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a>
- </h3>
- </div>
-</div>
-
-<div class="h-box">
- <div id="descriptionWrapper">
- <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p>
- </div>
-</div>
-
-<div class="h-box">
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
-</div>
-
-<div class="pure-g h-box">
- <div class="pure-u-1-3">
- <a href="https://www.youtube.com/channel/<%= channel.ucid %>/community"><%= translate(locale, "View channel on YouTube") %></a>
- <div class="pure-u-1 pure-md-1-3">
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% else %>
- <a href="https://redirect.invidious.io<%= env.request.resource %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% end %>
- </div>
- <% if !channel.auto_generated %>
- <div class="pure-u-1 pure-md-1-3">
- <a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a>
- </div>
- <% end %>
- <div class="pure-u-1 pure-md-1-3">
- <a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a>
- </div>
- <div class="pure-u-1 pure-md-1-3">
- <% if channel.tabs.includes? "community" %>
- <b><%= translate(locale, "Community") %></b>
- <% end %>
- </div>
- </div>
- <div class="pure-u-2-3"></div>
-</div>
+<%= rendered "components/channel_info" %>
<div class="h-box">
<hr>
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 %>
+ <div class="h-box">
+ <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>">
+ </div>
+
+ <div class="h-box">
+ <hr>
+ </div>
+<% end %>
+
+<div class="pure-g h-box">
+ <div class="pure-u-2-3">
+ <div class="channel-profile">
+ <img src="/ggpht<%= channel_profile_pic %>">
+ <span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
+ </div>
+ </div>
+ <div class="pure-u-1-3">
+ <h3 style="text-align:right">
+ <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a>
+ </h3>
+ </div>
+</div>
+
+<div class="h-box">
+ <div id="descriptionWrapper">
+ <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
+ </div>
+</div>
+
+<div class="h-box">
+ <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <%= rendered "components/subscribe_widget" %>
+</div>
+
+<div class="pure-g h-box">
+ <div class="pure-u-1-2">
+ <div class="pure-u-1 pure-md-1-3">
+ <a href="<%= youtube_url %>"><%= translate(locale, "View channel on YouTube") %></a>
+ </div>
+ <div class="pure-u-1 pure-md-1-3">
+ <a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ </div>
+
+ <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
+ </div>
+ <div class="pure-u-1-2">
+ <div class="pure-g" style="text-align:end">
+ <% sort_options.each do |sort| %>
+ <div class="pure-u-1 pure-md-1-3">
+ <% if sort_by == sort %>
+ <b><%= translate(locale, sort) %></b>
+ <% else %>
+ <a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+ </div>
+</div>
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 @@
</div>
</div>
+<% if CONFIG.enable_user_notifications %>
+
<center>
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
@@ -39,6 +41,8 @@
<% end %>
</div>
+<% end %>
+
<div class="h-box">
<hr>
</div>
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 %>
-<title><%= author %> - Invidious</title>
-<% end %>
-
-<% if channel.banner %>
- <div class="h-box">
- <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>">
- </div>
-
- <div class="h-box">
- <hr>
- </div>
-<% end %>
-
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
- <div class="channel-profile">
- <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>">
- <span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
- </div>
- </div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3 style="text-align:right">
- <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a>
- </h3>
- </div>
-</div>
-
-<div class="h-box">
- <div id="descriptionWrapper">
- <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %></span></p>
- </div>
-</div>
-
-<div class="h-box">
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
-</div>
-
-<div class="pure-g h-box">
- <div class="pure-g pure-u-1-3">
- <div class="pure-u-1 pure-md-1-3">
- <a href="https://www.youtube.com/channel/<%= ucid %>/playlists"><%= translate(locale, "View channel on YouTube") %></a>
- </div>
-
- <div class="pure-u-1 pure-md-1-3">
- <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% else %>
- <a href="https://redirect.invidious.io<%= env.request.resource %>"><%= translate(locale, "Switch Invidious Instance") %></a>
- <% end %>
- </div>
-
- <div class="pure-u-1 pure-md-1-3">
- <a href="/channel/<%= ucid %>"><%= translate(locale, "Videos") %></a>
- </div>
- <div class="pure-u-1 pure-md-1-3">
- <% if !channel.auto_generated %>
- <b><%= translate(locale, "Playlists") %></b>
- <% end %>
- </div>
- <div class="pure-u-1 pure-md-1-3">
- <% if channel.tabs.includes? "community" %>
- <a href="/channel/<%= ucid %>/community"><%= translate(locale, "Community") %></a>
- <% end %>
- </div>
- </div>
- <div class="pure-u-1-3"></div>
- <div class="pure-u-1-3">
- <div class="pure-g" style="text-align:right">
- <% {"last", "oldest", "newest"}.each do |sort| %>
- <div class="pure-u-1 pure-md-1-3">
- <% if sort_by == sort %>
- <b><%= translate(locale, sort) %></b>
- <% else %>
- <a href="/channel/<%= ucid %>/playlists?sort_by=<%= sort %>">
- <%= translate(locale, sort) %>
- </a>
- <% end %>
- </div>
- <% end %>
- </div>
- </div>
-</div>
-
-<div class="h-box">
- <hr>
-</div>
-
-<div class="pure-g">
-<% items.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-md-4-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if continuation %>
- <a href="/channel/<%= ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 98f72eba..77265679 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -54,7 +54,7 @@
<div class="pure-u-1-4">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(Invidious::User).notifications.size %>
- <% if notification_count > 0 %>
+ <% if CONFIG.enable_user_notifications && notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
<% else %>
<i class="icon ion-ios-notifications-outline"></i>
@@ -170,7 +170,9 @@
}.to_pretty_json
%>
</script>
+ <% if CONFIG.enable_user_notifications %>
<script src="/js/notifications.js?v=<%= ASSET_COMMIT %>"></script>
+ <% end %>
<% end %>
</body>
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 @@
<input name="unseen_only" id="unseen_only" type="checkbox" <% if preferences.unseen_only %>checked<% end %>>
</div>
+ <% if CONFIG.enable_user_notifications %>
<div class="pure-control-group">
<label for="notifications_only"><%= translate(locale, "preferences_notifications_only_label") %></label>
<input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>>
@@ -255,6 +256,7 @@
<a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
+ <% end %>
<% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index 1f7726fb..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.
@@ -355,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({
@@ -394,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
@@ -418,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"
)
@@ -446,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
@@ -460,8 +470,8 @@ private module Parsers
regex_match = /- (?<min>\d+ minutes? )?(?<sec>\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)
@@ -485,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
@@ -520,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
@@ -585,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
@@ -618,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"]?
@@ -705,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.
@@ -716,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
@@ -741,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
+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
+ 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