summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assets/css/default.css42
-rw-r--r--locales/ar.json44
-rw-r--r--locales/bn_BD.json132
-rw-r--r--locales/cs.json233
-rw-r--r--locales/da.json13
-rw-r--r--locales/eo.json32
-rw-r--r--locales/es.json24
-rw-r--r--locales/eu.json115
-rw-r--r--locales/fr.json26
-rw-r--r--locales/he.json36
-rw-r--r--locales/hr.json50
-rw-r--r--locales/hu-HU.json120
-rw-r--r--locales/id.json56
-rw-r--r--locales/lt.json427
-rw-r--r--locales/nb-NO.json38
-rw-r--r--locales/nl.json32
-rw-r--r--locales/pt-BR.json10
-rw-r--r--locales/si.json3
-rw-r--r--locales/sk.json132
-rw-r--r--locales/sr.json5
-rw-r--r--locales/sr_Cyrl.json115
-rw-r--r--locales/tr.json104
-rw-r--r--locales/vi.json427
-rw-r--r--locales/zh-CN.json56
-rw-r--r--locales/zh-TW.json20
-rw-r--r--spec/helpers_spec.cr2
-rw-r--r--src/invidious.cr1
-rw-r--r--src/invidious/channels.cr962
-rw-r--r--src/invidious/channels/about.cr192
-rw-r--r--src/invidious/channels/channels.cr310
-rw-r--r--src/invidious/channels/community.cr275
-rw-r--r--src/invidious/channels/playlists.cr93
-rw-r--r--src/invidious/channels/videos.cr89
-rw-r--r--src/invidious/helpers/helpers.cr20
-rw-r--r--src/invidious/helpers/i18n.cr65
-rw-r--r--src/invidious/helpers/youtube_api.cr10
-rw-r--r--src/invidious/jobs/bypass_captcha_job.cr6
-rw-r--r--src/invidious/trending.cr30
-rw-r--r--src/invidious/views/channel.ecr6
-rw-r--r--src/invidious/views/community.ecr6
-rw-r--r--src/invidious/views/components/item.ecr130
-rw-r--r--src/invidious/views/playlist.ecr4
-rw-r--r--src/invidious/views/playlists.ecr6
-rw-r--r--src/invidious/views/watch.ecr14
44 files changed, 2886 insertions, 1627 deletions
diff --git a/assets/css/default.css b/assets/css/default.css
index 1d62bc01..ce6c30c9 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -282,6 +282,21 @@ input[type="search"]::-webkit-search-cancel-button {
}
}
+
+/*
+ * Video "cards" (results/playlist/channel videos)
+ */
+
+.video-card-row { margin: 15px 0; }
+
+.flexible { display: flex; }
+.flex-left { flex: 1 1 100%; flex-wrap: wrap; }
+.flex-right { flex: 1 0 max-content; flex-wrap: nowrap; }
+
+p.channel-name { margin: 0; }
+p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
+
+
/*
* Footer
*/
@@ -492,11 +507,6 @@ hr {
}
/* Description Expansion Styling*/
-#description-box {
- display: flex;
- flex-direction: column;
-}
-
#descexpansionbutton {
display: none
}
@@ -511,7 +521,27 @@ hr {
height: 100%;
}
-#descexpansionbutton + label {
+#descexpansionbutton ~ label {
order: 1;
margin-top: 20px;
}
+
+/* Bidi (bidirectional text) support */
+h1,
+h2,
+h3,
+h4,
+h5,
+p,
+#descriptionWrapper,
+#description-box {
+ unicode-bidi: plaintext;
+ text-align: start;
+}
+
+#descriptionWrapper {
+ max-width: 600px;
+}
+
+/* Center the "invidious" logo on the search page */
+#logo > h1 { text-align: center; }
diff --git a/locales/ar.json b/locales/ar.json
index f4fda666..5a981578 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -1,15 +1,15 @@
{
"`x` subscribers": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` المشتركين",
- "": "`x` المشتركين."
+ "": "`x` المشتركين"
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` المقاطع المرئيَّة",
- "": "`x` المقاطع المرئيَّة."
+ "": "`x` المقاطع المرئيَّة"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` قوائم التشغيل",
- "": "`x` قوائم التشغيل."
+ "": "`x` قوائم التشغيل"
},
"LIVE": "مُباشِر",
"Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`",
@@ -86,8 +86,8 @@
"dark": "غامق (اسود)",
"light": "فاتح (ابيض)",
"Thin mode: ": "الوضع الخفيف: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "تفضيلات متنوعة",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ",
"Subscription preferences": "تفضيلات الإشتراك",
"Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
@@ -117,7 +117,7 @@
"Administrator preferences": "إعدادات المدير",
"Default homepage: ": "الصفحة الرئيسية الافتراضية ",
"Feed menu: ": "قائمة التدفقات: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "إظهار اللقب في الأعلى: ",
"Top enabled: ": "تفعيل 'الأفضل' ؟ ",
"CAPTCHA enabled: ": "تفعيل الكابتشا: ",
"Login enabled: ": "تفعيل الولوج: ",
@@ -129,11 +129,11 @@
"Token": "الرمز",
"`x` subscriptions": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشتركين",
- "": "`x` مشتركين."
+ "": "`x` مشتركين"
},
"`x` tokens": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` رموز",
- "": "`x` رموز."
+ "": "`x` رموز"
},
"Import/export": "إضافة\\إستخراج",
"unsubscribe": "إلغاء الإشتراك",
@@ -164,8 +164,8 @@
"Show more": "أظهر المزيد",
"Show less": "عرض اقل",
"Watch on YouTube": "مشاهدة الفيديو على اليوتيوب",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "تبديل المثيل Invidious",
+ "Broken? Try another Invidious Instance": "معطل؟ جرب مثيل Invidious آخر",
"Hide annotations": "إخفاء الملاحظات فى الفيديو",
"Show annotations": "عرض الملاحظات فى الفيديو",
"Genre: ": "النوع: ",
@@ -178,7 +178,7 @@
"Shared `x`": "شارك منذ `x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشاهدات",
- "": "`x` مشاهدات."
+ "": "`x` مشاهدات"
},
"Premieres in `x`": "يعرض فى `x`",
"Premieres `x`": "يعرض `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات",
- "": "عرض `x` تعليقات."
+ "": "عرض `x` تعليقات"
},
"View Reddit comments": "عرض تعليقات ريدإت Reddit",
"Hide replies": "إخفاء الردود",
@@ -207,7 +207,7 @@
"Password cannot be empty": "الرقم السرى لايمكن ان يكون فارغ",
"Password cannot be longer than 55 characters": "الرقم السرى لا يتعدى 55 حرف",
"Please log in": "الرجاء تسجيل الدخول",
- "Invidious Private Feed for `x`": "صفحة Invidious للمشتركين الخاصة\\مخفية لـ `x`",
+ "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل 'x'",
"channel:`x`": "قناة:`x`",
"Deleted or invalid channel": "قناة ممسوحة او غير صالحة",
"This channel does not exist.": "القناة غير موجودة.",
@@ -215,13 +215,13 @@
"Could not fetch comments": "لم يتمكن من إحضار التعليقات",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` ردود",
- "": "عرض `x` ردود."
+ "": "عرض `x` ردود"
},
"`x` ago": "`x` منذ",
"Load more": "عرض المزيد",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقاط",
- "": "`x` نقاط."
+ "": "`x` نقاط"
},
"Could not create mix.": "لم يستطع عمل خلط.",
"Empty playlist": "قائمة التشغيل فارغة",
@@ -342,31 +342,31 @@
"Zulu": "الزولو",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` سنوات",
- "": "`x` سنوات."
+ "": "`x` سنوات"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` شهور",
- "": "`x` شهور."
+ "": "`x` شهور"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` اسابيع",
- "": "`x` اسابيع."
+ "": "`x` اسابيع"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` ايام",
- "": "`x` ايام."
+ "": "`x` ايام"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعات",
- "": "`x` ساعات."
+ "": "`x` ساعات"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقائق",
- "": "`x` دقائق."
+ "": "`x` دقائق"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثوانى",
- "": "`x` ثوانى."
+ "": "`x` ثوانى"
},
"Fallback comments: ": "التعليقات البديلة: ",
"Popular": "الأكثر شعبية",
diff --git a/locales/bn_BD.json b/locales/bn_BD.json
index 83bd6555..5f91c67e 100644
--- a/locales/bn_BD.json
+++ b/locales/bn_BD.json
@@ -1,10 +1,16 @@
{
- "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার",
- "`x` subscribers.": "`x` সাবস্ক্রাইবার।",
- "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও",
- "`x` videos.": "`x` ভিডিও",
- "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট",
- "`x` playlists.": "`x` প্লেলিস্ট",
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার",
+ "": "`x` সাবস্ক্রাইবার"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও",
+ "": "`x` ভিডিও"
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট",
+ "": "`x` প্লেলিস্ট"
+ },
"LIVE": "লাইভ",
"Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে",
"Unsubscribe": "আনসাবস্ক্রাইব",
@@ -81,7 +87,7 @@
"light": "",
"Thin mode: ": "",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "",
"Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "",
@@ -111,6 +117,7 @@
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
@@ -120,16 +127,22 @@
"Subscription manager": "",
"Token manager": "",
"Token": "",
- "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` subscriptions.": "",
- "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` tokens.": "",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Import/export": "",
"unsubscribe": "",
"revoke": "",
"Subscriptions": "",
- "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` unseen notifications.": "",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"search": "",
"Log out": "",
"Released under the AGPLv3 by Omar Roth.": "",
@@ -163,15 +176,19 @@
"Whitelisted regions: ": "",
"Blacklisted regions: ": "",
"Shared `x`": "",
- "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` views.": "",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Premieres in `x`": "",
"Premieres `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": "",
- "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "View `x` comments.": "",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"View Reddit comments": "",
"Hide replies": "",
"Show replies": "",
@@ -196,12 +213,16 @@
"This channel does not exist.": "",
"Could not get channel info.": "",
"Could not fetch comments": "",
- "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "View `x` replies.": "",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"`x` ago": "",
"Load more": "",
- "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` points.": "",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Could not create mix.": "",
"Empty playlist": "",
"Not a playlist.": "",
@@ -319,20 +340,34 @@
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
- "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` years.": "",
- "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` months.": "",
- "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` weeks.": "",
- "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` days.": "",
- "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` hours.": "",
- "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` minutes.": "",
- "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` seconds.": "",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Fallback comments: ": "",
"Popular": "",
"Search": "",
@@ -358,6 +393,33 @@
"Videos": "",
"Playlists": "",
"Community": "",
+ "relevance": "",
+ "rating": "",
+ "date": "",
+ "views": "",
+ "content_type": "",
+ "duration": "",
+ "features": "",
+ "sort": "",
+ "hour": "",
+ "today": "",
+ "week": "",
+ "month": "",
+ "year": "",
+ "video": "",
+ "channel": "",
+ "playlist": "",
+ "movie": "",
+ "show": "",
+ "hd": "",
+ "subtitles": "",
+ "creative_commons": "",
+ "3d": "",
+ "live": "",
+ "4k": "",
+ "location": "",
+ "hdr": "",
+ "filter": "",
"Current version: ": "",
"next_steps_error_message": "",
"next_steps_error_message_refresh": "",
diff --git a/locales/cs.json b/locales/cs.json
index c8320a07..abb2d503 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -9,7 +9,7 @@
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
- "": "`x` playlisty."
+ "": "`x` playlisty"
},
"LIVE": "ŽIVĚ",
"Shared `x` ago": "Sdíleno před `x`",
@@ -87,7 +87,7 @@
"light": "světlý",
"Thin mode: ": "Kompaktní režim: ",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Nastavení předplatných",
"Show annotations by default for subscribed channels: ": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ",
"Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ",
@@ -117,8 +117,9 @@
"Administrator preferences": "Administrátorská nastavení",
"Default homepage: ": "Základní domovská stránka: ",
"Feed menu: ": "Menu doporučených: ",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
- "CAPTCHA enabled: ": "CAPTCHA povolena: ",
+ "CAPTCHA enabled: ": "CAPTCHA povolen: ",
"Login enabled: ": "Přihlášení povoleno: ",
"Registration enabled: ": "Registrace povolena ",
"Report statistics: ": "Oznámit statistiky: ",
@@ -233,112 +234,112 @@
"Erroneous token": "",
"No such user": "",
"Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
+ "English": "Angličtina",
+ "English (auto-generated)": "Angličtina (automaticky generováno)",
+ "Afrikaans": "Afrikánština",
+ "Albanian": "Albánština",
+ "Amharic": "Amharština",
+ "Arabic": "Arabština",
+ "Armenian": "Arménština",
+ "Azerbaijani": "Azerbajdžánština",
+ "Bangla": "Bengálština",
+ "Basque": "Baskičtina",
+ "Belarusian": "Běloruština",
+ "Bosnian": "Bosenština",
+ "Bulgarian": "Bulharština",
+ "Burmese": "Barmština",
+ "Catalan": "Katalánština",
+ "Cebuano": "Cebuánština",
+ "Chinese (Simplified)": "Čínština (zjednodušená)",
+ "Chinese (Traditional)": "Čínština (tradiční)",
+ "Corsican": "Korsičtina",
+ "Croatian": "Chorvatština",
+ "Czech": "Čeština",
+ "Danish": "Dánština",
+ "Dutch": "Nizozemština",
+ "Esperanto": "Esperanto",
+ "Estonian": "Estonština",
+ "Filipino": "Filipínština",
+ "Finnish": "Finština",
+ "French": "Francouzština",
+ "Galician": "Galicijština",
+ "Georgian": "Gruzínština",
+ "German": "Němčina",
+ "Greek": "Řečtina",
+ "Gujarati": "Gudžarátština",
+ "Haitian Creole": "Haitská kreolština",
+ "Hausa": "Hauština",
+ "Hawaiian": "Havajština",
+ "Hebrew": "Hebrejština",
+ "Hindi": "Hindština",
+ "Hmong": "Hmongština",
+ "Hungarian": "Maďarština",
+ "Icelandic": "Islandština",
+ "Igbo": "Igboština",
+ "Indonesian": "Indonéština",
+ "Irish": "Irština",
+ "Italian": "Italština",
+ "Japanese": "Japonština",
+ "Javanese": "Javánština",
+ "Kannada": "Kannadština",
+ "Kazakh": "Kazaština",
+ "Khmer": "Khmerština",
+ "Korean": "Korejština",
+ "Kurdish": "Kurdština",
+ "Kyrgyz": "Kyrgyzština",
+ "Lao": "Laoština",
+ "Latin": "Latina",
+ "Latvian": "Lotyština",
+ "Lithuanian": "Litevština",
+ "Luxembourgish": "Lucemburština",
+ "Macedonian": "Makedonština",
+ "Malagasy": "Malgaština",
+ "Malay": "Malajština",
+ "Malayalam": "Malajálamština",
+ "Maltese": "Maltština",
+ "Maori": "Maorština",
+ "Marathi": "Maráthština",
+ "Mongolian": "Mongolština",
+ "Nepali": "Nepálština",
+ "Norwegian Bokmål": "Norština Bokmål",
+ "Nyanja": "Čičevština",
+ "Pashto": "Paštština",
+ "Persian": "Perština",
+ "Polish": "Polština",
+ "Portuguese": "Portugalština",
+ "Punjabi": "Paňdžábština",
+ "Romanian": "Rumunština",
+ "Russian": "Ruština",
+ "Samoan": "Samojština",
+ "Scottish Gaelic": "Skotská gaelština",
+ "Serbian": "Srbština",
+ "Shona": "Shona",
+ "Sindhi": "Sindhština",
+ "Sinhala": "Sinhálština",
+ "Slovak": "Slovenština",
+ "Slovenian": "Slovinština",
+ "Somali": "Somálština",
+ "Southern Sotho": "Sesothština",
+ "Spanish": "Španělština",
+ "Spanish (Latin America)": "Španělština (Latinská Amerika)",
+ "Sundanese": "Sundština",
+ "Swahili": "Svahilština",
+ "Swedish": "Švédština",
+ "Tajik": "Tádžičtina",
+ "Tamil": "Tamilština",
+ "Telugu": "Telugština",
+ "Thai": "Thajština",
+ "Turkish": "Turečtina",
+ "Ukrainian": "Ukrajinština",
+ "Urdu": "Urdština",
+ "Uzbek": "Uzbečtina",
+ "Vietnamese": "Vietnamština",
+ "Welsh": "Velština",
+ "Western Frisian": "Západofríština",
+ "Xhosa": "Xhoština",
+ "Yiddish": "Jidiš",
+ "Yoruba": "Jorubština",
+ "Zulu": "Zuluština",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "",
"": ""
@@ -368,18 +369,18 @@
"": ""
},
"Fallback comments: ": "",
- "Popular": "",
+ "Popular": "Populární",
"Search": "",
"Top": "",
"About": "Informace",
"Rating: ": "Hodnocení: ",
"Language: ": "Jazyk: ",
"View as playlist": "",
- "Default": "",
+ "Default": "Výchozí",
"Music": "Hudba",
- "Gaming": "",
+ "Gaming": "Hry",
"News": "Zprávy",
- "Movies": "",
+ "Movies": "Filmy",
"Download": "Stáhnout",
"Download as: ": "Stáhnout jako: ",
"%A %B %-d, %Y": "",
@@ -407,12 +408,12 @@
"year": "rok",
"video": "video",
"channel": "kanál",
- "playlist": "",
- "movie": "",
+ "playlist": "playlist",
+ "movie": "film",
"show": "zobrazit",
"hd": "HD",
"subtitles": "titulky",
- "creative_commons": "",
+ "creative_commons": "Creative Commons",
"3d": "3D",
"live": "živě",
"4k": "4k",
diff --git a/locales/da.json b/locales/da.json
index d207939c..fca62ecf 100644
--- a/locales/da.json
+++ b/locales/da.json
@@ -87,7 +87,7 @@
"light": "lys",
"Thin mode: ": "Tynd tilstand: ",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Abonnements præferencer",
"Show annotations by default for subscribed channels: ": "Vis annotationer som standard for abonnerede kanaler: ",
"Redirect homepage to feed: ": "Omdiriger startside til feed: ",
@@ -117,6 +117,7 @@
"Administrator preferences": "Administrator præferencer",
"Default homepage: ": "Standard startside: ",
"Feed menu: ": "Feed menu: ",
+ "Show nickname on top: ": "",
"Top enabled: ": "Top aktiveret: ",
"CAPTCHA enabled: ": "CAPTCHA aktiveret: ",
"Login enabled: ": "Login aktiveret: ",
@@ -186,7 +187,7 @@
"View more comments on Reddit": "Se flere kommentarer på Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Vis `x` kommentarer."
+ "": "Vis `x` kommentarer"
},
"View Reddit comments": "Vis Reddit kommentarer",
"Hide replies": "Skjul svar",
@@ -213,14 +214,14 @@
"Could not get channel info.": "Kunne ikke hente kanal info.",
"Could not fetch comments": "Kunne ikke hente kommentarer",
"View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` besvarelser.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Vis 'x' besvarelser."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` besvarelser",
+ "": "Vis 'x' besvarelser"
},
"`x` ago": "'x' siden",
"Load more": "Hent flere",
"`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "'x' point."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point",
+ "": "'x' point"
},
"Could not create mix.": "Kunne ikke skabe blanding.",
"Empty playlist": "Tom playliste",
diff --git a/locales/eo.json b/locales/eo.json
index f78c27cf..e3970159 100644
--- a/locales/eo.json
+++ b/locales/eo.json
@@ -86,8 +86,8 @@
"dark": "malhela",
"light": "hela",
"Thin mode: ": "Maldika reĝimo: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Aliaj agordoj",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ",
"Subscription preferences": "Abonaj agordoj",
"Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Agordoj de administranto",
"Default homepage: ": "Defaŭlta hejmpaĝo: ",
"Feed menu: ": "Flua menuo: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Montri kromnomon supre: ",
"Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ",
"CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ",
"Login enabled: ": "Ĉu ensaluto aktivita? ",
@@ -164,8 +164,8 @@
"Show more": "Montri pli",
"Show less": "Montri malpli",
"Watch on YouTube": "Vidi filmeton en JuTubo",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Ŝanĝi instalaĵon de Indivious",
+ "Broken? Try another Invidious Instance": "Ĉu misfunkcio? Provu alian instalaĵon de Indivious",
"Hide annotations": "Kaŝi prinotojn",
"Show annotations": "Montri prinotojn",
"Genre: ": "Ĝenro: ",
@@ -178,7 +178,7 @@
"Shared `x`": "Konigita `x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` spektaĵoj",
- "": "`x` spektaĵoj."
+ "": "`x` spektaĵoj"
},
"Premieres in `x`": "Premieras en `x`",
"Premieres `x`": "Premieras `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Vidi pli komentoj en Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komentojn",
- "": "Vidi `x` komentojn."
+ "": "Vidi `x` komentojn"
},
"View Reddit comments": "Vidi komentojn de Reddit",
"Hide replies": "Kaŝi respondojn",
@@ -215,13 +215,13 @@
"Could not fetch comments": "Ne povis venigi komentojn",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` respondojn",
- "": "Vidi `x` respondojn."
+ "": "Vidi `x` respondojn"
},
"`x` ago": "antaŭ `x`",
"Load more": "Ŝarĝi pli",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` poentoj",
- "": "`x` poentoj."
+ "": "`x` poentoj"
},
"Could not create mix.": "Ne povis krei mikson.",
"Empty playlist": "Ludlisto estas malplena",
@@ -342,31 +342,31 @@
"Zulu": "Zulua",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaroj",
- "": "`x` jaroj."
+ "": "`x` jaroj"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` monatoj",
- "": "`x` monatoj."
+ "": "`x` monatoj"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` semajnoj",
- "": "`x` semajnoj."
+ "": "`x` semajnoj"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` tagoj",
- "": "`x` tagoj."
+ "": "`x` tagoj"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` horoj",
- "": "`x` horoj."
+ "": "`x` horoj"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutoj",
- "": "`x` minutoj."
+ "": "`x` minutoj"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundoj",
- "": "`x` sekundoj."
+ "": "`x` sekundoj"
},
"Fallback comments: ": "Retrodefaŭltaj komentoj: ",
"Popular": "Popularaj",
diff --git a/locales/es.json b/locales/es.json
index 894e3b0d..ee93298c 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -5,7 +5,7 @@
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos",
- "": "`x` vídeos."
+ "": "`x` vídeos"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reproducción",
@@ -178,7 +178,7 @@
"Shared `x`": "Compartido `x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizaciones",
- "": "`x` visualizaciones."
+ "": "`x` visualizaciones"
},
"Premieres in `x`": "Se estrena en `x`",
"Premieres `x`": "Estrenos `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Ver más comentarios en Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentarios",
- "": "Ver `x` comentarios."
+ "": "Ver `x` comentarios"
},
"View Reddit comments": "Ver los comentarios de Reddit",
"Hide replies": "Ocultar las respuestas",
@@ -215,13 +215,13 @@
"Could not fetch comments": "No se han podido recuperar los comentarios",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respuestas",
- "": "Ver `x` respuestas."
+ "": "Ver `x` respuestas"
},
"`x` ago": "hace `x`",
"Load more": "Cargar más",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` puntos",
- "": "`x` puntos."
+ "": "`x` puntos"
},
"Could not create mix.": "No se ha podido crear la mezcla.",
"Empty playlist": "La lista de reproducción está vacía",
@@ -342,31 +342,31 @@
"Zulu": "Zulú",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` años",
- "": "`x` años."
+ "": "`x` años"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
- "": "`x` meses."
+ "": "`x` meses"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
- "": "`x` semanas."
+ "": "`x` semanas"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` días",
- "": "`x` días."
+ "": "`x` días"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
- "": "`x` horas."
+ "": "`x` horas"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
- "": "`x` minutos."
+ "": "`x` minutos"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
- "": "`x` segundos."
+ "": "`x` segundos"
},
"Fallback comments: ": "Comentarios alternativos: ",
"Popular": "Populares",
diff --git a/locales/eu.json b/locales/eu.json
index 34820a50..87d0b902 100644
--- a/locales/eu.json
+++ b/locales/eu.json
@@ -1,7 +1,16 @@
{
- "`x` subscribers": "`x` harpidedun",
- "`x` videos": "`x` bideo",
- "`x` playlists": "`x` erreprodukzio-zerrenda",
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` harpidedun"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` bideo"
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` erreprodukzio-zerrenda"
+ },
"LIVE": "ZUZENEAN",
"Shared `x` ago": "Duela `x` partekatua",
"Unsubscribe": "Harpidetza kendu",
@@ -78,7 +87,7 @@
"light": "argia",
"Thin mode: ": "",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Harpidetzen hobespenak",
"Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "",
@@ -108,6 +117,7 @@
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
@@ -117,13 +127,22 @@
"Subscription manager": "",
"Token manager": "",
"Token": "",
- "`x` subscriptions": "",
- "`x` tokens": "",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Import/export": "",
"unsubscribe": "",
"revoke": "",
"Subscriptions": "",
- "`x` unseen notifications": "",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"search": "",
"Log out": "",
"Released under the AGPLv3 by Omar Roth.": "",
@@ -157,13 +176,19 @@
"Whitelisted regions: ": "",
"Blacklisted regions: ": "",
"Shared `x`": "",
- "`x` views": "",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Premieres in `x`": "",
"Premieres `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": "",
- "View `x` comments": "",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"View Reddit comments": "",
"Hide replies": "",
"Show replies": "",
@@ -188,10 +213,16 @@
"This channel does not exist.": "",
"Could not get channel info.": "",
"Could not fetch comments": "",
- "View `x` replies": "",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"`x` ago": "",
"Load more": "",
- "`x` points": "",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Could not create mix.": "",
"Empty playlist": "",
"Not a playlist.": "",
@@ -309,13 +340,34 @@
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
- "`x` years": "",
- "`x` months": "",
- "`x` weeks": "",
- "`x` days": "",
- "`x` hours": "",
- "`x` minutes": "",
- "`x` seconds": "",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Fallback comments: ": "",
"Popular": "",
"Search": "",
@@ -341,6 +393,33 @@
"Videos": "",
"Playlists": "",
"Community": "",
+ "relevance": "",
+ "rating": "",
+ "date": "",
+ "views": "",
+ "content_type": "",
+ "duration": "",
+ "features": "",
+ "sort": "",
+ "hour": "",
+ "today": "",
+ "week": "",
+ "month": "",
+ "year": "",
+ "video": "",
+ "channel": "",
+ "playlist": "",
+ "movie": "",
+ "show": "",
+ "hd": "",
+ "subtitles": "",
+ "creative_commons": "",
+ "3d": "",
+ "live": "",
+ "4k": "",
+ "location": "",
+ "hdr": "",
+ "filter": "",
"Current version: ": "",
"next_steps_error_message": "",
"next_steps_error_message_refresh": "",
diff --git a/locales/fr.json b/locales/fr.json
index b3931b36..9c6bd8eb 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -77,8 +77,8 @@
"Fallback captions: ": "Sous-titres alternatifs : ",
"Show related videos: ": "Voir les vidéos liées : ",
"Show annotations by default: ": "Afficher les annotations par défaut : ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
+ "Automatically extend video description: ": "Etendre automatiquement la description : ",
+ "Interactive 360 degree videos: ": "Vidéos interactives à 360° : ",
"Visual preferences": "Préférences du site",
"Player style: ": "Style du lecteur : ",
"Dark mode: ": "Mode sombre : ",
@@ -86,9 +86,9 @@
"dark": "sombre",
"light": "clair",
"Thin mode: ": "Mode léger : ",
+ "Miscellaneous preferences": "Paramètres divers",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ",
"Subscription preferences": "Préférences des abonnements",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Miscellaneous preferences": "",
"Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ",
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
"Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Préferences d'Administration",
"Default homepage: ": "Page d'accueil par défaut : ",
"Feed menu: ": "Préferences des abonnements : ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Afficher le nom d'utilisateur en haut à droite : ",
"Top enabled: ": "Top activé : ",
"CAPTCHA enabled: ": "CAPTCHA activé : ",
"Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ",
@@ -161,11 +161,11 @@
"Title": "Titre",
"Playlist privacy": "Paramètres de confidentialité de la liste de lecture",
"Editing playlist `x`": "Liste de lecture modifier le `x`",
- "Show more": "",
- "Show less": "",
+ "Show more": "Afficher plus",
+ "Show less": "Afficher moins",
"Watch on YouTube": "Voir la vidéo sur Youtube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Changer d'instance",
+ "Broken? Try another Invidious Instance": "Instance Invidious défectueuse ? Essayez-en une autre",
"Hide annotations": "Masquer les annotations",
"Show annotations": "Afficher les annotations",
"Genre: ": "Genre : ",
@@ -410,7 +410,7 @@
"channel": "chaîne",
"playlist": "liste de lecture",
"movie": "film",
- "show": "affichage",
+ "show": "émission",
"hd": "HD",
"subtitles": "sous-titres / CC",
"creative_commons": "Creative Commons",
@@ -421,7 +421,7 @@
"hdr": "HDR",
"filter": "filtrer",
"Current version: ": "Version actuelle : ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Vous pouvez essayer de : ",
+ "next_steps_error_message_refresh": "Rafraîchir la page",
+ "next_steps_error_message_go_to_youtube": "Aller sur Youtube"
}
diff --git a/locales/he.json b/locales/he.json
index cb3f94e5..5d7f85c6 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -178,7 +178,7 @@
"Shared `x`": "",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` צפיות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` צפיות."
+ "": "`x` צפיות"
},
"Premieres in `x`": "",
"Premieres `x`": "",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "להצגת תגובות נוספות ב־Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "הצגת `x` תגובות."
+ "": "הצגת `x` תגובות"
},
"View Reddit comments": "להצגת התגובות ב־Reddit",
"Hide replies": "הסתרת תגובות",
@@ -214,8 +214,8 @@
"Could not get channel info.": "לא היה ניתן לקבל מידע על הערוץ.",
"Could not fetch comments": "לא היה ניתן למשוך את התגובות",
"View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "הצגת `x` תגובות."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות",
+ "": "הצגת `x` תגובות"
},
"`x` ago": "לפני `x`",
"Load more": "לטעון עוד",
@@ -341,32 +341,32 @@
"Yoruba": "יורובה",
"Zulu": "זולו",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` שנים."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים",
+ "": "`x` שנים"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` חודשים."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים",
+ "": "`x` חודשים"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` שבועות."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות",
+ "": "`x` שבועות"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` ימים."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים",
+ "": "`x` ימים"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` שעות."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות",
+ "": "`x` שעות"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` דקות."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות",
+ "": "`x` דקות"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` שניות."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות",
+ "": "`x` שניות"
},
"Fallback comments: ": "",
"Popular": "סרטונים פופולריים",
diff --git a/locales/hr.json b/locales/hr.json
index d278a6ef..fd978edb 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -86,8 +86,8 @@
"dark": "tamno",
"light": "svijetlo",
"Thin mode: ": "Pojednostavljen prikaz: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Razne postavke",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ",
"Subscription preferences": "Postavke pretplata",
"Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ",
"Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Postavke administratora",
"Default homepage: ": "Standardna početna stranica: ",
"Feed menu: ": "Izbornik za feedove: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Prikaži nadimak na vrhu: ",
"Top enabled: ": "Najbolji aktivirani: ",
"CAPTCHA enabled: ": "Aktivirani CAPTCHA: ",
"Login enabled: ": "Prijava aktivirana: ",
@@ -164,8 +164,8 @@
"Show more": "Pokaži više",
"Show less": "Pokaži manje",
"Watch on YouTube": "Gledaj na YouTubeu",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Promijeni Invidious instancu",
+ "Broken? Try another Invidious Instance": "Pokvarena? Probaj jednu drugu Invidious instancu",
"Hide annotations": "Sakrij napomene",
"Show annotations": "Prikaži napomene",
"Genre: ": "Žanr: ",
@@ -178,7 +178,7 @@
"Shared `x`": "Dijeljeno `x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` gledanja.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` gledanja."
+ "": "`x` gledanja"
},
"Premieres in `x`": "Premijera za `x`",
"Premieres `x`": "Premijera `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Prikaži još komentara na Redditu",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentara.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Prikaži `x` komentara."
+ "": "Prikaži `x` komentara"
},
"View Reddit comments": "Prikaži Reddit komentare",
"Hide replies": "Sakrij odgovore",
@@ -214,14 +214,14 @@
"Could not get channel info.": "Neuspjelo dobivanje podataka kanala.",
"Could not fetch comments": "Neuspjelo dohvaćanje komentara",
"View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Prikaži `x` odgovora."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora",
+ "": "Prikaži `x` odgovora"
},
"`x` ago": "prije `x`",
"Load more": "Učitaj više",
"`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` bodova."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova",
+ "": "`x` bodova"
},
"Could not create mix.": "Neuspjelo stvaranje miksa.",
"Empty playlist": "Prazna playlista",
@@ -341,32 +341,32 @@
"Yoruba": "Jorubški",
"Zulu": "Zulu",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` g."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g",
+ "": "`x` g"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` mj."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj",
+ "": "`x` mj"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` tj."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj",
+ "": "`x` tj"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` dana."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana",
+ "": "`x` dana"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` h."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h",
+ "": "`x` h"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` min."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min",
+ "": "`x` min"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` s."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s",
+ "": "`x` s"
},
"Fallback comments: ": "Alternativni komentari: ",
"Popular": "Popularni",
diff --git a/locales/hu-HU.json b/locales/hu-HU.json
index a0c6c17f..1c00bdbe 100644
--- a/locales/hu-HU.json
+++ b/locales/hu-HU.json
@@ -1,7 +1,16 @@
{
- "`x` subscribers": "`x` feliratkozó",
- "`x` videos": "`x` videó",
- "`x` playlists": "`x` playlist",
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` feliratkozó"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` videó"
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` playlist"
+ },
"LIVE": "ÉLŐ",
"Shared `x` ago": "`x` óta megosztva",
"Unsubscribe": "Leiratkozás",
@@ -78,7 +87,7 @@
"light": "világos",
"Thin mode: ": "Vékony mód: ",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Feliratkozási beállítások",
"Show annotations by default for subscribed channels: ": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ",
"Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ",
@@ -108,6 +117,7 @@
"Administrator preferences": "Adminisztrátor beállítások",
"Default homepage: ": "Alapértelmezett oldal: ",
"Feed menu: ": "Feed menü: ",
+ "Show nickname on top: ": "",
"Top enabled: ": "Top lista engedélyezve: ",
"CAPTCHA enabled: ": "CAPTCHA engedélyezve: ",
"Login enabled: ": "Bejelentkezés engedélyezve: ",
@@ -117,13 +127,22 @@
"Subscription manager": "Feliratkozás kezelő",
"Token manager": "Token kezelő",
"Token": "Token",
- "`x` subscriptions": "`x` feliratkozás",
- "`x` tokens": "`x` token",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` feliratkozás"
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` token"
+ },
"Import/export": "Import/export",
"unsubscribe": "leiratkozás",
"revoke": "visszavonás",
"Subscriptions": "Feliratkozások",
- "`x` unseen notifications": "`x` kimaradt érdesítés",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` kimaradt érdesítés"
+ },
"search": "keresés",
"Log out": "Kijelentkezés",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth által kiadva AGPLv3 licensz alatt.",
@@ -145,10 +164,10 @@
"Show more": "Mutass többet",
"Show less": "Mutass kevesebbet",
"Watch on YouTube": "Megtekintés a YouTube-on",
- "Hide annotations": "Szövegmagyarázat elrejtése",
- "Show annotations": "Szövegmagyarázat mutatása",
"Switch Invidious Instance": "",
"Broken? Try another Invidious Instance": "",
+ "Hide annotations": "Szövegmagyarázat elrejtése",
+ "Show annotations": "Szövegmagyarázat mutatása",
"Genre: ": "Műfaj: ",
"License: ": "Licensz: ",
"Family friendly? ": "Családbarát? ",
@@ -157,19 +176,26 @@
"Whitelisted regions: ": "Engedélyezett régiók: ",
"Blacklisted regions: ": "Tiltott régiók: ",
"Shared `x`": "Megosztva `x`",
- "`x` views": "`x` megtekintés",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` megtekintés"
+ },
"Premieres in `x`": "premierel `x` múlva",
"Premieres `x`": "`x`-t premierel",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Úgy látszik, hogy a JavaScript ki van kapcsolva a böngésződben. Kattints ide hogy megtekintsd a kommenteket, de tudd, hogy így kicsit tovább tarthat a betöltés.",
"View YouTube comments": "YouTube kommentek megtekintése",
"View more comments on Reddit": "További kommentek megtekintése Redditen",
- "View `x` comments": "`x` komment megtekintése",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` komment megtekintése"
+ },
"View Reddit comments": "Reddit kommentek megtekintése",
"Hide replies": "Válaszok elrejtése",
"Show replies": "Válaszok mutatása",
"Incorrect password": "Helytelen jelszó",
"Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés (hitelesítő vagy SMS) engedélyezve van.",
+ "Invalid TFA code": "",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés engedélyezve van.",
"Wrong answer": "Rossz válasz",
"Erroneous CAPTCHA": "Hibás CAPTCHA",
@@ -187,10 +213,16 @@
"This channel does not exist.": "Ez a csatorna nem létezik.",
"Could not get channel info.": "Nem sikerült lekérni a csatorna adatokat.",
"Could not fetch comments": "Nem sikerült lekérni a kommenteket",
- "View `x` replies": "`x` válasz megtekintése",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` válasz megtekintése"
+ },
"`x` ago": "`x` óta",
"Load more": "További betöltése",
- "`x` points": "`x` pont",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` pont"
+ },
"Could not create mix.": "Nem tudok mix-et készíteni.",
"Empty playlist": "Üres lejátszási lista",
"Not a playlist.": "Nem lejátszási lista.",
@@ -308,13 +340,34 @@
"Yiddish": "jiddis",
"Yoruba": "joruba",
"Zulu": "zulu",
- "`x` years": "`x` év",
- "`x` months": "`x` hónap",
- "`x` weeks": "`x` hét",
- "`x` days": "`x` nap",
- "`x` hours": "`x` óra",
- "`x` minutes": "`x` perc",
- "`x` seconds": "`x` másodperc",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` év"
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` hónap"
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` hét"
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` nap"
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` óra"
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` perc"
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` másodperc"
+ },
"Fallback comments: ": "Másodlagos kommentek: ",
"Popular": "Népszerű",
"Search": "Keresés",
@@ -340,6 +393,33 @@
"Videos": "Videók",
"Playlists": "Lejátszási listák",
"Community": "Közösség",
+ "relevance": "",
+ "rating": "",
+ "date": "",
+ "views": "",
+ "content_type": "",
+ "duration": "",
+ "features": "",
+ "sort": "",
+ "hour": "",
+ "today": "",
+ "week": "",
+ "month": "",
+ "year": "",
+ "video": "",
+ "channel": "",
+ "playlist": "",
+ "movie": "",
+ "show": "",
+ "hd": "",
+ "subtitles": "",
+ "creative_commons": "",
+ "3d": "",
+ "live": "",
+ "4k": "",
+ "location": "",
+ "hdr": "",
+ "filter": "",
"Current version: ": "Jelenlegi verzió: ",
"next_steps_error_message": "",
"next_steps_error_message_refresh": "",
diff --git a/locales/id.json b/locales/id.json
index 0d81ff6a..d3558396 100644
--- a/locales/id.json
+++ b/locales/id.json
@@ -1,15 +1,15 @@
{
"`x` subscribers": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan",
- "": "`x` pelanggan."
+ "": "`x` pelanggan"
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` video."
+ "": "`x` video"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar",
- "": "`x` daftar putar."
+ "": "`x` daftar putar"
},
"LIVE": "SIARAN LANGSUNG",
"Shared `x` ago": "Dibagikan`x` lalu",
@@ -117,7 +117,7 @@
"Administrator preferences": "Preferensi administrator",
"Default homepage: ": "Laman beranda default: ",
"Feed menu: ": "Menu umpan: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Tampilkan nama panggilan di atas: ",
"Top enabled: ": "Teratas diaktifkan: ",
"CAPTCHA enabled: ": "CAPTCHA diaktifkan: ",
"Login enabled: ": "Masuk diaktifkan: ",
@@ -129,11 +129,11 @@
"Token": "Token",
"`x` subscriptions": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan",
- "": "`x` langganan."
+ "": "`x` langganan"
},
"`x` tokens": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` token."
+ "": "`x` token"
},
"Import/export": "Impor/ekspor",
"unsubscribe": "batal langganan",
@@ -141,7 +141,7 @@
"Subscriptions": "Langganan",
"`x` unseen notifications": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` pemberitahuan belum dilihat",
- "": "`x` pemberitahuan belum dilihat."
+ "": "`x` pemberitahuan belum dilihat"
},
"search": "cari",
"Log out": "Keluar",
@@ -178,7 +178,7 @@
"Shared `x`": "Berbagi`x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` tampilan."
+ "": "`x` tampilan"
},
"Premieres in `x`": "Tayang dalam `x`",
"Premieres `x`": "Tayang `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Lihat lebih banyak komentar di Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Lihat`x` komentar."
+ "": "Lihat`x` komentar"
},
"View Reddit comments": "Lihat komentar Reddit",
"Hide replies": "Sembunyikan balasan",
@@ -214,14 +214,14 @@
"Could not get channel info.": "Tidak bisa mendapatkan info kanal.",
"Could not fetch comments": "Tidak dapat memuat komentar",
"View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "Lihat `x` balasan."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan",
+ "": "Lihat `x` balasan"
},
"`x` ago": "`x` lalu",
"Load more": "Muat lebih banyak",
"`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` titik."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik",
+ "": "`x` titik"
},
"Could not create mix.": "Tidak dapat membuat mix.",
"Empty playlist": "Daftar putar kosong",
@@ -341,32 +341,32 @@
"Yoruba": "Bahasa Yoruba",
"Zulu": "Bahasa Zulu",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` tahun."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun",
+ "": "`x` tahun"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` bulan."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan",
+ "": "`x` bulan"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` pekan."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan",
+ "": "`x` pekan"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` hari."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari",
+ "": "`x` hari"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` jam."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam",
+ "": "`x` jam"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` menit."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit",
+ "": "`x` menit"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` detik."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik",
+ "": "`x` detik"
},
"Fallback comments: ": "Komentar mundur: ",
"Popular": "Populer",
@@ -421,7 +421,7 @@
"hdr": "hdr",
"filter": "saring",
"Current version: ": "Versi saat ini: ",
- "next_steps_error_message": "next_steps_error_message",
+ "next_steps_error_message": "",
"next_steps_error_message_refresh": "",
"next_steps_error_message_go_to_youtube": ""
}
diff --git a/locales/lt.json b/locales/lt.json
new file mode 100644
index 00000000..68d7437a
--- /dev/null
+++ b/locales/lt.json
@@ -0,0 +1,427 @@
+{
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumeratorius",
+ "": "`x` prenumeratoriai"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vaizdo įrašas",
+ "": "`x` vaizdo įrašai"
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` grojaraštis",
+ "": "`x` grojaraščiai"
+ },
+ "LIVE": "LIVE",
+ "Shared `x` ago": "Pasidalino prieš `x`",
+ "Unsubscribe": "Atšaukti prenumeratą",
+ "Subscribe": "Prenumeruoti",
+ "View channel on YouTube": "Peržiūrėti kanalą YouTube",
+ "View playlist on YouTube": "Peržiūrėti grojaraštį YouTube",
+ "newest": "naujausia",
+ "oldest": "seniausia",
+ "popular": "populiaru",
+ "last": "paskutinis",
+ "Next page": "Kitas puslapis",
+ "Previous page": "Ankstesnis puslapis",
+ "Clear watch history?": "Išvalyti žiūrėjimo istoriją?",
+ "New password": "Naujas slaptažodis",
+ "New passwords must match": "Naujas slaptažodis turi sutapti",
+ "Cannot change password for Google accounts": "Negalima pakeisti Google paskyros slaptažodžio",
+ "Authorize token?": "Autorizuoti žetoną?",
+ "Authorize token for `x`?": "Autorizuoti žetoną `x`?",
+ "Yes": "Taip",
+ "No": "Ne",
+ "Import and Export Data": "Importuoti ir eksportuoti duomenis",
+ "Import": "Importuoti",
+ "Import Invidious data": "Importuoti Invidious duomenis",
+ "Import YouTube subscriptions": "Importuoti YouTube prenumeratas",
+ "Import FreeTube subscriptions (.db)": "Importuoti FreeTube prenumeratas (.db)",
+ "Import NewPipe subscriptions (.json)": "Importuoti NewPipe prenumeratas (.json)",
+ "Import NewPipe data (.zip)": "Importuoti NewPipe duomenis (.zip)",
+ "Export": "Eksportuoti",
+ "Export subscriptions as OPML": "Eksportuoti prenumeratas kaip OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuoti prenumeratas kaip OPML (skirta NewPipe & FreeTube)",
+ "Export data as JSON": "Eksportuoti duomenis kaip JSON",
+ "Delete account?": "Ištrinti paskyrą?",
+ "History": "Istorija",
+ "An alternative front-end to YouTube": "Alternatyvus YouTube žiūrėjimo būdas",
+ "JavaScript license information": "JavaScript licencijos informacija",
+ "source": "šaltinis",
+ "Log in": "Prisijungti",
+ "Log in/register": "Prisijungti/ registruotis",
+ "Log in with Google": "Prisijungti naudojantis Google",
+ "User ID": "Naudotojo ID",
+ "Password": "Slaptažodis",
+ "Time (h:mm:ss):": "Laikas (h:mm:ss):",
+ "Text CAPTCHA": "CAPTCHA tekstas",
+ "Image CAPTCHA": "CAPTCHA paveikslėlis",
+ "Sign In": "Prisijungti",
+ "Register": "Registruotis",
+ "E-mail": "El. paštas",
+ "Google verification code": "Google patvirtinimo kodas",
+ "Preferences": "Pasirinktys",
+ "Player preferences": "Grotuvo pasirinktys",
+ "Always loop: ": "Visada kartoti: ",
+ "Autoplay: ": "Leisti automatiškai: ",
+ "Play next by default: ": "Leisti sekantį automatiškai kaip nustatyta: ",
+ "Autoplay next video: ": "Automatiškai leisti sekantį vaizdo įrašą: ",
+ "Listen by default: ": "Klausytis kaip nustatyta: ",
+ "Proxy videos: ": "Vaizdo įrašams naudoti proxy: ",
+ "Default speed: ": "Numatytasis greitis: ",
+ "Preferred video quality: ": "Pageidaujama vaizdo kokybė: ",
+ "Player volume: ": "Grotuvo garsas: ",
+ "Default comments: ": "Numatytieji komentarai: ",
+ "youtube": "youtube",
+ "reddit": "reddit",
+ "Default captions: ": "Numatytieji subtitrai: ",
+ "Fallback captions: ": "Atsarginiai subtitrai: ",
+ "Show related videos: ": "Rodyti susijusius vaizdo įrašus: ",
+ "Show annotations by default: ": "Rodyti anotacijas pagal nutylėjimą: ",
+ "Automatically extend video description: ": "Automatiškai išplėsti vaizdo įrašo aprašymą: ",
+ "Interactive 360 degree videos: ": "Interaktyvūs 360 laipsnių vaizdo įrašai: ",
+ "Visual preferences": "Vizualinės nuostatos",
+ "Player style: ": "Vaizdo grotuvo stilius: ",
+ "Dark mode: ": "Tamsus rėžimas: ",
+ "Theme: ": "Tema: ",
+ "dark": "tamsi",
+ "light": "šviesi",
+ "Thin mode: ": "Sugretintas rėžimas: ",
+ "Miscellaneous preferences": "Įvairios nuostatos",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ",
+ "Subscription preferences": "Prenumeratų nuostatos",
+ "Show annotations by default for subscribed channels: ": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ",
+ "Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ",
+ "Number of videos shown in feed: ": "Vaizdo įrašų kiekis kanalų sąraše: ",
+ "Sort videos by: ": "Rūšiuoti vaizdo įrašus pagal: ",
+ "published": "paskelbta",
+ "published - reverse": "paskelbta - atvirkštine tvarka",
+ "alphabetically": "pagal abėcėlę",
+ "alphabetically - reverse": "pagal abėcėlę - atvirkštine tvarka",
+ "channel name": "kanalo pavadinimas",
+ "channel name - reverse": "kanalo pavadinimas - atvirkštine tvarka",
+ "Only show latest video from channel: ": "Rodyti tik naujausius vaizdo įrašus iš kanalo: ",
+ "Only show latest unwatched video from channel: ": "Rodyti tik naujausius nežiūrėtus vaizdo įrašus iš kanalo: ",
+ "Only show unwatched: ": "Rodyti tik nežiūrėtus: ",
+ "Only show notifications (if there are any): ": "Rodyti tik pranešimus (jei yra): ",
+ "Enable web notifications": "Įgalinti žiniatinklio pranešimus",
+ "`x` uploaded a video": "`x` įkėlė vaizdo įrašą",
+ "`x` is live": "`x` transliuoja tiesiogiai",
+ "Data preferences": "Duomenų parinktys",
+ "Clear watch history": "Išvalyti žiūrėjimo istoriją",
+ "Import/export data": "Importuoti/ eksportuoti duomenis",
+ "Change password": "Pakeisti slaptažodį",
+ "Manage subscriptions": "Valdyti prenumeratas",
+ "Manage tokens": "Valdyti žetonus",
+ "Watch history": "Žiūrėjimo istorija",
+ "Delete account": "Ištrinti paskyrą",
+ "Administrator preferences": "Administratoriaus nuostatos",
+ "Default homepage: ": "Numatytasis pagrindinis puslapis ",
+ "Feed menu: ": "Kanalų sąrašo meniu: ",
+ "Show nickname on top: ": "Rodyti slapyvardį viršuje: ",
+ "Top enabled: ": "Įgalinti viršų: ",
+ "CAPTCHA enabled: ": "Įgalinta CAPTCHA: ",
+ "Login enabled: ": "Įgalintas prisijungimas: ",
+ "Registration enabled: ": "Įgalinta registracija: ",
+ "Report statistics: ": "Dalintis statistika: ",
+ "Save preferences": "Išsaugoti nuostatas",
+ "Subscription manager": "Prenumeratų valdytojas",
+ "Token manager": "Žetonų valdytojas",
+ "Token": "Žetonas",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumerata",
+ "": "`x` prenumeratos"
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` žetonas",
+ "": "`x` žetonai"
+ },
+ "Import/export": "Importuoti/ eksportuoti",
+ "unsubscribe": "atšaukti prenumeratą",
+ "revoke": "atšaukti",
+ "Subscriptions": "Prenumeratos",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nematytas pranešimas",
+ "": "`x` nematyti pranešimai"
+ },
+ "search": "ieškoti",
+ "Log out": "Atsijungti",
+ "Released under the AGPLv3 by Omar Roth.": "Išleista pagal AGPLv3 - Omar Roth.",
+ "Source available here.": "Kodas prieinamas čia.",
+ "View JavaScript license information.": "Žiūrėti JavaScript licencijos informaciją.",
+ "View privacy policy.": "Žiūrėti privatumo politiką.",
+ "Trending": "Populiarūs",
+ "Public": "Viešas",
+ "Unlisted": "Neįtrauktas į sąrašą",
+ "Private": "Neviešas",
+ "View all playlists": "Žiūrėti visus grojaraščius",
+ "Updated `x` ago": "Atnaujinta prieš `x`",
+ "Delete playlist `x`?": "Ištrinti grojaraštį `x`?",
+ "Delete playlist": "Ištrinti grojaraštį",
+ "Create playlist": "Sukurti grojaraštį",
+ "Title": "Pavadinimas",
+ "Playlist privacy": "Grojaraščio privatumas",
+ "Editing playlist `x`": "Redaguojamas grojaraštis `x`",
+ "Show more": "Rodyti daugiau",
+ "Show less": "Rodyti mažiau",
+ "Watch on YouTube": "Žiaurėti Youtube",
+ "Switch Invidious Instance": "Keisti Invidious šaltinį",
+ "Broken? Try another Invidious Instance": "Neveikia? Bandyk kitą Invidious šaltinį",
+ "Hide annotations": "Slėpti anotacijas",
+ "Show annotations": "Rodyti anotacijas",
+ "Genre: ": "Žanras: ",
+ "License: ": "Licencija: ",
+ "Family friendly? ": "Draugiška šeimai? ",
+ "Wilson score: ": "Wilson taškai: ",
+ "Engagement: ": "Įsitraukimas: ",
+ "Whitelisted regions: ": "Prieinantys regionai: ",
+ "Blacklisted regions: ": "Blokuojami regionai: ",
+ "Shared `x`": "Pasidalino `x`",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` peržiūrų",
+ "": "`x` peržiūrų"
+ },
+ "Premieres in `x`": "Premjera už `x`",
+ "Premieres `x`": "Premjera`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.": "Sveiki! Atrodo, kad turite išjungę \"JavaScript\". Spauskite čia norėdami peržiūrėti komentarus, turėkite omenyje, kad jų įkėlimas gali užtrukti.",
+ "View YouTube comments": "Žiūrėti YouTube komentarus",
+ "View more comments on Reddit": "Žiūrėti daugiau komentarų Reddit",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` komentarus",
+ "": "Žiūrėti `x` komentarus"
+ },
+ "View Reddit comments": "Žiūrėti Reddit komentarus",
+ "Hide replies": "Slėpti atsakymus",
+ "Show replies": "Rodyti atsakymus",
+ "Incorrect password": "Slaptažodis neteisingas",
+ "Quota exceeded, try again in a few hours": "Viršyta kvota, bandykite dar kartą po keleto valandų",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nepavyko prisijungti, įsitikinkite, kad yra įjungta dviejų etapų autentifikacija (Autentifikatorius arba SMS).",
+ "Invalid TFA code": "Neteisingas TFA kodas",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prisijungimas nepavyko. Tai gali būti todėl, kad jūsų paskyroje nėra įjungta dviejų etapų autentifikacija.",
+ "Wrong answer": "Atsakymas neteisingas",
+ "Erroneous CAPTCHA": "Klaidinga CAPTCHA",
+ "CAPTCHA is a required field": "CAPTCHA yra reikalinga šiam laukeliui",
+ "User ID is a required field": "Vartotojo ID yra reikalingas šiam laukeliui",
+ "Password is a required field": "Slaptažodis yra reikalingas šiam laukeliui",
+ "Wrong username or password": "Neteisingas vartotojo vardas arba slaptažodis",
+ "Please sign in using 'Log in with Google'": "Prašome prisijungti naudojant \"Prisijungti su\" Google \"",
+ "Password cannot be empty": "Slaptažodžio laukelis negali būti tuščias",
+ "Password cannot be longer than 55 characters": "Slaptažodis negali būti ilgesnis nei 55 simboliai",
+ "Please log in": "Prašome prisijungti",
+ "Invidious Private Feed for `x`": "Invidious neviešas kanalų sąrašas `x`",
+ "channel:`x`": "kanalas:`x`",
+ "Deleted or invalid channel": "Panaikintas arba netinkamas kanalas",
+ "This channel does not exist.": "Šis kanalas neegzistuoja.",
+ "Could not get channel info.": "Nepavyko gauti kanalo informacijos.",
+ "Could not fetch comments": "Nepavyko atsiųsti komentarų",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` atsakymus",
+ "": "Žiūrėti `x` atsakymus"
+ },
+ "`x` ago": "`x` prieš",
+ "Load more": "Pakrauti daugiau",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` taškai",
+ "": "`x` taškai"
+ },
+ "Could not create mix.": "Nepavyko sukurti derinio.",
+ "Empty playlist": "Tuščias grojaraštis",
+ "Not a playlist.": "Ne grojaraštis.",
+ "Playlist does not exist.": "Grojaraštis neegzistuoja.",
+ "Could not pull trending pages.": "Nepavyko pritraukti 'dabar populiaru' puslapių.",
+ "Hidden field \"challenge\" is a required field": "Paslėptas laukas „iššūkis“ yra privalomas laukas",
+ "Hidden field \"token\" is a required field": "Paslėptas laukas „žetonas“ yra privalomas laukas",
+ "Erroneous challenge": "Klaidingas iššūkis",
+ "Erroneous token": "Klaidingas žetonas",
+ "No such user": "Nėra tokio vartotojo",
+ "Token is expired, please try again": "Žetonas pasibaigęs, prašome bandyti dar kartą",
+ "English": "Anglų",
+ "English (auto-generated)": "Anglų (Sugeneruota automatiškai)",
+ "Afrikaans": "Afrikans",
+ "Albanian": "Albanų",
+ "Amharic": "Amharų",
+ "Arabic": "Arabų",
+ "Armenian": "Armėnų",
+ "Azerbaijani": "Azerbaidžanų",
+ "Bangla": "Bengalų",
+ "Basque": "Baskų",
+ "Belarusian": "Baltarusių",
+ "Bosnian": "Bosnių",
+ "Bulgarian": "Bulgarų",
+ "Burmese": "Birmiečių",
+ "Catalan": "Katalonų",
+ "Cebuano": "Cebuano",
+ "Chinese (Simplified)": "Kinų (supaprastinta)",
+ "Chinese (Traditional)": "Kinų (tradicinė)",
+ "Corsican": "Korsikiečių",
+ "Croatian": "Kroatų",
+ "Czech": "Čekų",
+ "Danish": "Danų",
+ "Dutch": "Nyderlandų",
+ "Esperanto": "Esperanto",
+ "Estonian": "Estų",
+ "Filipino": "Filipiniečių",
+ "Finnish": "Suomių",
+ "French": "Prancūzų",
+ "Galician": "Galicijos",
+ "Georgian": "Sakartveliečių",
+ "German": "Vokiečių",
+ "Greek": "Graikų",
+ "Gujarati": "Gujarati",
+ "Haitian Creole": "Haičio kreolė",
+ "Hausa": "Hausa",
+ "Hawaiian": "Havajiečių",
+ "Hebrew": "Hebrajų",
+ "Hindi": "Hindi",
+ "Hmong": "Hmong",
+ "Hungarian": "Vengrų",
+ "Icelandic": "Islandų",
+ "Igbo": "Igbo",
+ "Indonesian": "Indoneziečių",
+ "Irish": "Airių",
+ "Italian": "Italų",
+ "Japanese": "Japonų",
+ "Javanese": "Javos",
+ "Kannada": "Kannada",
+ "Kazakh": "Kazachų",
+ "Khmer": "Khmerų",
+ "Korean": "Korejiėčių",
+ "Kurdish": "Kurdų",
+ "Kyrgyz": "Kirgizų",
+ "Lao": "Lao",
+ "Latin": "Lotynų",
+ "Latvian": "Latvių",
+ "Lithuanian": "Lietuvių",
+ "Luxembourgish": "Liuksemburgiečių",
+ "Macedonian": "Šiaurės makedonų",
+ "Malagasy": "Malagasi",
+ "Malay": "Malajų",
+ "Malayalam": "Malayalam",
+ "Maltese": "Maltiečių",
+ "Maori": "Maori",
+ "Marathi": "Marathi",
+ "Mongolian": "Mongolų",
+ "Nepali": "Nepaliečių",
+ "Norwegian Bokmål": "Norvegų Bokmål",
+ "Nyanja": "Nyanja",
+ "Pashto": "Paštunų",
+ "Persian": "Persų",
+ "Polish": "Lenkų",
+ "Portuguese": "Portugalų",
+ "Punjabi": "Punjabi",
+ "Romanian": "Romėnų",
+ "Russian": "Rusų",
+ "Samoan": "Samoa",
+ "Scottish Gaelic": "Škotų Gaelic",
+ "Serbian": "Serbų",
+ "Shona": "Shona",
+ "Sindhi": "Sindhi",
+ "Sinhala": "Sinhala",
+ "Slovak": "Slovakų",
+ "Slovenian": "Slovėnų",
+ "Somali": "Somaliečių",
+ "Southern Sotho": "Pietų Sotho",
+ "Spanish": "Ispanų",
+ "Spanish (Latin America)": "Ispanų (Lotynų Amerika)",
+ "Sundanese": "Sudaniečių",
+ "Swahili": "Svahili",
+ "Swedish": "Švedų",
+ "Tajik": "Tadžikų",
+ "Tamil": "Tamilų",
+ "Telugu": "Telugų",
+ "Thai": "Talaindiečių",
+ "Turkish": "Turkų",
+ "Ukrainian": "Ukrainiečių",
+ "Urdu": "Udrų",
+ "Uzbek": "Uzbekų",
+ "Vietnamese": "Vietnamiečių",
+ "Welsh": "Velso",
+ "Western Frisian": "Vakarų Fryzų",
+ "Xhosa": "Xhosa",
+ "Yiddish": "Jidiš",
+ "Yoruba": "Yorubiečių",
+ "Zulu": "Zulu",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` metus",
+ "": "`x` metus"
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mėnesį",
+ "": "`x` mėnesius"
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` savaitę",
+ "": "`x` savaites"
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dieną",
+ "": "`x` dienas"
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x`valandą",
+ "": "`x` valandas"
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutę",
+ "": "`x` minutes"
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundę",
+ "": "`x` sekundes"
+ },
+ "Fallback comments: ": "Atsarginiai komentarai: ",
+ "Popular": "Šiuo metu populiaru",
+ "Search": "Paieška",
+ "Top": "Top",
+ "About": "Apie",
+ "Rating: ": "Reitingas: ",
+ "Language: ": "Kalba: ",
+ "View as playlist": "Žiūrėti kaip grojaraštį",
+ "Default": "Numatytasis",
+ "Music": "Muzika",
+ "Gaming": "Žaidimai",
+ "News": "Naujienos",
+ "Movies": "Filmai",
+ "Download": "Atsisiųsti",
+ "Download as: ": "Atsisiųsti kaip: ",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "(edited)": "(redaguota)",
+ "YouTube comment permalink": "YouTube komentaro adresas",
+ "permalink": "adresas",
+ "`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",
+ "Playlists": "Grojaraiščiai",
+ "Community": "Bendruomenė",
+ "relevance": "Aktualumas",
+ "rating": "Reitingas",
+ "date": "Įkėlimo data",
+ "views": "Peržiūrų skaičius",
+ "content_type": "Tipas",
+ "duration": "Trukmė",
+ "features": "Funkcijos",
+ "sort": "Rūšiuoti pagal",
+ "hour": "Per paskutinę valandą",
+ "today": "Šiandien",
+ "week": "Šią savaitę",
+ "month": "Šį mėnesį",
+ "year": "Šiais metais",
+ "video": "Vaizdo įrašas",
+ "channel": "Kanalas",
+ "playlist": "Grojaraštis",
+ "movie": "Filmas",
+ "show": "Serialas",
+ "hd": "HD",
+ "subtitles": "Subtitrai/CC",
+ "creative_commons": "Creative Commons",
+ "3d": "3D",
+ "live": "Tiesiogiai",
+ "4k": "4K",
+ "location": "Vietovė",
+ "hdr": "HDR",
+ "filter": "Filtras",
+ "Current version: ": "Dabartinė versija: ",
+ "next_steps_error_message": "Po to turėtumėte pabandyti: ",
+ "next_steps_error_message_refresh": "Atnaujinti",
+ "next_steps_error_message_go_to_youtube": "Eiti į Youtube"
+}
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index 2eb30083..10723bfa 100644
--- a/locales/nb-NO.json
+++ b/locales/nb-NO.json
@@ -5,11 +5,11 @@
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer",
- "": "`x` videoer."
+ "": "`x` videoer"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` spillelister",
- "": "`x` spillelister."
+ "": "`x` spillelister"
},
"LIVE": "SANNTIDSVISNING",
"Shared `x` ago": "Delt for `x` siden",
@@ -78,7 +78,7 @@
"Show related videos: ": "Vis relaterte videoer? ",
"Show annotations by default: ": "Vis merknader som forvalg? ",
"Automatically extend video description: ": "Utvid videobeskrivelse automatisk: ",
- "Interactive 360 degree videos: ": "",
+ "Interactive 360 degree videos: ": "Interaktive 360-gradersfilmer: ",
"Visual preferences": "Visuelle innstillinger",
"Player style: ": "Avspillerstil: ",
"Dark mode: ": "Mørk drakt: ",
@@ -86,8 +86,8 @@
"dark": "Mørk",
"light": "Lys",
"Thin mode: ": "Tynt modus: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Ulike innstillinger",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ",
"Subscription preferences": "Abonnementsinnstillinger",
"Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ",
"Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Administratorinnstillinger",
"Default homepage: ": "Forvalgt hjemmeside: ",
"Feed menu: ": "Kilde-meny: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Vis kallenavn på toppen: ",
"Top enabled: ": "Topp påskrudd? ",
"CAPTCHA enabled: ": "CAPTCHA påskrudd? ",
"Login enabled: ": "Innlogging påskrudd? ",
@@ -164,8 +164,8 @@
"Show more": "Vis mer",
"Show less": "Vis mindre",
"Watch on YouTube": "Vis video på YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Bytt Invidious-instans",
+ "Broken? Try another Invidious Instance": "Knekt? Forsøk en annen Invidious-instans",
"Hide annotations": "Skjul merknader",
"Show annotations": "Vis merknader",
"Genre: ": "Sjanger: ",
@@ -178,7 +178,7 @@
"Shared `x`": "Delt `x`",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger",
- "": "`x` visninger."
+ "": "`x` visninger"
},
"Premieres in `x`": "Premiere om `x`",
"Premieres `x`": "Première `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Vis flere kommenterer på Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer",
- "": "Vis `x` kommentarer."
+ "": "Vis `x` kommentarer"
},
"View Reddit comments": "Vis Reddit-kommentarer",
"Hide replies": "Skjul svar",
@@ -215,13 +215,13 @@
"Could not fetch comments": "Kunne ikke hente kommentarer",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` svar",
- "": "Vis `x` svar."
+ "": "Vis `x` svar"
},
"`x` ago": "`x` siden",
"Load more": "Last inn flere",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` poeng",
- "": "`x` poeng."
+ "": "`x` poeng"
},
"Could not create mix.": "Kunne ikke opprette miks.",
"Empty playlist": "Spillelisten er tom",
@@ -342,31 +342,31 @@
"Zulu": "Zulu",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` år",
- "": "`x` år."
+ "": "`x` år"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` måneder",
- "": "`x` måneder."
+ "": "`x` måneder"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` uker",
- "": "`x` uker."
+ "": "`x` uker"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` dager",
- "": "`x` dager."
+ "": "`x` dager"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` timer",
- "": "`x` timer."
+ "": "`x` timer"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutter",
- "": "`x` minutter."
+ "": "`x` minutter"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder",
- "": "`x` sekunder."
+ "": "`x` sekunder"
},
"Fallback comments: ": "Tilbakefallskommentarer: ",
"Popular": "Populært",
diff --git a/locales/nl.json b/locales/nl.json
index 9597e1bd..c4948fd1 100644
--- a/locales/nl.json
+++ b/locales/nl.json
@@ -1,15 +1,15 @@
{
"`x` subscribers": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnees",
- "": "`x` abonnees."
+ "": "`x` abonnees"
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` video's",
- "": "`x` video's."
+ "": "`x` video's"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspeellijsten",
- "": "`x` afspeellijsten."
+ "": "`x` afspeellijsten"
},
"LIVE": "LIVE",
"Shared `x` ago": "Gedeeld: `x` geleden",
@@ -129,11 +129,11 @@
"Token": "Toegangssleutel",
"`x` subscriptions": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementen",
- "": "`x` abonnementen."
+ "": "`x` abonnementen"
},
"`x` tokens": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` toegangssleutels",
- "": "`x` toegangssleutels."
+ "": "`x` toegangssleutels"
},
"Import/export": "Importeren/Exporteren",
"unsubscribe": "Deabonneren",
@@ -178,7 +178,7 @@
"Shared `x`": "`x` gedeeld",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` weergaven",
- "": "`x` weergaven."
+ "": "`x` weergaven"
},
"Premieres in `x`": "Verschijnt over `x`",
"Premieres `x`": "Verschijnt op `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "Meer reacties bekijken op Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` reacties tonen",
- "": "`x` reacties tonen."
+ "": "`x` reacties tonen"
},
"View Reddit comments": "Reddit-reacties tonen",
"Hide replies": "Antwoorden verbergen",
@@ -215,13 +215,13 @@
"Could not fetch comments": "Kan reacties niet ophalen",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` antwoorden tonen",
- "": "`x` antwoorden tonen."
+ "": "`x` antwoorden tonen"
},
"`x` ago": "`x` geleden",
"Load more": "Meer laden",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` punten",
- "": "`x` punten."
+ "": "`x` punten"
},
"Could not create mix.": "Kan geen mix maken.",
"Empty playlist": "Lege afspeellijst",
@@ -342,31 +342,31 @@
"Zulu": "Zulu",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaar",
- "": "`x` jaren."
+ "": "`x` jaren"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` maanden",
- "": "`x` maanden."
+ "": "`x` maanden"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` weken",
- "": "`x` weken."
+ "": "`x` weken"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagen",
- "": "`x` dagen."
+ "": "`x` dagen"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` uur",
- "": "`x` uren."
+ "": "`x` uren"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuten",
- "": "`x` minuten."
+ "": "`x` minuten"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconden",
- "": "`x` seconden."
+ "": "`x` seconden"
},
"Fallback comments: ": "Terugvallen op ",
"Popular": "Populair",
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index 5af56ddf..478847f2 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -86,8 +86,8 @@
"dark": "escuro",
"light": "claro",
"Thin mode: ": "Modo compacto: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Preferências diversas",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ",
"Subscription preferences": "Preferências de inscrições",
"Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Preferências de administrador",
"Default homepage: ": "Página de início padrão: ",
"Feed menu: ": "Menu do feed: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Mostrar o nickname no topo: ",
"Top enabled: ": "Habilitar destaques: ",
"CAPTCHA enabled: ": "Habilitar CAPTCHA: ",
"Login enabled: ": "Habilitar login: ",
@@ -164,8 +164,8 @@
"Show more": "Mostrar mais",
"Show less": "Mostrar menos",
"Watch on YouTube": "Assistir no YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Broken? Try another Invidious Instance": "Quebrou? Tente outra Instância do Invidious",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Gênero: ",
diff --git a/locales/si.json b/locales/si.json
index cbc9bdde..f59629d0 100644
--- a/locales/si.json
+++ b/locales/si.json
@@ -87,7 +87,7 @@
"light": "",
"Thin mode: ": "",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "",
"Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "",
@@ -117,6 +117,7 @@
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
diff --git a/locales/sk.json b/locales/sk.json
index 9330232e..32df0569 100644
--- a/locales/sk.json
+++ b/locales/sk.json
@@ -1,10 +1,16 @@
{
- "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` subscribers.": "`x` odberateľov",
- "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` videos.": "",
- "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` playlists.": "",
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` odberateľov"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"LIVE": "NAŽIVO",
"Shared `x` ago": "",
"Unsubscribe": "Zrušiť odber",
@@ -81,7 +87,7 @@
"light": "svetlá",
"Thin mode: ": "Tenký režim: ",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Nastavenia predplatného",
"Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ",
"Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ",
@@ -111,6 +117,7 @@
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
@@ -120,16 +127,22 @@
"Subscription manager": "",
"Token manager": "",
"Token": "",
- "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` subscriptions.": "",
- "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` tokens.": "",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Import/export": "",
"unsubscribe": "",
"revoke": "",
"Subscriptions": "",
- "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` unseen notifications.": "",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"search": "",
"Log out": "",
"Released under the AGPLv3 by Omar Roth.": "",
@@ -163,15 +176,19 @@
"Whitelisted regions: ": "",
"Blacklisted regions: ": "",
"Shared `x`": "",
- "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` views.": "",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Premieres in `x`": "",
"Premieres `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": "",
- "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "View `x` comments.": "",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"View Reddit comments": "",
"Hide replies": "",
"Show replies": "",
@@ -196,12 +213,16 @@
"This channel does not exist.": "",
"Could not get channel info.": "",
"Could not fetch comments": "",
- "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "View `x` replies.": "",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"`x` ago": "",
"Load more": "",
- "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` points.": "",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Could not create mix.": "",
"Empty playlist": "",
"Not a playlist.": "",
@@ -319,20 +340,34 @@
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
- "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` years.": "",
- "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` months.": "",
- "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` weeks.": "",
- "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` days.": "",
- "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` hours.": "",
- "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` minutes.": "",
- "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "",
- "`x` seconds.": "",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Fallback comments: ": "",
"Popular": "",
"Search": "",
@@ -358,6 +393,33 @@
"Videos": "",
"Playlists": "",
"Community": "",
+ "relevance": "",
+ "rating": "",
+ "date": "",
+ "views": "",
+ "content_type": "",
+ "duration": "",
+ "features": "",
+ "sort": "",
+ "hour": "",
+ "today": "",
+ "week": "",
+ "month": "",
+ "year": "",
+ "video": "",
+ "channel": "",
+ "playlist": "",
+ "movie": "",
+ "show": "",
+ "hd": "",
+ "subtitles": "",
+ "creative_commons": "",
+ "3d": "",
+ "live": "",
+ "4k": "",
+ "location": "",
+ "hdr": "",
+ "filter": "",
"Current version: ": "",
"next_steps_error_message": "",
"next_steps_error_message_refresh": "",
diff --git a/locales/sr.json b/locales/sr.json
index 4835e9a3..83cc12c1 100644
--- a/locales/sr.json
+++ b/locales/sr.json
@@ -87,7 +87,7 @@
"light": "",
"Thin mode: ": "",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "",
"Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "",
@@ -117,6 +117,7 @@
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
@@ -163,6 +164,8 @@
"Show more": "",
"Show less": "",
"Watch on YouTube": "",
+ "Switch Invidious Instance": "",
+ "Broken? Try another Invidious Instance": "",
"Hide annotations": "",
"Show annotations": "",
"Genre: ": "",
diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json
index 7ac90fc8..92cfd103 100644
--- a/locales/sr_Cyrl.json
+++ b/locales/sr_Cyrl.json
@@ -1,7 +1,16 @@
{
- "`x` subscribers.": "`x` пратилац",
- "`x` videos.": "`x` видеа",
- "`x` playlists.": "`x` плејлиста/е",
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` пратилац"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` видеа"
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` плејлиста/е"
+ },
"LIVE": "УЖИВО",
"Shared `x` ago": "Објављено пре `x`",
"Unsubscribe": "Прекините праћење",
@@ -78,7 +87,7 @@
"light": "светла",
"Thin mode: ": "Узани режим: ",
"Miscellaneous preferences": "",
- "Automatically redirect to another Instance: ": "",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
"Subscription preferences": "Подешавања о праћењима",
"Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ",
"Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ",
@@ -108,6 +117,7 @@
"Administrator preferences": "Подешавања администратора",
"Default homepage: ": "Подразумевана главна страница: ",
"Feed menu: ": "Мени довода: ",
+ "Show nickname on top: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "CAPTCHA укључена?: ",
"Login enabled: ": "Пријава укључена?: ",
@@ -117,13 +127,22 @@
"Subscription manager": "Управљање праћењима",
"Token manager": "Управљање токенима",
"Token": "Токен",
- "`x` subscriptions.": "`x`праћења",
- "`x` tokens.": "`x`токена",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x`праћења"
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x`токена"
+ },
"Import/export": "Увези/извези",
"unsubscribe": "укини праћење",
"revoke": "опозови",
"Subscriptions": "Праћења",
- "`x` unseen notifications.": "`x` непрочитаних обавештења",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` непрочитаних обавештења"
+ },
"search": "претрага",
"Log out": "Одјавите се",
"Released under the AGPLv3 by Omar Roth.": "Издао Омар Рот (Omar Roth) под условима AGPLv3 лиценце.",
@@ -157,13 +176,19 @@
"Whitelisted regions: ": "Дозвољене области: ",
"Blacklisted regions: ": "Забрањене области: ",
"Shared `x`": "",
- "`x` views.": "`x` прегледа.",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": "`x` прегледа"
+ },
"Premieres in `x`": "Емитује се уживо за `x`",
"Premieres `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.": "Здраво! Изгледа да је искључен JavaScript. Кликните овде да бисте приказали коментаре. Требаће мало дуже да се учитају.",
"View YouTube comments": "Прикажи коментаре са YouTube-а",
"View more comments on Reddit": "Прикажи још коментара на Reddit-у",
- "View `x` comments.": "",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"View Reddit comments": "Прикажи коментаре са Reddit-а",
"Hide replies": "Сакриј одговоре",
"Show replies": "Прикажи одговоре",
@@ -188,10 +213,16 @@
"This channel does not exist.": "",
"Could not get channel info.": "",
"Could not fetch comments": "",
- "View `x` replies.": "",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"`x` ago": "",
"Load more": "",
- "`x` points.": "",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Could not create mix.": "",
"Empty playlist": "",
"Not a playlist.": "",
@@ -309,13 +340,34 @@
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
- "`x` years.": "",
- "`x` months.": "",
- "`x` weeks.": "",
- "`x` days.": "",
- "`x` hours.": "",
- "`x` minutes.": "",
- "`x` seconds.": "",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
"Fallback comments: ": "",
"Popular": "",
"Search": "",
@@ -341,6 +393,33 @@
"Videos": "",
"Playlists": "",
"Community": "",
+ "relevance": "",
+ "rating": "",
+ "date": "",
+ "views": "",
+ "content_type": "",
+ "duration": "",
+ "features": "",
+ "sort": "",
+ "hour": "",
+ "today": "",
+ "week": "",
+ "month": "",
+ "year": "",
+ "video": "",
+ "channel": "",
+ "playlist": "",
+ "movie": "",
+ "show": "",
+ "hd": "",
+ "subtitles": "",
+ "creative_commons": "",
+ "3d": "",
+ "live": "",
+ "4k": "",
+ "location": "",
+ "hdr": "",
+ "filter": "",
"Current version: ": "Тренутна верзија: ",
"next_steps_error_message": "",
"next_steps_error_message_refresh": "",
diff --git a/locales/tr.json b/locales/tr.json
index 1b3b55d7..5246ab6f 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -86,8 +86,8 @@
"dark": "karanlık",
"light": "aydınlık",
"Thin mode: ": "İnce mod: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "Çeşitli tercihler",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ",
"Subscription preferences": "Abonelik tercihleri",
"Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ",
"Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "Yönetici tercihleri",
"Default homepage: ": "Öntanımlı ana sayfa: ",
"Feed menu: ": "Akış menüsü: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "Takma adı üstte göster: ",
"Top enabled: ": "Top etkin: ",
"CAPTCHA enabled: ": "CAPTCHA etkin: ",
"Login enabled: ": "Oturum açma etkin: ",
@@ -164,8 +164,8 @@
"Show more": "Daha fazla göster",
"Show less": "Daha az göster",
"Watch on YouTube": "YouTube'da izle",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Invidious Örneğini Değiştir",
+ "Broken? Try another Invidious Instance": "Bozuk mu? Başka bir Invidious örneğini deneyin",
"Hide annotations": "Ek açıklamaları gizle",
"Show annotations": "Ek açıklamaları göster",
"Genre: ": "Tür: ",
@@ -177,8 +177,8 @@
"Blacklisted regions: ": "Kara listeye alınan bölgeler: ",
"Shared `x`": "`x` paylaşıldı",
"`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` görüntüleme."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme",
+ "": "`x` görüntüleme"
},
"Premieres in `x`": "`x`içinde ilk gösterim",
"Premieres `x`": "`x` ilk gösterim",
@@ -186,8 +186,8 @@
"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.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` yorumu görüntüle."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle",
+ "": "`x` yorumu görüntüle"
},
"View Reddit comments": "Reddit yorumlarını görüntüle",
"Hide replies": "Cevapları gizle",
@@ -214,14 +214,14 @@
"Could not get channel info.": "Kanal bilgisi alınamadı.",
"Could not fetch comments": "Yorumlar alınamadı",
"View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` yanıtı görüntüle."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle",
+ "": "`x` yanıtı görüntüle"
},
"`x` ago": "`x` önce",
"Load more": "Daha fazla yükle",
"`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` puan."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan",
+ "": "`x` puan"
},
"Could not create mix.": "Mix oluşturulamadı.",
"Empty playlist": "Boş oynatma listesi",
@@ -341,32 +341,32 @@
"Yoruba": "Yoruba dili",
"Zulu": "Zuluca",
"`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` yıl."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl",
+ "": "`x` yıl"
},
"`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` ay."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay",
+ "": "`x` ay"
},
"`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` hafta."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta",
+ "": "`x` hafta"
},
"`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` gün."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün",
+ "": "`x` gün"
},
"`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` saat."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat",
+ "": "`x` saat"
},
"`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` dakika."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika",
+ "": "`x` dakika"
},
"`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` saniye."
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye",
+ "": "`x` saniye"
},
"Fallback comments: ": "Yedek yorumlar: ",
"Popular": "Popüler",
@@ -393,35 +393,35 @@
"Videos": "Videolar",
"Playlists": "Oynatma listeleri",
"Community": "Topluluk",
- "relevance": "ilgi",
- "rating": "değerlendirme",
- "date": "tarih",
- "views": "görüntüleme",
- "content_type": "içerik_türü",
- "duration": "süre",
- "features": "özellikler",
- "sort": "sırala",
- "hour": "saat",
- "today": "bugün",
- "week": "hafta",
- "month": "ay",
- "year": "yıl",
- "video": "video",
- "channel": "kanal",
- "playlist": "oynatma listesi",
- "movie": "film",
- "show": "gösteri",
+ "relevance": "İlgi",
+ "rating": "Değerlendirme",
+ "date": "Yükleme tarihi",
+ "views": "Görüntüleme sayısı",
+ "content_type": "Tür",
+ "duration": "Süre",
+ "features": "Özellikler",
+ "sort": "Sıralama Ölçütü",
+ "hour": "Son Saat",
+ "today": "Bugün",
+ "week": "Bu hafta",
+ "month": "Bu ay",
+ "year": "Bu yıl",
+ "video": "Video",
+ "channel": "Kanal",
+ "playlist": "Oynatma listesi",
+ "movie": "Film",
+ "show": "Gösteri",
"hd": "HD",
- "subtitles": "alt yazılar",
+ "subtitles": "Alt yazılar",
"creative_commons": "Creative Commons",
"3d": "3B",
- "live": "canlı",
+ "live": "Canlı",
"4k": "4K",
- "location": "konum",
+ "location": "Konum",
"hdr": "HDR",
- "filter": "filtrele",
+ "filter": "Filtrele",
"Current version: ": "Şu anki sürüm: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Bundan sonra şunları denemelisiniz: ",
+ "next_steps_error_message_refresh": "Yenile",
+ "next_steps_error_message_go_to_youtube": "Youtube'a git"
}
diff --git a/locales/vi.json b/locales/vi.json
new file mode 100644
index 00000000..a3614ce9
--- /dev/null
+++ b/locales/vi.json
@@ -0,0 +1,427 @@
+{
+ "`x` subscribers": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscribers",
+ "": "`x` subscribers"
+ },
+ "`x` videos": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
+ "": ""
+ },
+ "`x` playlists": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "LIVE": "TRỰC TIẾP",
+ "Shared `x` ago": "Đã chia sẻ` x` trước",
+ "Unsubscribe": "Hủy đăng ký",
+ "Subscribe": "Đăng ký",
+ "View channel on YouTube": "Xem kênh trên YouTube",
+ "View playlist on YouTube": "Xem danh sách phát trên YouTube",
+ "newest": "mới nhất",
+ "oldest": "lâu đời nhất",
+ "popular": "phổ biến",
+ "last": "Cuối cùng",
+ "Next page": "Trang tiếp theo",
+ "Previous page": "Trang trước",
+ "Clear watch history?": "Xóa lịch sử xem?",
+ "New password": "Mật khẩu mới",
+ "New passwords must match": "Mật khẩu mới phải khớp",
+ "Cannot change password for Google accounts": "Không thể thay đổi mật khẩu cho tài khoản Google",
+ "Authorize token?": "Cấp phép mã thông báo?",
+ "Authorize token for `x`?": "Cấp phép mã thông báo cho` x`?",
+ "Yes": "Đúng",
+ "No": "Không",
+ "Import and Export Data": "Nhập và xuất dữ liệu",
+ "Import": "Nhập",
+ "Import Invidious data": "Nhập dữ liệu sống động",
+ "Import YouTube subscriptions": "Nhập đăng ký YouTube",
+ "Import FreeTube subscriptions (.db)": "Nhập đăng ký FreeTube (.db)",
+ "Import NewPipe subscriptions (.json)": "Nhập đăng ký NewPipe (.json)",
+ "Import NewPipe data (.zip)": "Nhập dữ liệu NewPipe (.zip)",
+ "Export": "Xuất",
+ "Export subscriptions as OPML": "Xuất đăng ký dưới dạng OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Xuất đăng ký dưới dạng OPML (cho NewPipe & FreeTube)",
+ "Export data as JSON": "Xuất dữ liệu dưới dạng JSON",
+ "Delete account?": "Xóa tài khoản?",
+ "History": "Lịch sử",
+ "An alternative front-end to YouTube": "Giao diện người dùng thay thế cho YouTube",
+ "JavaScript license information": "Thông tin giấy phép JavaScript",
+ "source": "nguồn",
+ "Log in": "Đăng nhập",
+ "Log in/register": "Đăng nhập / đăng ký",
+ "Log in with Google": "Đăng nhập bằng Google",
+ "User ID": "Tên người dùng",
+ "Password": "Mật khẩu",
+ "Time (h:mm:ss):": "Thời gian (h: mm: ss):",
+ "Text CAPTCHA": "Nhắn tin tới CAPTCHA",
+ "Image CAPTCHA": "Hình ảnh CAPTCHA",
+ "Sign In": "Đăng nhập",
+ "Register": "Đăng ký",
+ "E-mail": "E-mail",
+ "Google verification code": "Mã xác minh của Google",
+ "Preferences": "Sở thích",
+ "Player preferences": "Tùy chọn người chơi",
+ "Always loop: ": "Luôn lặp lại: ",
+ "Autoplay: ": "Tự chạy: ",
+ "Play next by default: ": "Phát tiếp theo theo mặc định: ",
+ "Autoplay next video: ": "Tự động phát video tiếp theo: ",
+ "Listen by default: ": "Nghe theo mặc định: ",
+ "Proxy videos: ": "Video proxy: ",
+ "Default speed: ": "Tốc độ mặc định: ",
+ "Preferred video quality: ": "Chất lượng video ưa thích: ",
+ "Player volume: ": "Khối lượng trình phát: ",
+ "Default comments: ": "Nhận xét mặc định: ",
+ "youtube": "youtube",
+ "reddit": "reddit",
+ "Default captions: ": "Phụ đề mặc định: ",
+ "Fallback captions: ": "Phụ đề dự phòng: ",
+ "Show related videos: ": "Hiển thị các video có liên quan: ",
+ "Show annotations by default: ": "Hiển thị chú thích theo mặc định: ",
+ "Automatically extend video description: ": "Tự động mở rộng mô tả video: ",
+ "Interactive 360 degree videos: ": "Video 360 độ tương tác: ",
+ "Visual preferences": "Tùy chọn hình ảnh",
+ "Player style: ": "Phong cách người chơi: ",
+ "Dark mode: ": "Chế độ tối: ",
+ "Theme: ": "Chủ đề: ",
+ "dark": "tối",
+ "light": "ánh sáng",
+ "Thin mode: ": "Chế độ mỏng: ",
+ "Miscellaneous preferences": "Tùy chọn khác",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ",
+ "Subscription preferences": "Tùy chọn đăng ký",
+ "Show annotations by default for subscribed channels: ": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ",
+ "Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ",
+ "Number of videos shown in feed: ": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ",
+ "Sort videos by: ": "Sắp xếp video theo: ",
+ "published": "được phát hành",
+ "published - reverse": "đã xuất bản - đảo ngược",
+ "alphabetically": "theo thứ tự bảng chữ cái",
+ "alphabetically - reverse": "theo thứ tự bảng chữ cái - đảo ngược",
+ "channel name": "Tên kênh",
+ "channel name - reverse": "tên kênh - đảo ngược",
+ "Only show latest video from channel: ": "Chỉ hiển thị video mới nhất từ kênh: ",
+ "Only show latest unwatched video from channel: ": "Chỉ hiển thị video chưa xem mới nhất từ kênh: ",
+ "Only show unwatched: ": "Chỉ hiển thị chưa xem: ",
+ "Only show notifications (if there are any): ": "Chỉ hiển thị thông báo (nếu có): ",
+ "Enable web notifications": "Bật thông báo web",
+ "`x` uploaded a video": "` x` đã tải lên một video",
+ "`x` is live": "` x` đang phát trực tiếp",
+ "Data preferences": "Tùy chọn dữ liệu",
+ "Clear watch history": "Xóa lịch sử xem",
+ "Import/export data": "Nhập / xuất dữ liệu",
+ "Change password": "Đổi mật khẩu",
+ "Manage subscriptions": "Quản lý các mục đăng kí",
+ "Manage tokens": "Quản lý mã thông báo",
+ "Watch history": "Lịch sử xem",
+ "Delete account": "Xóa tài khoản",
+ "Administrator preferences": "Tùy chọn quản trị viên",
+ "Default homepage: ": "Trang chủ mặc định: ",
+ "Feed menu: ": "Menu nguồn cấp dữ liệu: ",
+ "Show nickname on top: ": "Hiển thị biệt hiệu ở trên cùng: ",
+ "Top enabled: ": "Đã bật hàng đầu: ",
+ "CAPTCHA enabled: ": "Đã bật CAPTCHA: ",
+ "Login enabled: ": "Đã bật đăng nhập: ",
+ "Registration enabled: ": "Đã bật đăng ký: ",
+ "Report statistics: ": "Báo cáo thống kê: ",
+ "Save preferences": "Lưu tùy chọn",
+ "Subscription manager": "Người quản lý đăng ký",
+ "Token manager": "Trình quản lý mã thông báo",
+ "Token": "Mã thông báo",
+ "`x` subscriptions": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` tokens": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "Import/export": "",
+ "unsubscribe": "",
+ "revoke": "",
+ "Subscriptions": "",
+ "`x` unseen notifications": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "search": "Tìm kiếm",
+ "Log out": "Đăng xuất",
+ "Released under the AGPLv3 by Omar Roth.": "Được phát hành theo AGPLv3 bởi Omar Roth.",
+ "Source available here.": "Nguồn có sẵn ở đây.",
+ "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.",
+ "View privacy policy.": "Xem chính sách bảo mật.",
+ "Trending": "Xu hướng",
+ "Public": "Công cộng",
+ "Unlisted": "Riêng tư",
+ "Private": "Riêng tư",
+ "View all playlists": "Xem tất cả danh sách phát",
+ "Updated `x` ago": "Đã cập nhật` x` trước",
+ "Delete playlist `x`?": "Xóa danh sách phát` x`?",
+ "Delete playlist": "Xóa danh sách phát",
+ "Create playlist": "Tạo danh sách phát",
+ "Title": "Tiêu đề",
+ "Playlist privacy": "Bảo mật danh sách phát",
+ "Editing playlist `x`": "Chỉnh sửa danh sách phát` x`",
+ "Show more": "Cho xem nhiều hơn",
+ "Show less": "Hiện ít hơn",
+ "Watch on YouTube": "Xem trên YouTube",
+ "Switch Invidious Instance": "Chuyển phiên bản Invidious",
+ "Broken? Try another Invidious Instance": "Bị hỏng? Hãy thử một Phiên bản Invidious khác",
+ "Hide annotations": "Ẩn chú thích",
+ "Show annotations": "Hiển thị chú thích",
+ "Genre: ": "Thể loại: ",
+ "License: ": "Giấy phép: ",
+ "Family friendly? ": "Gia đình thân thiện? ",
+ "Wilson score: ": "Điểm số Wilson: ",
+ "Engagement: ": "Hôn ước: ",
+ "Whitelisted regions: ": "Các vùng nằm trong danh sách trắng: ",
+ "Blacklisted regions: ": "Khu vực nằm trong danh sách đen: ",
+ "Shared `x`": "Chia sẻ` x`",
+ "`x` views": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "Premieres in `x`": "",
+ "Premieres `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": "",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "View Reddit comments": "Xem nhận xét trên Reddit",
+ "Hide replies": "Ẩn câu trả lời",
+ "Show replies": "Hiển thị câu trả lời",
+ "Incorrect password": "Mật khẩu không đúng",
+ "Quota exceeded, try again in a few hours": "Đã vượt quá hạn ngạch, hãy thử lại sau vài giờ nữa",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Không thể đăng nhập, hãy đảm bảo rằng xác thực hai yếu tố (Authenticator hoặc SMS) được bật.",
+ "Invalid TFA code": "Mã TFA không hợp lệ",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Đăng nhập không thành công. Điều này có thể là do xác thực hai yếu tố chưa được bật cho tài khoản của bạn.",
+ "Wrong answer": "Câu trả lời sai",
+ "Erroneous CAPTCHA": "CAPTCHA bị lỗi",
+ "CAPTCHA is a required field": "CAPTCHA là trường bắt buộc",
+ "User ID is a required field": "User ID là trường bắt buộc",
+ "Password is a required field": "Mật khẩu là trường bắt buộc",
+ "Wrong username or password": "Tên người dùng hoặc mật khẩu sai",
+ "Please sign in using 'Log in with Google'": "Vui lòng đăng nhập bằng 'Đăng nhập bằng Google'",
+ "Password cannot be empty": "Mật khẩu không được để trống",
+ "Password cannot be longer than 55 characters": "Mật khẩu không được dài hơn 55 ký tự",
+ "Please log in": "Xin vui lòng đăng nhập",
+ "Invidious Private Feed for `x`": "Nguồn cấp dữ liệu riêng tư Invidious cho` x`",
+ "channel:`x`": "kênh:` x`",
+ "Deleted or invalid channel": "Kênh đã xóa hoặc không hợp lệ",
+ "This channel does not exist.": "Kênh này không tồn tại.",
+ "Could not get channel info.": "Không thể tải thông tin kênh.",
+ "Could not fetch comments": "Không thể tìm nạp nhận xét",
+ "View `x` replies": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` ago": "",
+ "Load more": "",
+ "`x` points": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "Could not create mix.": "Không thể tạo kết hợp.",
+ "Empty playlist": "Danh sách phát trống",
+ "Not a playlist.": "Không phải danh sách phát.",
+ "Playlist does not exist.": "Danh sách phát không tồn tại.",
+ "Could not pull trending pages.": "Không thể kéo các trang thịnh hành.",
+ "Hidden field \"challenge\" is a required field": "Trường ẩn \"challenge\" là trường bắt buộc",
+ "Hidden field \"token\" is a required field": "Trường ẩn \"token\" là trường bắt buộc",
+ "Erroneous challenge": "Thử thách sai",
+ "Erroneous token": "Mã thông báo bị lỗi",
+ "No such user": "Không có người dùng như vậy",
+ "Token is expired, please try again": "Token đã hết hạn, vui lòng thử lại",
+ "English": "Tiếng Anh",
+ "English (auto-generated)": "Tiếng Anh (auto-generated))",
+ "Afrikaans": "Tiếng Afrikaans",
+ "Albanian": "Tiếng Albania",
+ "Amharic": "Amharic",
+ "Arabic": "Tiếng Ả Rập",
+ "Armenian": "Tiếng Armenia",
+ "Azerbaijani": "Azerbaijan",
+ "Bangla": "Bangla",
+ "Basque": "Tiếng Basque",
+ "Belarusian": "Người Belarus",
+ "Bosnian": "Tiếng Bosnia",
+ "Bulgarian": "Tiếng Bungari",
+ "Burmese": "Tiếng Miến Điện",
+ "Catalan": "Tiếng Catalan",
+ "Cebuano": "Cebuano",
+ "Chinese (Simplified)": "Tiếng Trung (Giản thể)",
+ "Chinese (Traditional)": "Truyền thống Trung Hoa)",
+ "Corsican": "Corsican",
+ "Croatian": "Tiếng Croatia",
+ "Czech": "Tiếng Séc",
+ "Danish": "Người Đan Mạch",
+ "Dutch": "Tiếng Hà Lan",
+ "Esperanto": "Quốc tế ngữ",
+ "Estonian": "Tiếng Estonia",
+ "Filipino": "Filipino",
+ "Finnish": "Tiếng Phần Lan",
+ "French": "Người Pháp",
+ "Galician": "Tiếng Galicia",
+ "Georgian": "Tiếng Georgia",
+ "German": "Tiếng Đức",
+ "Greek": "Người Hy Lạp",
+ "Gujarati": "Gujarati",
+ "Haitian Creole": "Tiếng Creole của Haiti",
+ "Hausa": "Hausa",
+ "Hawaiian": "Tiếng Hawaii",
+ "Hebrew": "Tiếng Do Thái",
+ "Hindi": "Tiếng Hindi",
+ "Hmong": "Hmong",
+ "Hungarian": "Người Hungary",
+ "Icelandic": "Tiếng Iceland",
+ "Igbo": "Igbo",
+ "Indonesian": "Tiếng Indonesia",
+ "Irish": "Tiếng Ailen",
+ "Italian": "Người Ý",
+ "Japanese": "Tiếng Nhật",
+ "Javanese": "Tiếng Java",
+ "Kannada": "Tiếng Kannada",
+ "Kazakh": "Tiếng Kazakh",
+ "Khmer": "Tiếng Khmer",
+ "Korean": "Hàn Quốc",
+ "Kurdish": "Tiếng Kurd",
+ "Kyrgyz": "Kyrgyz",
+ "Lao": "Lào",
+ "Latin": "Latin",
+ "Latvian": "Tiếng Latvia",
+ "Lithuanian": "Tiếng Litva",
+ "Luxembourgish": "Tiếng Luxembourg",
+ "Macedonian": "Người Macedonian",
+ "Malagasy": "Malagasy",
+ "Malay": "Tiếng Mã Lai",
+ "Malayalam": "Tiếng Malayalam",
+ "Maltese": "Cây nho",
+ "Maori": "Tiếng Maori",
+ "Marathi": "Marathi",
+ "Mongolian": "Tiếng Mông Cổ",
+ "Nepali": "Tiếng Nepal",
+ "Norwegian Bokmål": "Tiếng Na Uy Bokmål",
+ "Nyanja": "Nyanja",
+ "Pashto": "Pashto",
+ "Persian": "Tiếng Ba Tư",
+ "Polish": "Đánh bóng",
+ "Portuguese": "Tiếng Bồ Đào Nha",
+ "Punjabi": "Punjabi",
+ "Romanian": "Tiếng Rumani",
+ "Russian": "Tiếng Nga",
+ "Samoan": "Samoan",
+ "Scottish Gaelic": "Tiếng Gaelic Scotland",
+ "Serbian": "Tiếng Serbia",
+ "Shona": "Shona",
+ "Sindhi": "Sindhi",
+ "Sinhala": "Sinhala",
+ "Slovak": "Tiếng Slovak",
+ "Slovenian": "Tiếng Slovenia",
+ "Somali": "Tiếng Somali",
+ "Southern Sotho": "Southern Sotho",
+ "Spanish": "Người Tây Ban Nha",
+ "Spanish (Latin America)": "Tiếng Tây Ban Nha (Mỹ Latinh)",
+ "Sundanese": "Tiếng Sundan",
+ "Swahili": "Tiếng Swahili",
+ "Swedish": "Tiếng Thụy Điển",
+ "Tajik": "Tajik",
+ "Tamil": "Tamil",
+ "Telugu": "Tiếng Telugu",
+ "Thai": "Tiếng Thái",
+ "Turkish": "Tiếng Thổ Nhĩ Kỳ",
+ "Ukrainian": "Tiếng Ukraina",
+ "Urdu": "Tiếng Urdu",
+ "Uzbek": "Tiếng Uzbek",
+ "Vietnamese": "Tiếng Việt",
+ "Welsh": "Người xứ Wales",
+ "Western Frisian": "Western Frisian",
+ "Xhosa": "Xhosa",
+ "Yiddish": "Yiddish",
+ "Yoruba": "Yoruba",
+ "Zulu": "Tiếng Zulu",
+ "`x` years": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` months": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` weeks": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` days": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` hours": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` minutes": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "`x` seconds": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "",
+ "": ""
+ },
+ "Fallback comments: ": "Nhận xét dự phòng: ",
+ "Popular": "Phổ biến",
+ "Search": "Tìm kiếm",
+ "Top": "Hàng đầu",
+ "About": "Trong khoảng",
+ "Rating: ": "Xếp hạng: ",
+ "Language: ": "Ngôn ngữ: ",
+ "View as playlist": "Xem dưới dạng danh sách phát",
+ "Default": "Mặc định",
+ "Music": "Âm nhạc",
+ "Gaming": "Trò chơi",
+ "News": "Tin tức",
+ "Movies": "Phim",
+ "Download": "Tải xuống",
+ "Download as: ": "Tải tệp dưới dạng: ",
+ "%A %B %-d, %Y": "% A% B% -d,% Y",
+ "(edited)": "(đã chỉnh sửa)",
+ "YouTube comment permalink": "Liên kết cố định nhận xét trên YouTube",
+ "permalink": "liên kết cố định",
+ "`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",
+ "Playlists": "Danh sách phát",
+ "Community": "Cộng đồng",
+ "relevance": "liên quan",
+ "rating": "Xếp hạng",
+ "date": "ngày",
+ "views": "lượt xem",
+ "content_type": "content_type",
+ "duration": "thời lượng",
+ "features": "đặc trưng",
+ "sort": "sắp xếp",
+ "hour": "giờ",
+ "today": "hôm nay",
+ "week": "tuần",
+ "month": "tháng",
+ "year": "năm",
+ "video": "video",
+ "channel": "kênh",
+ "playlist": "danh sách phát",
+ "movie": "bộ phim",
+ "show": "chỉ",
+ "hd": "hd",
+ "subtitles": "phụ đề",
+ "creative_commons": "Commons sáng tạo",
+ "3d": "3d",
+ "live": "trực tiếp",
+ "4k": "4k",
+ "location": "vị trí",
+ "hdr": "hdr",
+ "filter": "bộ lọc",
+ "Current version: ": "Phiên bản hiện tại: ",
+ "next_steps_error_message": "",
+ "next_steps_error_message_refresh": "",
+ "next_steps_error_message_go_to_youtube": ""
+}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 00e295fa..93631b3f 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -1,15 +1,15 @@
{
"`x` subscribers": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 位订阅者",
- "": "`x` 位订阅者."
+ "": "`x` 位订阅者"
},
"`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个视频",
- "": "`x` 个视频."
+ "": "`x` 个视频"
},
"`x` playlists": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个播放列表",
- "": "`x` 个播放列表。"
+ "": "`x` 个播放列表"
},
"LIVE": "直播",
"Shared `x` ago": "`x` 前分享",
@@ -87,7 +87,7 @@
"light": "亮色",
"Thin mode: ": "窄页模式: ",
"Miscellaneous preferences": "其他选项",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向(回退到redirect.invious.io): ",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向 (回退到redirect.invidious.io): ",
"Subscription preferences": "订阅设置",
"Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ",
"Redirect homepage to feed: ": "跳转主页到 feed: ",
@@ -129,11 +129,11 @@
"Token": "令牌",
"`x` subscriptions": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个订阅",
- "": "`x` 个订阅."
+ "": "`x` 个订阅"
},
"`x` tokens": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个令牌",
- "": "`x` 个令牌。"
+ "": "`x` 个令牌"
},
"Import/export": "导入/导出",
"unsubscribe": "取消订阅",
@@ -141,7 +141,7 @@
"Subscriptions": "订阅",
"`x` unseen notifications": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 条未读通知",
- "": "`x` 条未读通知。"
+ "": "`x` 条未读通知"
},
"search": "搜索",
"Log out": "登出",
@@ -178,7 +178,7 @@
"Shared `x`": "`x`发布",
"`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放",
- "": "`x` 次观看."
+ "": "`x` 次观看"
},
"Premieres in `x`": "首映于 `x` 后",
"Premieres `x`": "首映于 `x`",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "在 Reddit 查看更多评论",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条评论",
- "": "查看 `x` 条评论."
+ "": "查看 `x` 条评论"
},
"View Reddit comments": "查看 Reddit 评论",
"Hide replies": "隐藏回复",
@@ -215,13 +215,13 @@
"Could not fetch comments": "无法获取评论",
"View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条回复",
- "": "查看 `x` 条回复."
+ "": "查看 `x` 条回复"
},
"`x` ago": "`x` 前",
"Load more": "加载更多",
"`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分",
- "": "`x` 分."
+ "": "`x` 分"
},
"Could not create mix.": "无法创建合集。",
"Empty playlist": "空播放列表",
@@ -342,31 +342,31 @@
"Zulu": "祖鲁语",
"`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年",
- "": "`x` 年."
+ "": "`x` 年"
},
"`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月",
- "": "`x` 个月."
+ "": "`x` 个月"
},
"`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 周",
- "": "`x` 周."
+ "": "`x` 周"
},
"`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
- "": "`x` 天."
+ "": "`x` 天"
},
"`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小时",
- "": "`x` 小时."
+ "": "`x` 小时"
},
"`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分钟",
- "": "`x` 分钟."
+ "": "`x` 分钟"
},
"`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒",
- "": "`x` 秒."
+ "": "`x` 秒"
},
"Fallback comments: ": "后备评论: ",
"Popular": "热门频道",
@@ -395,17 +395,17 @@
"Community": "社区",
"relevance": "相关度",
"rating": "评分",
- "date": "日期",
+ "date": "上传日期",
"views": "观看次数",
- "content_type": "content_type",
+ "content_type": "类型",
"duration": "持续时间",
"features": "功能",
- "sort": "排序",
- "hour": "小时",
+ "sort": "排序依据",
+ "hour": "上个小时",
"today": "今日",
- "week": "周",
- "month": "月",
- "year": "年份",
+ "week": "本周",
+ "month": "本月",
+ "year": "今年",
"video": "视频",
"channel": "频道",
"playlist": "播放列表",
@@ -421,7 +421,7 @@
"hdr": "hdr",
"filter": "过滤器",
"Current version: ": "当前版本: ",
- "next_steps_error_message": "next_steps_error_message",
- "next_steps_error_message_refresh": "next_steps_error_message_refresh",
- "next_steps_error_message_go_to_youtube": "next_steps_error_message_go_to_youtube"
+ "next_steps_error_message": "在此之后你应尝试: ",
+ "next_steps_error_message_refresh": "刷新",
+ "next_steps_error_message_go_to_youtube": "转到 Youtube"
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index c651bcc0..e554b23a 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -71,7 +71,7 @@
"Preferred video quality: ": "偏好的影片畫質: ",
"Player volume: ": "播放器音量: ",
"Default comments: ": "預設留言: ",
- "youtube": "youtube",
+ "youtube": "YouTube",
"reddit": "reddit",
"Default captions: ": "預設字幕: ",
"Fallback captions: ": "汰退字幕: ",
@@ -86,8 +86,8 @@
"dark": "深色",
"light": "淺色",
"Thin mode: ": "精簡模式: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
+ "Miscellaneous preferences": "其他偏好設定",
+ "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動站台重新導向(汰退至 redirect.invidious.io): ",
"Subscription preferences": "訂閱偏好設定",
"Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ",
"Redirect homepage to feed: ": "重新導向首頁至 feed: ",
@@ -117,7 +117,7 @@
"Administrator preferences": "管理員偏好設定",
"Default homepage: ": "預設首頁: ",
"Feed menu: ": "Feed 選單: ",
- "Show nickname on top: ": "",
+ "Show nickname on top: ": "在頂部顯示暱稱: ",
"Top enabled: ": "頂部啟用: ",
"CAPTCHA enabled: ": "CAPTCHA 啟用: ",
"Login enabled: ": "啟用登入: ",
@@ -164,8 +164,8 @@
"Show more": "顯示更多",
"Show less": "顯示較少",
"Watch on YouTube": "在 YouTube 上觀看",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "切換 Invidious 站台",
+ "Broken? Try another Invidious Instance": "故障了嗎?試試看其他 Invidious 站台吧",
"Hide annotations": "隱藏註釋",
"Show annotations": "顯示註釋",
"Genre: ": "風格: ",
@@ -187,7 +187,7 @@
"View more comments on Reddit": "在 Reddit 上檢視更多留言",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則留言",
- "": "檢視 `x` 則留言。"
+ "": "檢視 `x` 則留言"
},
"View Reddit comments": "檢視 Reddit 留言",
"Hide replies": "隱藏回覆",
@@ -421,7 +421,7 @@
"hdr": "HDR",
"filter": "篩選條件",
"Current version: ": "目前版本: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "之後您應該嘗試: ",
+ "next_steps_error_message_refresh": "重新整理",
+ "next_steps_error_message_go_to_youtube": "到 YouTube"
}
diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr
index ed3a3d48..ada5b28f 100644
--- a/spec/helpers_spec.cr
+++ b/spec/helpers_spec.cr
@@ -5,7 +5,7 @@ require "protodec/utils"
require "spec"
require "yaml"
require "../src/invidious/helpers/*"
-require "../src/invidious/channels"
+require "../src/invidious/channels/*"
require "../src/invidious/comments"
require "../src/invidious/playlists"
require "../src/invidious/search"
diff --git a/src/invidious.cr b/src/invidious.cr
index 57809c0b..89292f05 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -27,6 +27,7 @@ require "compress/zip"
require "protodec/utils"
require "./invidious/helpers/*"
require "./invidious/*"
+require "./invidious/channels/*"
require "./invidious/routes/**"
require "./invidious/jobs/**"
diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr
deleted file mode 100644
index bbef3d4f..00000000
--- a/src/invidious/channels.cr
+++ /dev/null
@@ -1,962 +0,0 @@
-struct InvidiousChannel
- include DB::Serializable
-
- property id : String
- property author : String
- property updated : Time
- property deleted : Bool
- property subscribed : Time?
-end
-
-struct ChannelVideo
- include DB::Serializable
-
- property id : String
- property title : String
- property published : Time
- property updated : Time
- property ucid : String
- property author : String
- property length_seconds : Int32 = 0
- property live_now : Bool = false
- property premiere_timestamp : Time? = nil
- property views : Int64? = nil
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "shortVideo"
-
- json.field "title", self.title
- json.field "videoId", self.id
- json.field "videoThumbnails" do
- generate_thumbnails(json, self.id)
- end
-
- json.field "lengthSeconds", self.length_seconds
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
- json.field "published", self.published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
-
- json.field "viewCount", self.views
- end
- end
-
- def to_json(locale, json : JSON::Builder | Nil = nil)
- if json
- to_json(locale, json)
- else
- JSON.build do |json|
- to_json(locale, json)
- end
- end
- end
-
- def to_xml(locale, query_params, xml : XML::Builder)
- query_params["v"] = self.id
-
- xml.element("entry") do
- xml.element("id") { xml.text "yt:video:#{self.id}" }
- xml.element("yt:videoId") { xml.text self.id }
- xml.element("yt:channelId") { xml.text self.ucid }
- xml.element("title") { xml.text self.title }
- xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}")
-
- xml.element("author") do
- xml.element("name") { xml.text self.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
- end
-
- xml.element("content", type: "xhtml") do
- xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
- xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do
- xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
- end
- end
- end
-
- xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
- xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") }
-
- xml.element("media:group") do
- xml.element("media:title") { xml.text self.title }
- xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
- width: "320", height: "180")
- end
- end
- end
-
- def to_xml(locale, xml : XML::Builder | Nil = nil)
- if xml
- to_xml(locale, xml)
- else
- XML.build do |xml|
- to_xml(locale, xml)
- end
- end
- end
-
- def to_tuple
- {% begin %}
- {
- {{*@type.instance_vars.map { |var| var.name }}}
- }
- {% end %}
- end
-end
-
-struct AboutRelatedChannel
- include DB::Serializable
-
- property ucid : String
- property author : String
- property author_url : String
- property author_thumbnail : String
-end
-
-# TODO: Refactor into either SearchChannel or InvidiousChannel
-struct AboutChannel
- include DB::Serializable
-
- property ucid : String
- property author : String
- property auto_generated : Bool
- property author_url : String
- property author_thumbnail : String
- property banner : String?
- property description_html : String
- property paid : Bool
- property total_views : Int64
- property sub_count : Int32
- property joined : Time
- property is_family_friendly : Bool
- property allowed_regions : Array(String)
- property related_channels : Array(AboutRelatedChannel)
- property tabs : Array(String)
-end
-
-class ChannelRedirect < Exception
- property channel_id : String
-
- def initialize(@channel_id)
- end
-end
-
-def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
- finished_channel = Channel(String | Nil).new
-
- spawn do
- active_threads = 0
- active_channel = Channel(Nil).new
-
- channels.each do |ucid|
- if active_threads >= max_threads
- active_channel.receive
- active_threads -= 1
- end
-
- active_threads += 1
- spawn do
- begin
- get_channel(ucid, db, refresh, pull_all_videos)
- finished_channel.send(ucid)
- rescue ex
- finished_channel.send(nil)
- ensure
- active_channel.send(nil)
- end
- end
- end
- end
-
- final = [] of String
- channels.size.times do
- if ucid = finished_channel.receive
- final << ucid
- end
- end
-
- return final
-end
-
-def get_channel(id, db, refresh = true, pull_all_videos = true)
- if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
- if refresh && Time.utc - channel.updated > 10.minutes
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
- channel_array = channel.to_a
- args = arg_array(channel_array)
-
- db.exec("INSERT INTO channels VALUES (#{args}) \
- ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array)
- end
- else
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
- channel_array = channel.to_a
- args = arg_array(channel_array)
-
- db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array)
- end
-
- return channel
-end
-
-def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
- LOGGER.debug("fetch_channel: #{ucid}")
- LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}")
-
- LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
- rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
- LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
- rss = XML.parse_html(rss)
-
- author = rss.xpath_node(%q(//feed/title))
- if !author
- raise InfoException.new("Deleted or invalid channel")
- end
- author = author.content
-
- # Auto-generated channels
- # https://support.google.com/youtube/answer/2579942
- if author.ends_with?(" - Topic") ||
- {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
- auto_generated = true
- end
-
- LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
-
- page = 1
-
- 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)
-
- LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
- rss.xpath_nodes("//feed/entry").each do |entry|
- video_id = entry.xpath_node("videoid").not_nil!.content
- title = entry.xpath_node("title").not_nil!.content
- published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
- updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
- author = entry.xpath_node("author/name").not_nil!.content
- ucid = entry.xpath_node("channelid").not_nil!.content
- 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]?
-
- length_seconds = channel_video.try &.length_seconds
- length_seconds ||= 0
-
- live_now = channel_video.try &.live_now
- live_now ||= false
-
- premiere_timestamp = channel_video.try &.premiere_timestamp
-
- video = ChannelVideo.new({
- id: video_id,
- title: title,
- published: published,
- updated: Time.utc,
- ucid: ucid,
- author: author,
- length_seconds: length_seconds,
- live_now: live_now,
- premiere_timestamp: premiere_timestamp,
- views: views,
- })
-
- LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")
-
- # We don't include the 'premiere_timestamp' here because channel pages don't include them,
- # meaning the above timestamp is always null
- was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
- ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
- updated = $4, ucid = $5, author = $6, length_seconds = $7, \
- live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
-
- if was_insert
- LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
- db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid)
- 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
-
- # 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 = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
- ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
- updated = $4, ucid = $5, author = $6, length_seconds = $7, \
- live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
-
- db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert
- end
- end
-
- break if count < 25
- page += 1
- end
- end
-
- channel = InvidiousChannel.new({
- id: ucid,
- author: author,
- updated: Time.utc,
- deleted: false,
- subscribed: nil,
- })
-
- return channel
-end
-
-def fetch_channel_playlists(ucid, author, continuation, sort_by)
- if continuation
- response_json = request_youtube_api_browse(continuation)
- continuationItems = response_json["onResponseReceivedActions"]?
- .try &.[0]["appendContinuationItemsAction"]["continuationItems"]
-
- return [] of SearchItem, nil if !continuationItems
-
- items = [] of SearchItem
- continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item|
- extract_item(item, author, ucid).try { |t| items << t }
- }
-
- continuation = continuationItems.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
-
-def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
- object = {
- "80226972:embedded" => {
- "2:string" => ucid,
- "3:base64" => {
- "2:string" => "videos",
- "6:varint" => 2_i64,
- "7:varint" => 1_i64,
- "12:varint" => 1_i64,
- "13:string" => "",
- "23:varint" => 0_i64,
- },
- },
- }
-
- if !v2
- if auto_generated
- seed = Time.unix(1525757349)
- until seed >= Time.utc
- seed += 1.month
- end
- timestamp = seed - (page - 1).months
-
- object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64
- object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}"
- else
- object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64
- object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}"
- end
- else
- object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64
-
- object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({
- "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({
- "1:varint" => 30_i64 * (page - 1),
- }))),
- })))
- end
-
- case sort_by
- when "newest"
- when "popular"
- object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64
- when "oldest"
- object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64
- else nil # Ignore
- 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(object) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return continuation
-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"
-end
-
-# ## NOTE: DEPRECATED
-# Reason -> Unstable
-# The Protobuf object must be provided with an id of the last playlist from the current "page"
-# in order to fetch the next one accurately
-# (if the id isn't included, entries shift around erratically between pages,
-# leading to repetitions and skip overs)
-#
-# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user,
-# it's better to stick to continuation tokens provided by the first request and onward
-def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false)
- object = {
- "80226972:embedded" => {
- "2:string" => ucid,
- "3:base64" => {
- "2:string" => "playlists",
- "6:varint" => 2_i64,
- "7:varint" => 1_i64,
- "12:varint" => 1_i64,
- "13:string" => "",
- "23:varint" => 0_i64,
- },
- },
- }
-
- if cursor
- cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated
- object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
- end
-
- if auto_generated
- object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64
- else
- object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64
- case sort
- when "oldest", "oldest_created"
- object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64
- when "newest", "newest_created"
- object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64
- when "last", "last_added"
- object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64
- else nil # Ignore
- end
- end
-
- object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
- object["80226972:embedded"].delete("3:base64")
-
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
-end
-
-# TODO: Add "sort_by"
-def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
- response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en")
- if response.status_code != 200
- response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en")
- end
-
- if response.status_code != 200
- raise InfoException.new("This channel does not exist.")
- end
-
- ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
-
- if !continuation || continuation.empty?
- initial_data = extract_initial_data(response.body)
- body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
-
- if !body
- raise InfoException.new("Could not extract community tab.")
- end
-
- body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
- else
- continuation = produce_channel_community_continuation(ucid, continuation)
-
- headers = HTTP::Headers.new
- headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"]
-
- session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || ""
- post_req = {
- session_token: session_token,
- }
-
- response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
- body = JSON.parse(response.body)
-
- body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
- body["response"]["continuationContents"]["backstageCommentsContinuation"]?
-
- if !body
- raise InfoException.new("Could not extract continuation.")
- end
- end
-
- continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s
- posts = body["contents"].as_a
-
- if message = posts[0]["messageRenderer"]?
- error_message = (message["text"]["simpleText"]? ||
- message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
- .try &.as_s || ""
- raise InfoException.new(error_message)
- end
-
- response = JSON.build do |json|
- json.object do
- json.field "authorId", ucid
- json.field "comments" do
- json.array do
- posts.each do |post|
- comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
- post["backstageCommentsContinuation"]?
-
- post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
- post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
-
- next if !post
-
- content_html = post["contentText"]?.try { |t| parse_content(t) } || ""
- author = post["authorText"]?.try &.["simpleText"]? || ""
-
- json.object do
- json.field "author", author
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
- author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
-
- qualities.each do |quality|
- json.object do
- json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
- json.field "width", quality
- json.field "height", quality
- end
- end
- end
- end
-
- if post["authorEndpoint"]?
- json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
- json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
- else
- json.field "authorId", ""
- json.field "authorUrl", ""
- end
-
- published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
- published = decode_date(published_text.rchop(" (edited)"))
-
- if published_text.includes?(" (edited)")
- json.field "isEdited", true
- else
- json.field "isEdited", false
- end
-
- like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
- .try &.as_s.gsub(/\D/, "").to_i? || 0
-
- json.field "content", html_to_content(content_html)
- json.field "contentHtml", content_html
-
- json.field "published", published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
-
- json.field "likeCount", like_count
- json.field "commentId", post["postId"]? || post["commentId"]? || ""
- json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
-
- if attachment = post["backstageAttachment"]?
- json.field "attachment" do
- json.object do
- case attachment.as_h
- when .has_key?("videoRenderer")
- attachment = attachment["videoRenderer"]
- json.field "type", "video"
-
- if !attachment["videoId"]?
- error_message = (attachment["title"]["simpleText"]? ||
- attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?)
-
- json.field "error", error_message
- else
- video_id = attachment["videoId"].as_s
-
- video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?
- json.field "title", video_title
- json.field "videoId", video_id
- json.field "videoThumbnails" do
- generate_thumbnails(json, video_id)
- end
-
- json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)
-
- author_info = attachment["ownerText"]["runs"][0].as_h
-
- json.field "author", author_info["text"].as_s
- json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"]
- json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
-
- # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers"
- # TODO: json.field "authorVerified", "ownerBadges"
-
- published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s)
-
- json.field "published", published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
-
- view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
-
- json.field "viewCount", view_count
- json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
- end
- when .has_key?("backstageImageRenderer")
- attachment = attachment["backstageImageRenderer"]
- json.field "type", "image"
-
- json.field "imageThumbnails" do
- json.array do
- thumbnail = attachment["image"]["thumbnails"][0].as_h
- width = thumbnail["width"].as_i
- height = thumbnail["height"].as_i
- aspect_ratio = (width.to_f / height.to_f)
- url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
-
- qualities = {320, 560, 640, 1280, 2000}
-
- qualities.each do |quality|
- json.object do
- json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", (quality / aspect_ratio).ceil.to_i
- end
- end
- end
- end
- # TODO
- # when .has_key?("pollRenderer")
- # attachment = attachment["pollRenderer"]
- # json.field "type", "poll"
- else
- json.field "type", "unknown"
- json.field "error", "Unrecognized attachment type."
- end
- end
- end
- end
-
- if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
- comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
- .try &.as_s.gsub(/\D/, "").to_i?)
- continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
- continuation ||= ""
-
- json.field "replies" do
- json.object do
- json.field "replyCount", reply_count
- json.field "continuation", extract_channel_community_cursor(continuation)
- end
- end
- end
- end
- end
- end
- end
-
- if body["continuations"]?
- continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
- json.field "continuation", extract_channel_community_cursor(continuation)
- end
- end
- end
-
- if format == "html"
- response = JSON.parse(response)
- content_html = template_youtube_comments(response, locale, thin_mode)
-
- response = JSON.build do |json|
- json.object do
- json.field "contentHtml", content_html
- end
- end
- end
-
- return response
-end
-
-def produce_channel_community_continuation(ucid, cursor)
- object = {
- "80226972:embedded" => {
- "2:string" => ucid,
- "3:string" => cursor || "",
- },
- }
-
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return continuation
-end
-
-def extract_channel_community_cursor(continuation)
- object = URI.decode_www_form(continuation)
- .try { |i| Base64.decode(i) }
- .try { |i| IO::Memory.new(i) }
- .try { |i| Protodec::Any.parse(i) }
- .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h }
-
- if object["53:2:embedded"]?.try &.["3:0:embedded"]?
- object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"]
- .try { |i| i["2:0:base64"].as_h }
- .try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i, padding: false) }
-
- object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64")
- end
-
- cursor = Protodec::Any.cast_json(object)
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
-
- cursor
-end
-
-def get_about_info(ucid, locale)
- result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en")
- if result.status_code != 200
- result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en")
- end
-
- if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/)
- raise ChannelRedirect.new(channel_id: md["ucid"])
- end
-
- if result.status_code != 200
- raise InfoException.new("This channel does not exist.")
- end
-
- about = XML.parse_html(result.body)
- if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
- raise InfoException.new("This channel does not exist.")
- end
-
- initdata = extract_initial_data(result.body)
- if initdata.empty?
- error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
- error_message ||= translate(locale, "Could not get channel info.")
- raise InfoException.new(error_message)
- end
-
- if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
- raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s)
- end
-
- auto_generated = false
- # Check for special auto generated gaming channels
- if !initdata.has_key?("metadata")
- auto_generated = true
- end
-
- if auto_generated
- author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
- author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
- author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
-
- # Raises a KeyError on failure.
- banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
-
- description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s
- description_html = HTML.escape(description).gsub("\n", "<br>")
-
- paid = false
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s }
-
- related_channels = [] of AboutRelatedChannel
- else
- author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
- author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
- author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
-
- ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
-
- # Raises a KeyError on failure.
- banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
-
- # if banner.includes? "channels/c4/default_banner"
- # banner = nil
- # end
-
- description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || ""
- description_html = HTML.escape(description).gsub("\n", "<br>")
-
- paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True"
- is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
- allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
-
- related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"]
- .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]?
- .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node|
- renderer = node["miniChannelRenderer"]?
- related_id = renderer.try &.["channelId"]?.try &.as_s?
- related_id ||= ""
-
- related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s?
- related_title ||= ""
-
- related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]?
- .try &.["url"]?.try &.as_s?
- related_author_url ||= ""
-
- related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a?
- related_author_thumbnails ||= [] of JSON::Any
-
- related_author_thumbnail = ""
- if related_author_thumbnails.size > 0
- related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s?
- related_author_thumbnail ||= ""
- end
-
- AboutRelatedChannel.new({
- ucid: related_id,
- author: related_title,
- author_url: related_author_url,
- author_thumbnail: related_author_thumbnail,
- })
- end
- related_channels ||= [] of AboutRelatedChannel
- end
-
- 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, node| acc + node["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
- end
- tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase }
- end
-
- sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s?
- .try { |text| short_text_to_number(text.split(" ")[0]) } || 0
-
- AboutChannel.new({
- ucid: ucid,
- author: author,
- auto_generated: auto_generated,
- author_url: author_url,
- author_thumbnail: author_thumbnail,
- banner: banner,
- description_html: description_html,
- paid: paid,
- total_views: total_views,
- sub_count: sub_count,
- joined: joined,
- is_family_friendly: is_family_friendly,
- allowed_regions: allowed_regions,
- related_channels: related_channels,
- tabs: tabs,
- })
-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 request_youtube_api_browse(continuation)
-end
-
-def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
- videos = [] of SearchVideo
-
- 2.times do |i|
- initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by)
- videos.concat extract_videos(initial_data, author, ucid)
- end
-
- return videos.size, videos
-end
-
-def get_latest_videos(ucid)
- initial_data = get_channel_videos_response(ucid)
- author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s
-
- return extract_videos(initial_data, author, ucid)
-end
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
new file mode 100644
index 00000000..8b0ecfbc
--- /dev/null
+++ b/src/invidious/channels/about.cr
@@ -0,0 +1,192 @@
+# TODO: Refactor into either SearchChannel or InvidiousChannel
+struct AboutChannel
+ include DB::Serializable
+
+ property ucid : String
+ property author : String
+ property auto_generated : Bool
+ property author_url : String
+ property author_thumbnail : String
+ property banner : String?
+ property description_html : String
+ property paid : Bool
+ property total_views : Int64
+ property sub_count : Int32
+ property joined : Time
+ property is_family_friendly : Bool
+ property allowed_regions : Array(String)
+ property related_channels : Array(AboutRelatedChannel)
+ property tabs : Array(String)
+end
+
+struct AboutRelatedChannel
+ include DB::Serializable
+
+ property ucid : String
+ property author : String
+ property author_url : String
+ property author_thumbnail : String
+end
+
+def get_about_info(ucid, locale)
+ result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en")
+ if result.status_code != 200
+ result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en")
+ end
+
+ if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/)
+ raise ChannelRedirect.new(channel_id: md["ucid"])
+ end
+
+ if result.status_code != 200
+ raise InfoException.new("This channel does not exist.")
+ end
+
+ about = XML.parse_html(result.body)
+ if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
+ raise InfoException.new("This channel does not exist.")
+ end
+
+ initdata = extract_initial_data(result.body)
+ if initdata.empty?
+ error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
+ error_message ||= translate(locale, "Could not get channel info.")
+ raise InfoException.new(error_message)
+ end
+
+ if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
+ raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s)
+ end
+
+ auto_generated = false
+ # Check for special auto generated gaming channels
+ if !initdata.has_key?("metadata")
+ auto_generated = true
+ end
+
+ if auto_generated
+ author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
+ author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
+ author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
+
+ # Raises a KeyError on failure.
+ banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
+
+ description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s
+ description_html = HTML.escape(description).gsub("\n", "<br>")
+
+ paid = false
+ is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+ allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s }
+
+ related_channels = [] of AboutRelatedChannel
+ else
+ author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
+ author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
+ author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
+
+ ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
+
+ # Raises a KeyError on failure.
+ banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
+
+ # if banner.includes? "channels/c4/default_banner"
+ # banner = nil
+ # end
+
+ description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || ""
+ description_html = HTML.escape(description).gsub("\n", "<br>")
+
+ paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True"
+ is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
+ allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
+
+ related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"]
+ .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]?
+ .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node|
+ renderer = node["miniChannelRenderer"]?
+ related_id = renderer.try &.["channelId"]?.try &.as_s?
+ related_id ||= ""
+
+ related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s?
+ related_title ||= ""
+
+ related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]?
+ .try &.["url"]?.try &.as_s?
+ related_author_url ||= ""
+
+ related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a?
+ related_author_thumbnails ||= [] of JSON::Any
+
+ related_author_thumbnail = ""
+ if related_author_thumbnails.size > 0
+ related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s?
+ related_author_thumbnail ||= ""
+ end
+
+ AboutRelatedChannel.new({
+ ucid: related_id,
+ author: related_title,
+ author_url: related_author_url,
+ author_thumbnail: related_author_thumbnail,
+ })
+ end
+ related_channels ||= [] of AboutRelatedChannel
+ end
+
+ 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, node| acc + node["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
+ end
+ tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase }
+ end
+
+ sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s?
+ .try { |text| short_text_to_number(text.split(" ")[0]) } || 0
+
+ AboutChannel.new({
+ ucid: ucid,
+ author: author,
+ auto_generated: auto_generated,
+ author_url: author_url,
+ author_thumbnail: author_thumbnail,
+ banner: banner,
+ description_html: description_html,
+ paid: paid,
+ total_views: total_views,
+ sub_count: sub_count,
+ joined: joined,
+ is_family_friendly: is_family_friendly,
+ allowed_regions: allowed_regions,
+ related_channels: related_channels,
+ tabs: tabs,
+ })
+end
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
new file mode 100644
index 00000000..a6ab4015
--- /dev/null
+++ b/src/invidious/channels/channels.cr
@@ -0,0 +1,310 @@
+struct InvidiousChannel
+ include DB::Serializable
+
+ property id : String
+ property author : String
+ property updated : Time
+ property deleted : Bool
+ property subscribed : Time?
+end
+
+struct ChannelVideo
+ include DB::Serializable
+
+ property id : String
+ property title : String
+ property published : Time
+ property updated : Time
+ property ucid : String
+ property author : String
+ property length_seconds : Int32 = 0
+ property live_now : Bool = false
+ property premiere_timestamp : Time? = nil
+ property views : Int64? = nil
+
+ def to_json(locale, json : JSON::Builder)
+ json.object do
+ json.field "type", "shortVideo"
+
+ json.field "title", self.title
+ json.field "videoId", self.id
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, self.id)
+ end
+
+ json.field "lengthSeconds", self.length_seconds
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+ json.field "published", self.published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
+
+ json.field "viewCount", self.views
+ end
+ end
+
+ def to_json(locale, json : JSON::Builder | Nil = nil)
+ if json
+ to_json(locale, json)
+ else
+ JSON.build do |json|
+ to_json(locale, json)
+ end
+ end
+ end
+
+ def to_xml(locale, query_params, xml : XML::Builder)
+ query_params["v"] = self.id
+
+ xml.element("entry") do
+ xml.element("id") { xml.text "yt:video:#{self.id}" }
+ xml.element("yt:videoId") { xml.text self.id }
+ xml.element("yt:channelId") { xml.text self.ucid }
+ xml.element("title") { xml.text self.title }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}")
+
+ xml.element("author") do
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
+ end
+
+ xml.element("content", type: "xhtml") do
+ xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
+ xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do
+ xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
+ end
+ end
+ end
+
+ xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
+ xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") }
+
+ xml.element("media:group") do
+ xml.element("media:title") { xml.text self.title }
+ xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
+ width: "320", height: "180")
+ end
+ end
+ end
+
+ def to_xml(locale, xml : XML::Builder | Nil = nil)
+ if xml
+ to_xml(locale, xml)
+ else
+ XML.build do |xml|
+ to_xml(locale, xml)
+ end
+ end
+ end
+
+ def to_tuple
+ {% begin %}
+ {
+ {{*@type.instance_vars.map { |var| var.name }}}
+ }
+ {% end %}
+ end
+end
+
+class ChannelRedirect < Exception
+ property channel_id : String
+
+ def initialize(@channel_id)
+ end
+end
+
+def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
+ finished_channel = Channel(String | Nil).new
+
+ spawn do
+ active_threads = 0
+ active_channel = Channel(Nil).new
+
+ channels.each do |ucid|
+ if active_threads >= max_threads
+ active_channel.receive
+ active_threads -= 1
+ end
+
+ active_threads += 1
+ spawn do
+ begin
+ get_channel(ucid, db, refresh, pull_all_videos)
+ finished_channel.send(ucid)
+ rescue ex
+ finished_channel.send(nil)
+ ensure
+ active_channel.send(nil)
+ end
+ end
+ end
+ end
+
+ final = [] of String
+ channels.size.times do
+ if ucid = finished_channel.receive
+ final << ucid
+ end
+ end
+
+ return final
+end
+
+def get_channel(id, db, refresh = true, pull_all_videos = true)
+ if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
+ if refresh && Time.utc - channel.updated > 10.minutes
+ channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
+ channel_array = channel.to_a
+ args = arg_array(channel_array)
+
+ db.exec("INSERT INTO channels VALUES (#{args}) \
+ ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array)
+ end
+ else
+ channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
+ channel_array = channel.to_a
+ args = arg_array(channel_array)
+
+ db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array)
+ end
+
+ return channel
+end
+
+def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
+ LOGGER.debug("fetch_channel: #{ucid}")
+ LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}")
+
+ LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
+ rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
+ LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
+ rss = XML.parse_html(rss)
+
+ author = rss.xpath_node(%q(//feed/title))
+ if !author
+ raise InfoException.new("Deleted or invalid channel")
+ end
+ author = author.content
+
+ # Auto-generated channels
+ # https://support.google.com/youtube/answer/2579942
+ if author.ends_with?(" - Topic") ||
+ {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
+ auto_generated = true
+ end
+
+ LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
+
+ page = 1
+
+ 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)
+
+ LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
+ rss.xpath_nodes("//feed/entry").each do |entry|
+ video_id = entry.xpath_node("videoid").not_nil!.content
+ title = entry.xpath_node("title").not_nil!.content
+ published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
+ updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
+ author = entry.xpath_node("author/name").not_nil!.content
+ ucid = entry.xpath_node("channelid").not_nil!.content
+ 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]?
+
+ length_seconds = channel_video.try &.length_seconds
+ length_seconds ||= 0
+
+ live_now = channel_video.try &.live_now
+ live_now ||= false
+
+ premiere_timestamp = channel_video.try &.premiere_timestamp
+
+ video = ChannelVideo.new({
+ id: video_id,
+ title: title,
+ published: published,
+ updated: Time.utc,
+ ucid: ucid,
+ author: author,
+ length_seconds: length_seconds,
+ live_now: live_now,
+ premiere_timestamp: premiere_timestamp,
+ views: views,
+ })
+
+ LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")
+
+ # We don't include the 'premiere_timestamp' here because channel pages don't include them,
+ # meaning the above timestamp is always null
+ was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
+ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
+ updated = $4, ucid = $5, author = $6, length_seconds = $7, \
+ live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
+
+ if was_insert
+ LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
+ db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
+ feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid)
+ 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
+
+ # 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 = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
+ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
+ updated = $4, ucid = $5, author = $6, length_seconds = $7, \
+ live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
+
+ db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
+ feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert
+ end
+ end
+
+ break if count < 25
+ page += 1
+ end
+ end
+
+ channel = InvidiousChannel.new({
+ id: ucid,
+ author: author,
+ updated: Time.utc,
+ deleted: false,
+ subscribed: nil,
+ })
+
+ return channel
+end
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
new file mode 100644
index 00000000..97ab30ec
--- /dev/null
+++ b/src/invidious/channels/community.cr
@@ -0,0 +1,275 @@
+# TODO: Add "sort_by"
+def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
+ response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en")
+ if response.status_code != 200
+ response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en")
+ end
+
+ if response.status_code != 200
+ raise InfoException.new("This channel does not exist.")
+ end
+
+ ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
+
+ if !continuation || continuation.empty?
+ initial_data = extract_initial_data(response.body)
+ body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
+
+ if !body
+ raise InfoException.new("Could not extract community tab.")
+ end
+
+ body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
+ else
+ continuation = produce_channel_community_continuation(ucid, continuation)
+
+ headers = HTTP::Headers.new
+ headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"]
+
+ session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || ""
+ post_req = {
+ session_token: session_token,
+ }
+
+ response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
+ body = JSON.parse(response.body)
+
+ body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
+ body["response"]["continuationContents"]["backstageCommentsContinuation"]?
+
+ if !body
+ raise InfoException.new("Could not extract continuation.")
+ end
+ end
+
+ continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s
+ posts = body["contents"].as_a
+
+ if message = posts[0]["messageRenderer"]?
+ error_message = (message["text"]["simpleText"]? ||
+ message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
+ .try &.as_s || ""
+ raise InfoException.new(error_message)
+ end
+
+ response = JSON.build do |json|
+ json.object do
+ json.field "authorId", ucid
+ json.field "comments" do
+ json.array do
+ posts.each do |post|
+ comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
+ post["backstageCommentsContinuation"]?
+
+ post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
+ post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
+
+ next if !post
+
+ content_html = post["contentText"]?.try { |t| parse_content(t) } || ""
+ author = post["authorText"]?.try &.["simpleText"]? || ""
+
+ json.object do
+ json.field "author", author
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+ author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+
+ if post["authorEndpoint"]?
+ json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
+ json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
+ else
+ json.field "authorId", ""
+ json.field "authorUrl", ""
+ end
+
+ published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
+ published = decode_date(published_text.rchop(" (edited)"))
+
+ if published_text.includes?(" (edited)")
+ json.field "isEdited", true
+ else
+ json.field "isEdited", false
+ end
+
+ like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
+ .try &.as_s.gsub(/\D/, "").to_i? || 0
+
+ json.field "content", html_to_content(content_html)
+ json.field "contentHtml", content_html
+
+ json.field "published", published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
+
+ json.field "likeCount", like_count
+ json.field "commentId", post["postId"]? || post["commentId"]? || ""
+ json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
+
+ if attachment = post["backstageAttachment"]?
+ json.field "attachment" do
+ json.object do
+ case attachment.as_h
+ when .has_key?("videoRenderer")
+ attachment = attachment["videoRenderer"]
+ json.field "type", "video"
+
+ if !attachment["videoId"]?
+ error_message = (attachment["title"]["simpleText"]? ||
+ attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?)
+
+ json.field "error", error_message
+ else
+ video_id = attachment["videoId"].as_s
+
+ video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?
+ json.field "title", video_title
+ json.field "videoId", video_id
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, video_id)
+ end
+
+ json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)
+
+ author_info = attachment["ownerText"]["runs"][0].as_h
+
+ json.field "author", author_info["text"].as_s
+ json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"]
+ json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
+
+ # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers"
+ # TODO: json.field "authorVerified", "ownerBadges"
+
+ published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s)
+
+ json.field "published", published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
+
+ view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
+
+ json.field "viewCount", view_count
+ json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
+ end
+ when .has_key?("backstageImageRenderer")
+ attachment = attachment["backstageImageRenderer"]
+ json.field "type", "image"
+
+ json.field "imageThumbnails" do
+ json.array do
+ thumbnail = attachment["image"]["thumbnails"][0].as_h
+ width = thumbnail["width"].as_i
+ height = thumbnail["height"].as_i
+ aspect_ratio = (width.to_f / height.to_f)
+ url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640")
+
+ qualities = {320, 560, 640, 1280, 2000}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", url.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", (quality / aspect_ratio).ceil.to_i
+ end
+ end
+ end
+ end
+ # TODO
+ # when .has_key?("pollRenderer")
+ # attachment = attachment["pollRenderer"]
+ # json.field "type", "poll"
+ else
+ json.field "type", "unknown"
+ json.field "error", "Unrecognized attachment type."
+ end
+ end
+ end
+ end
+
+ if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
+ comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
+ .try &.as_s.gsub(/\D/, "").to_i?)
+ continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
+ continuation ||= ""
+
+ json.field "replies" do
+ json.object do
+ json.field "replyCount", reply_count
+ json.field "continuation", extract_channel_community_cursor(continuation)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ if body["continuations"]?
+ continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
+ json.field "continuation", extract_channel_community_cursor(continuation)
+ end
+ end
+ end
+
+ if format == "html"
+ response = JSON.parse(response)
+ content_html = template_youtube_comments(response, locale, thin_mode)
+
+ response = JSON.build do |json|
+ json.object do
+ json.field "contentHtml", content_html
+ end
+ end
+ end
+
+ return response
+end
+
+def produce_channel_community_continuation(ucid, cursor)
+ object = {
+ "80226972:embedded" => {
+ "2:string" => ucid,
+ "3:string" => cursor || "",
+ },
+ }
+
+ continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ return continuation
+end
+
+def extract_channel_community_cursor(continuation)
+ object = URI.decode_www_form(continuation)
+ .try { |i| Base64.decode(i) }
+ .try { |i| IO::Memory.new(i) }
+ .try { |i| Protodec::Any.parse(i) }
+ .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h }
+
+ if object["53:2:embedded"]?.try &.["3:0:embedded"]?
+ object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"]
+ .try { |i| i["2:0:base64"].as_h }
+ .try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i, padding: false) }
+
+ object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64")
+ end
+
+ cursor = Protodec::Any.cast_json(object)
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+
+ cursor
+end
diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr
new file mode 100644
index 00000000..222ec2b1
--- /dev/null
+++ b/src/invidious/channels/playlists.cr
@@ -0,0 +1,93 @@
+def fetch_channel_playlists(ucid, author, continuation, sort_by)
+ if continuation
+ response_json = request_youtube_api_browse(continuation)
+ continuationItems = response_json["onResponseReceivedActions"]?
+ .try &.[0]["appendContinuationItemsAction"]["continuationItems"]
+
+ return [] of SearchItem, nil if !continuationItems
+
+ items = [] of SearchItem
+ continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item|
+ extract_item(item, author, ucid).try { |t| items << t }
+ }
+
+ continuation = continuationItems.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
+ else
+ object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64
+ case sort
+ when "oldest", "oldest_created"
+ object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64
+ when "newest", "newest_created"
+ object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64
+ when "last", "last_added"
+ object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64
+ else nil # Ignore
+ end
+ end
+
+ object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
+ object["80226972:embedded"].delete("3:base64")
+
+ continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
+end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
new file mode 100644
index 00000000..cc291e9e
--- /dev/null
+++ b/src/invidious/channels/videos.cr
@@ -0,0 +1,89 @@
+def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
+ object = {
+ "80226972:embedded" => {
+ "2:string" => ucid,
+ "3:base64" => {
+ "2:string" => "videos",
+ "6:varint" => 2_i64,
+ "7:varint" => 1_i64,
+ "12:varint" => 1_i64,
+ "13:string" => "",
+ "23:varint" => 0_i64,
+ },
+ },
+ }
+
+ if !v2
+ if auto_generated
+ seed = Time.unix(1525757349)
+ until seed >= Time.utc
+ seed += 1.month
+ end
+ timestamp = seed - (page - 1).months
+
+ object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64
+ object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}"
+ else
+ object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64
+ object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}"
+ end
+ else
+ object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64
+
+ object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({
+ "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({
+ "1:varint" => 30_i64 * (page - 1),
+ }))),
+ })))
+ end
+
+ case sort_by
+ when "newest"
+ when "popular"
+ object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64
+ when "oldest"
+ object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64
+ else nil # Ignore
+ 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(object) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ 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 request_youtube_api_browse(continuation)
+end
+
+def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
+ videos = [] of SearchVideo
+
+ 2.times do |i|
+ initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by)
+ videos.concat extract_videos(initial_data, author, ucid)
+ end
+
+ return videos.size, videos
+end
+
+def get_latest_videos(ucid)
+ initial_data = get_channel_videos_response(ucid)
+ author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s
+
+ return extract_videos(initial_data, author, ucid)
+end
+
+# Used in bypass_captcha_job.cr
+def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
+ continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2)
+ return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
+end
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index 7353f2d9..d332ad37 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -700,22 +700,12 @@ def proxy_file(response, env)
end
end
-# See https://github.com/kemalcr/kemal/pull/576
-class HTTP::Server::Response::Output
- def close
- return if closed?
-
- unless response.wrote_headers?
- response.content_length = @out_count
- end
-
- ensure_headers_written
-
- super
-
- if @chunked
- @io << "0\r\n\r\n"
+class HTTP::Server::Response
+ class Output
+ private def unbuffered_flush
@io.flush
+ rescue ex : IO::Error
+ unbuffered_close
end
end
end
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index dd46feab..d5e06b25 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -1,31 +1,42 @@
LOCALES = {
- "ar" => load_locale("ar"),
- "de" => load_locale("de"),
- "el" => load_locale("el"),
- "en-US" => load_locale("en-US"),
- "eo" => load_locale("eo"),
- "es" => load_locale("es"),
- "fa" => load_locale("fa"),
- "fi" => load_locale("fi"),
- "fr" => load_locale("fr"),
- "he" => load_locale("he"),
- "hr" => load_locale("hr"),
- "id" => load_locale("id"),
- "is" => load_locale("is"),
- "it" => load_locale("it"),
- "ja" => load_locale("ja"),
- "nb-NO" => load_locale("nb-NO"),
- "nl" => load_locale("nl"),
- "pl" => load_locale("pl"),
- "pt-BR" => load_locale("pt-BR"),
- "pt-PT" => load_locale("pt-PT"),
- "ro" => load_locale("ro"),
- "ru" => load_locale("ru"),
- "sv-SE" => load_locale("sv-SE"),
- "tr" => load_locale("tr"),
- "uk" => load_locale("uk"),
- "zh-CN" => load_locale("zh-CN"),
- "zh-TW" => load_locale("zh-TW"),
+ "ar" => load_locale("ar"), # Arabic
+ "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh)
+ "cs" => load_locale("cs"), # Czech
+ "da" => load_locale("da"), # Danish
+ "de" => load_locale("de"), # German
+ "el" => load_locale("el"), # Greek
+ "en-US" => load_locale("en-US"), # English (US)
+ "eo" => load_locale("eo"), # Esperanto
+ "es" => load_locale("es"), # Spanish
+ "eu" => load_locale("eu"), # Basque
+ "fa" => load_locale("fa"), # Persian
+ "fi" => load_locale("fi"), # Finnish
+ "fr" => load_locale("fr"), # French
+ "he" => load_locale("he"), # Hebrew
+ "hr" => load_locale("hr"), # Croatian
+ "hu-HU" => load_locale("hu-HU"), # Hungarian
+ "id" => load_locale("id"), # Indonesian
+ "is" => load_locale("is"), # Icelandic
+ "it" => load_locale("it"), # Italian
+ "ja" => load_locale("ja"), # Japanese
+ "lt" => load_locale("lt"), # Lithuanian
+ "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål
+ "nl" => load_locale("nl"), # Dutch
+ "pl" => load_locale("pl"), # Polish
+ "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil)
+ "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal)
+ "ro" => load_locale("ro"), # Romanian
+ "ru" => load_locale("ru"), # Russian
+ "si" => load_locale("si"), # Sinhala
+ "sk" => load_locale("sk"), # Slovak
+ "sr" => load_locale("sr"), # Serbian
+ "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic)
+ "sv-SE" => load_locale("sv-SE"), # Swedish
+ "tr" => load_locale("tr"), # Turkish
+ "uk" => load_locale("uk"), # Ukrainian
+ "vi" => load_locale("vi"), # Vietnamese
+ "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified)
+ "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional)
}
def load_locale(name)
diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr
index e27d4088..734fddcd 100644
--- a/src/invidious/helpers/youtube_api.cr
+++ b/src/invidious/helpers/youtube_api.cr
@@ -25,12 +25,14 @@ end
####################################################################
# request_youtube_api_browse(continuation)
-# request_youtube_api_browse(browse_id, params)
+# request_youtube_api_browse(browse_id, params, region)
#
# Requests the youtubei/v1/browse endpoint with the required headers
-# and POST data in order to get a JSON reply in english US that can
+# and POST data in order to get a JSON reply in english that can
# be easily parsed.
#
+# The region can be provided, default is US.
+#
# The requested data can either be:
#
# - A continuation token (ctoken). Depending on this token's
@@ -49,11 +51,11 @@ def request_youtube_api_browse(continuation : String)
return _youtube_api_post_json("/youtubei/v1/browse", data)
end
-def request_youtube_api_browse(browse_id : String, params : String)
+def request_youtube_api_browse(browse_id : String, params : String, region : String = "US")
# JSON Request data, required by the API
data = {
"browseId" => browse_id,
- "context" => make_youtube_api_context("US"),
+ "context" => make_youtube_api_context(region),
}
# Append the additionnal parameters if those were provided
diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr
index 87cf7688..71f8a938 100644
--- a/src/invidious/jobs/bypass_captcha_job.cr
+++ b/src/invidious/jobs/bypass_captcha_job.cr
@@ -2,7 +2,11 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
def begin
loop do
begin
- {"/watch?v=zj82_v2R6ts&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCK87Lox575O_HCHBWaBSyGA")}.each do |path|
+ random_video = PG_DB.query_one?("select id, ucid from (select id, ucid from channel_videos limit 1000) as s ORDER BY RANDOM() LIMIT 1", as: {id: String, ucid: String})
+ if !random_video
+ random_video = {id: "zj82_v2R6ts", ucid: "UCK87Lox575O_HCHBWaBSyGA"}
+ end
+ {"/watch?v=#{random_video["id"]}&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: random_video["ucid"])}.each do |path|
response = YT_POOL.client &.get(path)
if response.body.includes?("To continue with your YouTube experience, please fill out the form below.")
html = XML.parse_html(response.body)
diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr
index 910a99d8..2ab1e7ba 100644
--- a/src/invidious/trending.cr
+++ b/src/invidious/trending.cr
@@ -2,31 +2,19 @@ def fetch_trending(trending_type, region, locale)
region ||= "US"
region = region.upcase
- trending = ""
plid = nil
- if trending_type && trending_type != "Default"
- if trending_type == "Music"
- trending_type = 1
- elsif trending_type == "Gaming"
- trending_type = 2
- elsif trending_type == "Movies"
- trending_type = 3
- end
-
- response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body
-
- initial_data = extract_initial_data(response)
- url = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][trending_type]["tabRenderer"]["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
- url = "#{url}&gl=#{region}&hl=en"
-
- trending = YT_POOL.client &.get(url).body
- plid = extract_plid(url)
- else
- trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body
+ if trending_type == "Music"
+ params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D"
+ elsif trending_type == "Gaming"
+ params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D"
+ elsif trending_type == "Movies"
+ params = "4gIKGgh0cmFpbGVycw%3D%3D"
+ else # Default
+ params = ""
end
- initial_data = extract_initial_data(trending)
+ initial_data = request_youtube_api_browse("FEtrending", params: params, region: region)
trending = extract_videos(initial_data)
return {trending, plid}
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index 21038394..dd2807de 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -21,14 +21,16 @@
</div>
</div>
<div class="pure-u-1-3" style="text-align:right">
- <h3>
+ <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">
- <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
+ <div id="descriptionWrapper">
+ <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
+ </div>
</div>
<div class="h-box">
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index b0092e5f..96976271 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -20,14 +20,16 @@
</div>
</div>
<div class="pure-u-1-3" style="text-align:right">
- <h3>
+ <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">
- <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p>
+ <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">
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 6f027bee..7fbefc38 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -2,13 +2,13 @@
<div class="h-box">
<% case item when %>
<% when SearchChannel %>
- <a style="width:100%" href="/channel/<%= item.ucid %>">
+ <a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<center>
<img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
</center>
<% end %>
- <p><%= item.author %></p>
+ <p dir="auto"><%= HTML.escape(item.author) %></p>
</a>
<p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p>
<% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %>
@@ -27,15 +27,13 @@
<p class="length"><%= number_with_separator(item.video_count) %> videos</p>
</div>
<% end %>
- <p><%= item.title %></p>
+ <p dir="auto"><%= HTML.escape(item.title) %></p>
+ </a>
+ <a href="/channel/<%= item.ucid %>">
+ <p dir=auto"><b><%= HTML.escape(item.author) %></b></p>
</a>
- <p>
- <b>
- <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a>
- </b>
- </p>
<% when MixVideo %>
- <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
+ <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
@@ -44,13 +42,11 @@
<% end %>
</div>
<% end %>
- <p><%= HTML.escape(item.title) %></p>
+ <p dir="auto"><%= HTML.escape(item.title) %></p>
+ </a>
+ <a href="/channel/<%= item.ucid %>">
+ <p dir=auto"><b><%= HTML.escape(item.author) %></b></p>
</a>
- <p>
- <b>
- <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a>
- </b>
- </p>
<% when PlaylistVideo %>
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
@@ -76,30 +72,33 @@
<% end %>
</div>
<% end %>
- <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p>
+ <p dir="auto"><%= HTML.escape(item.title) %></p>
</a>
- <p>
- <b>
- <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a>
- </b>
- </p>
- <h5 class="pure-g">
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
- <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div>
- <% elsif Time.utc - item.published > 1.minute %>
- <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div>
- <% else %>
- <div class="pure-u-2-3"></div>
- <% end %>
+ <div class="video-card-row flexible">
+ <div class="flex-left"><a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir=auto"><%= HTML.escape(item.author) %></p>
+ </a></div>
+ </div>
+
+ <div class="video-card-row flexible">
+ <div class="flex-left">
+ <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
+ <p dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
+ <% elsif Time.utc - item.published > 1.minute %>
+ <p dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
+ <% end %>
+ </div>
- <div class="pure-u-1-3" style="text-align:right">
- <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %>
+ <% if item.responds_to?(:views) && item.views %>
+ <div class="flex-right">
+ <p dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p>
</div>
- </h5>
+ <% end %>
+ </div>
<% else %>
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <a style="width:100%" href="/watch?v=<%= item.id %>">
+ <a style="width:100%" href="/watch?v=<%= item.id %>">
+ <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %>
@@ -129,44 +128,49 @@
<% end %>
<% if item.responds_to?(:live_now) && item.live_now %>
- <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
+ <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
<% elsif item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %>
</div>
- </a>
- <% end %>
- <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p>
- <div style="display: flex">
- <b style="flex: 1;">
- <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a>
- </b>
- <div class="icon-buttons">
- <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>">
- <i class="icon ion-logo-youtube"></i>
- </a>
- <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&amp;listen=1">
- <i class="icon ion-md-headset"></i>
- </a>
- <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>">
- <i class="icon ion-md-jet"></i>
- </a>
+ <% end %>
+ <p dir="auto"><%= HTML.escape(item.title) %></p>
+ </a>
+
+ <div class="video-card-row flexible">
+ <div class="flex-left"><a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir=auto"><%= HTML.escape(item.author) %></p>
+ </a></div>
+ <div class="flex-right">
+ <div class="icon-buttons">
+ <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>">
+ <i class="icon ion-logo-youtube"></i>
+ </a>
+ <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&amp;listen=1">
+ <i class="icon ion-md-headset"></i>
+ </a>
+ <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>">
+ <i class="icon ion-md-jet"></i>
+ </a>
+ </div>
</div>
</div>
- <h5 class="pure-g">
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
- <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div>
- <% elsif Time.utc - item.published > 1.minute %>
- <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div>
- <% else %>
- <div class="pure-u-2-3"></div>
- <% end %>
+ <div class="video-card-row flexible">
+ <div class="flex-left">
+ <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
+ <p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
+ <% elsif Time.utc - item.published > 1.minute %>
+ <p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
+ <% end %>
+ </div>
- <div class="pure-u-1-3" style="text-align:right">
- <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %>
+ <% if item.responds_to?(:views) && item.views %>
+ <div class="flex-right">
+ <p class="video-data" dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p>
</div>
- </h5>
+ <% end %>
+ </div>
<% end %>
</div>
</div>
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr
index a19dd182..377da20f 100644
--- a/src/invidious/views/playlist.ecr
+++ b/src/invidious/views/playlist.ecr
@@ -64,7 +64,9 @@
</div>
<div class="h-box">
- <p><%= playlist.description_html %></p>
+ <div id="descriptionWrapper">
+ <p><%= playlist.description_html %></p>
+ </div>
</div>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
index 975ccd6c..3f892650 100644
--- a/src/invidious/views/playlists.ecr
+++ b/src/invidious/views/playlists.ecr
@@ -20,14 +20,16 @@
</div>
</div>
<div class="pure-u-1-3" style="text-align:right">
- <h3>
+ <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">
- <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 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">
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index f21fdb04..c553dc0e 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -30,11 +30,11 @@
we're going to need to do it here in order to allow for translations.
-->
<style>
-#descexpansionbutton + label > a::after {
+#descexpansionbutton ~ label > a::after {
content: "<%= translate(locale, "Show more") %>"
}
-#descexpansionbutton:checked + label > a::after {
+#descexpansionbutton:checked ~ label > a::after {
content: "<%= translate(locale, "Show less") %>"
}
</style>
@@ -246,15 +246,17 @@ we're going to need to do it here in order to allow for translations.
<div id="description-box"> <!-- Description -->
<% if video.description.size < 200 || params.extend_desc %>
- <%= video.description_html %>
+ <div id="descriptionWrapper">
+ <%= video.description_html %>
+ </div>
<% else %>
<input id="descexpansionbutton" type="checkbox"/>
- <label for="descexpansionbutton" style="order: 1;">
- <a></a>
- </label>
<div id="descriptionWrapper">
<%= video.description_html %>
</div>
+ <label for="descexpansionbutton">
+ <a></a>
+ </label>
<% end %>
</div>