diff --git a/.github/workflows/auto-close-duplicate.yaml b/.github/workflows/auto-close-duplicate.yaml new file mode 100644 index 00000000..2eea099e --- /dev/null +++ b/.github/workflows/auto-close-duplicate.yaml @@ -0,0 +1,37 @@ +name: Close duplicates +on: + issues: + types: [opened] +jobs: + run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - uses: iv-org/close-potential-duplicates@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Issue title filter work with anymatch https://www.npmjs.com/package/anymatch. + # Any matched issue will stop detection immediately. + # You can specify multi filters in each line. + filter: '' + # Exclude keywords in title before detecting. + exclude: '' + # Label to set, when potential duplicates are detected. + label: duplicate + # Get issues with state to compare. Supported state: 'all', 'closed', 'open'. + state: open + # If similarity is higher than this threshold([0,1]), issue will be marked as duplicate. + threshold: 0.9 + # Reactions to be add to comment when potential duplicates are detected. + # Available reactions: "-1", "+1", "confused", "laugh", "heart", "hooray", "rocket", "eyes" + reactions: '' + close: true + # Comment to post when potential duplicates are detected. + comment: | + Hello, your issue is a duplicate of this/these issue(s): {{#issues}} + - #{{ number }} [accuracy: {{ accuracy }}%] + {{/issues}} + + If this is a mistake please explain why and ping @\unixfox, @\SamantazFox and @\TheFrenchGhosty. + + Please refrain from opening new issues, it won't help in solving your problem. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aa334c9..96903fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,11 @@ jobs: matrix: stable: [true] crystal: - - 1.3.2 - 1.4.1 - 1.5.1 - 1.6.2 + - 1.7.3 + - 1.8.1 include: - crystal: nightly stable: false diff --git a/Makefile b/Makefile index 29be727c..d4657792 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ ifeq ($(DISABLE_QUIC), 1) FLAGS += -Ddisable_quic endif +ifeq ($(API_ONLY), 1) + FLAGS += -Dapi_only +endif + # ----------------------- # Main @@ -82,6 +86,7 @@ clean: distclean: clean rm -rf libs + rm -rf ~/.cache/{crystal,shards} # ----------------------- @@ -106,11 +111,12 @@ help: @echo "" @echo "Build options available for this Makefile:" @echo "" - @echo " RELEASE Make a release build (Default: 1)" - @echo " STATIC Link libraries statically (Default: 0)" + @echo " RELEASE Make a release build (Default: 1)" + @echo " STATIC Link libraries statically (Default: 0)" @echo "" - @echo " DISABLE_QUIC Disable support for QUIC (Default: 0)" - @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" + @echo " API_ONLY Build invidious without a GUI (Default: 0)" + @echo " DISABLE_QUIC Disable support for QUIC (Default: 0)" + @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" diff --git a/README.md b/README.md index 602ad2e2..88770383 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,9 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab, - [WatchTube](https://github.com/WatchTubeTeam/WatchTube): Powerful YouTube client for Apple Watch. - [Yattee](https://github.com/yattee/yattee): Alternative YouTube frontend for iPhone, iPad, Mac and Apple TV. - [TubiTui](https://codeberg.org/777/TubiTui): A lightweight, libre, TUI-based YouTube client. -- [Ytfzf](https://github.com/pystardust/ytfzf): A posix script to find and watch youtube videos from the terminal. (Without API) -- [Playlet](https://github.com/iBicha/playlet): Unofficial Youtube client for Roku TV -- [Clipious](https://github.com/lamarios/clipious): Unofficial Invidious client for Android +- [Ytfzf](https://github.com/pystardust/ytfzf): A posix script to find and watch youtube videos from the terminal. (Without API). +- [Playlet](https://github.com/iBicha/playlet): Unofficial Youtube client for Roku TV. +- [Clipious](https://github.com/lamarios/clipious): Unofficial Invidious client for Android. ## Liability diff --git a/assets/css/default.css b/assets/css/default.css index 88ec6ef1..431a0427 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -46,6 +46,7 @@ body a.channel-owner { } .creator-heart { + display: inline-block; position: relative; width: 16px; height: 16px; @@ -66,6 +67,7 @@ body a.channel-owner { } .creator-heart-small-container { + display: block; position: relative; width: 13px; height: 13px; @@ -119,13 +121,16 @@ body a.pure-button { button.pure-button-primary, body a.pure-button-primary, -.channel-owner:hover { +.channel-owner:hover, +.channel-owner:focus { background-color: #a0a0a0; color: rgba(35, 35, 35, 1); } button.pure-button-primary:hover, -body a.pure-button-primary:hover { +body a.pure-button-primary:hover, +button.pure-button-primary:focus, +body a.pure-button-primary:focus { background-color: rgba(0, 182, 240, 1); color: #fff; } @@ -227,6 +232,7 @@ div.watched-indicator { border-radius: 0; box-shadow: none; + appearance: none; -webkit-appearance: none; } @@ -317,6 +323,30 @@ p.channel-name { margin: 0; } p.video-data { margin: 0; font-weight: bold; font-size: 80%; } +/* + * Comments & community posts + */ + +#comments { + max-width: 800px; + margin: auto; +} + +.video-iframe-wrapper { + position: relative; + height: 0; + padding-bottom: 56.25%; +} + +.video-iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} + /* * Footer */ @@ -365,11 +395,14 @@ span > select { .light-theme a:hover, .light-theme a:active, -.light-theme summary:hover { +.light-theme summary:hover, +.light-theme a:focus, +.light-theme summary:focus { color: #075A9E !important; } -.light-theme a.pure-button-primary:hover { +.light-theme a.pure-button-primary:hover, +.light-theme a.pure-button-primary:focus { color: #fff !important; } @@ -392,11 +425,14 @@ span > select { @media (prefers-color-scheme: light) { .no-theme a:hover, .no-theme a:active, - .no-theme summary:hover { + .no-theme summary:hover, + .no-theme a:focus, + .no-theme summary:focus { color: #075A9E !important; } - .no-theme a.pure-button-primary:hover { + .no-theme a.pure-button-primary:hover, + .no-theme a.pure-button-primary:focus { color: #fff !important; } @@ -423,7 +459,9 @@ span > select { .dark-theme a:hover, .dark-theme a:active, -.dark-theme summary:hover { +.dark-theme summary:hover, +.dark-theme a:focus, +.dark-theme summary:focus { color: rgb(0, 182, 240); } @@ -462,7 +500,8 @@ body.dark-theme { @media (prefers-color-scheme: dark) { .no-theme a:hover, - .no-theme a:active { + .no-theme a:active, + .no-theme a:focus { color: rgb(0, 182, 240); } diff --git a/assets/css/embed.css b/assets/css/embed.css index 466a284a..cbafcfea 100644 --- a/assets/css/embed.css +++ b/assets/css/embed.css @@ -21,6 +21,7 @@ color: white; } -.watch-on-invidious > a:hover { +.watch-on-invidious > a:hover, +.watch-on-invidious > a:focus { color: rgba(0, 182, 240, 1);; } diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js index 7c50670e..8e18169e 100644 --- a/assets/js/_helpers.js +++ b/assets/js/_helpers.js @@ -6,6 +6,7 @@ Array.prototype.find = Array.prototype.find || function (condition) { return this.filter(condition)[0]; }; + Array.from = Array.from || function (source) { return Array.prototype.slice.call(source); }; @@ -201,15 +202,19 @@ window.helpers = window.helpers || { if (localStorageIsUsable) { return { get: function (key) { - if (!localStorage[key]) return; + let storageItem = localStorage.getItem(key) + if (!storageItem) return; try { - return JSON.parse(decodeURIComponent(localStorage[key])); + return JSON.parse(decodeURIComponent(storageItem)); } catch(e) { // Erase non parsable value helpers.storage.remove(key); } }, - set: function (key, value) { localStorage[key] = encodeURIComponent(JSON.stringify(value)); }, + set: function (key, value) { + let encoded_value = encodeURIComponent(JSON.stringify(value)) + localStorage.setItem(key, encoded_value); + }, remove: function (key) { localStorage.removeItem(key); } }; } diff --git a/assets/js/handlers.js b/assets/js/handlers.js index 29810e72..539974fb 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -137,7 +137,7 @@ if (focused_tag === 'textarea') return; if (focused_tag === 'input') { let focused_type = document.activeElement.type.toLowerCase(); - if (!focused_type.match(allowed)) return; + if (!allowed.test(focused_type)) return; } // Focus search bar on '/' diff --git a/assets/js/player.js b/assets/js/player.js index ee678663..bb53ac24 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -261,7 +261,7 @@ function updateCookie(newVolume, newSpeed) { var date = new Date(); date.setFullYear(date.getFullYear() + 2); - var ipRegex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; + var ipRegex = /^((\d+\.){3}\d+|[\dA-Fa-f]*:[\d:A-Fa-f]*:[\d:A-Fa-f]+)$/; var domainUsed = location.hostname; // Fix for a bug in FF where the leading dot in the FQDN is not ignored diff --git a/assets/js/watch.js b/assets/js/watch.js index cff84e4d..36506abd 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -282,7 +282,7 @@ function get_youtube_replies(target, load_more, load_replies) { if (load_more) { body = body.parentNode.parentNode; body.removeChild(body.lastElementChild); - body.innerHTML += response.contentHtml; + body.insertAdjacentHTML('beforeend', response.contentHtml); } else { body.removeChild(body.lastElementChild); diff --git a/config/config.example.yml b/config/config.example.yml index 8abe1b9e..34070fe5 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -255,8 +255,7 @@ https_only: false #registration_enabled: true ## -## Allow/Forbid users to log-in. This setting affects the ability -## to connect with BOTH Google and Invidious (local) accounts. +## Allow/Forbid users to log-in. ## ## Accepted values: true, false ## Default: true @@ -456,13 +455,17 @@ jobs: #use_pubsub_feeds: false ## -## HMAC signing key used for CSRF tokens and pubsub +## HMAC signing key used for CSRF tokens, cookies and pubsub ## subscriptions verification. ## +## Note: This parameter is mandatory and should be a random string. +## Such random string can be generated on linux with the following +## command: `pwgen 20 1` +## ## Accepted values: a string ## Default: ## -#hmac_key: +hmac_key: "CHANGE_ME!!" ## ## List of video IDs where the "download" widget must be @@ -817,6 +820,16 @@ default_user_preferences: ## Default: true ## #vr_mode: true + + ## + ## Save the playback position + ## Allow to continue watching at the previous position when + ## watching the same video. + ## + ## Accepted values: true, false + ## Default: false + ## + #save_player_pos: false # ----------------------------- # Subscription feed diff --git a/docker-compose.yml b/docker-compose.yml index eb83b020..6a854475 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: # domain: # https_only: false # statistics_enabled: false + hmac_key: "CHANGE_ME!!" healthcheck: test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 interval: 30s diff --git a/locales/af.json b/locales/af.json index 0967ef42..35f40a13 100644 --- a/locales/af.json +++ b/locales/af.json @@ -1 +1,15 @@ -{} +{ + "generic_views_count": "{{count}} kyk", + "generic_views_count_plural": "{{count}} kyke", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videos", + "generic_playlists_count": "{{count}} snitlys", + "generic_playlists_count_plural": "{{count}} snitlyste", + "generic_subscriptions_count": "{{count}} intekening", + "generic_subscriptions_count_plural": "{{count}} intekeninge", + "LIVE": "LEWENDIG", + "generic_subscribers_count": "{{count}} intekenaar", + "generic_subscribers_count_plural": "{{count}} intekenare", + "Shared `x` ago": "`x` gelede gedeel", + "New passwords must match": "Nuwe wagwoord moet ooreenstem" +} diff --git a/locales/ar.json b/locales/ar.json index 3ce34c2d..c137d1a3 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -14,7 +14,6 @@ "Clear watch history?": "هل تريد محو سجل المشاهدة؟", "New password": "كلمة مرور جديدة", "New passwords must match": "يَجبُ أن تكون كلمتا المرور متطابقتين", - "Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل", "Authorize token?": "رمز التفويض؟", "Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟", "Yes": "نعم", @@ -37,7 +36,6 @@ "source": "المصدر", "Log in": "تسجيل الدخول", "Log in/register": "تسجيل الدخول \\ إنشاء حساب", - "Log in with Google": "تسجيل الدخول باستخدام جوجل", "User ID": "مُعرِّف المُستخدم", "Password": "كلمة المرور", "Time (h:mm:ss):": "الوقت (h:mm:ss):", @@ -46,13 +44,12 @@ "Sign In": "تسجيل الدخول", "Register": "التسجيل", "E-mail": "البريد الإلكتروني", - "Google verification code": "رمز تحقق جوجل", "Preferences": "الإعدادات", "preferences_category_player": "إعدادات المُشغِّل", "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ", "preferences_autoplay_label": "تشغيل تلقائي: ", - "preferences_continue_label": "شغل المقطع التالي تلقائيًا: ", - "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ", + "preferences_continue_label": "تشغيل المقطع التالي تلقائيًا: ", + "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: . ", "preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ", "preferences_local_label": "بروكسي المقاطع المرئيّة؟ ", "preferences_speed_label": "السرعة الافتراضية: ", @@ -158,23 +155,18 @@ "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات", - "": "عرض `x` تعليقات" + "": "عرض `x` تعليقات." }, "View Reddit comments": "عرض تعليقات ريديت", "Hide replies": "إخفاء الردود", "Show replies": "عرض الردود", "Incorrect password": "كلمة السر غير صحيحة", - "Quota exceeded, try again in a few hours": "تم تجاوز عدد المرات المسموح بها، حاول مجددًا بعد بضع ساعات", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "غير قادر على تسجيل الدخول، تأكد من تشغيل المصادقة الثنائية 2FA.", - "Invalid TFA code": "كود مصادقة ثنائية 2FA غير صحيح", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "فشل تسجيل الدخول. قد يكون هذا بسبب أن المصادقة الثنائية 2FA معطلة في حسابك.", "Wrong answer": "إجابة خاطئة", "Erroneous CAPTCHA": "الكابتشا CAPTCHA غير صاحلة", "CAPTCHA is a required field": "مكان الكابتشا CAPTCHA مطلوب", "User ID is a required field": "مكان اسم المستخدم مطلوب", "Password is a required field": "مكان كلمة السر مطلوب", "Wrong username or password": "اسم المستخدم او كلمة السر غير صحيح", - "Please sign in using 'Log in with Google'": "الرجاء تسجيل الدخول باستخدام \"تسجيل الدخول باستخدام Google\"", "Password cannot be empty": "لا يمكن أن تكون كلمة السر فارغة", "Password cannot be longer than 55 characters": "يجب أن لا تتعدى كلمة السر 55 حرفًا", "Please log in": "الرجاء تسجيل الدخول", @@ -545,5 +537,8 @@ "Album: ": "الألبوم: ", "Artist: ": "الفنان: ", "Song: ": "أغنية: ", - "Channel Sponsor": "راعي القناة" + "Channel Sponsor": "راعي القناة", + "Standard YouTube license": "ترخيص YouTube القياسي", + "Download is disabled": "تم تعطيل التحميلات", + "Import YouTube playlist (.csv)": "استيراد قائمة تشغيل YouTube (.csv)" } diff --git a/locales/bn.json b/locales/bn.json index 3d1cb5da..9d1c7b24 100644 --- a/locales/bn.json +++ b/locales/bn.json @@ -11,7 +11,6 @@ "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", "New password": "নতুন পাসওয়ার্ড", "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", - "Cannot change password for Google accounts": "গুগল অ্যাকাউন্টগুলোর জন্য পাসওয়ার্ড পরিবর্তন করা যায় না", "Authorize token?": "টোকেন অনুমোদন করবেন?", "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", "Yes": "হ্যাঁ", @@ -34,7 +33,6 @@ "source": "সূত্র", "Log in": "লগ ইন", "Log in/register": "লগ ইন/রেজিস্টার", - "Log in with Google": "গুগল দিয়ে লগ ইন করুন", "User ID": "ইউজার আইডি", "Password": "পাসওয়ার্ড", "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", @@ -43,7 +41,6 @@ "Sign In": "সাইন ইন", "Register": "নিবন্ধন", "E-mail": "ই-মেইল", - "Google verification code": "গুগল যাচাইকরণ কোড", "Preferences": "পছন্দসমূহ", "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", "preferences_video_loop_label": "সর্বদা লুপ: ", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 53cb79ae..a82b0da7 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -14,7 +14,6 @@ "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", "New password": "নতুন পাসওয়ার্ড", "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", - "Cannot change password for Google accounts": "গুগল অ্যাকাউন্টগুলোর জন্য পাসওয়ার্ড পরিবর্তন করা যায় না", "Authorize token?": "টোকেন অনুমোদন করবেন?", "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", "Yes": "হ্যাঁ", @@ -37,7 +36,6 @@ "source": "সূত্র", "Log in": "লগ ইন", "Log in/register": "লগ ইন/রেজিস্টার", - "Log in with Google": "গুগল দিয়ে লগ ইন করুন", "User ID": "ইউজার আইডি", "Password": "পাসওয়ার্ড", "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", @@ -46,7 +44,6 @@ "Sign In": "সাইন ইন", "Register": "নিবন্ধন", "E-mail": "ই-মেইল", - "Google verification code": "গুগল যাচাইকরণ কোড", "Preferences": "পছন্দসমূহ", "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", "preferences_video_loop_label": "সর্বদা লুপ: ", diff --git a/locales/ca.json b/locales/ca.json index 54a0b177..4392c2a9 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -4,7 +4,6 @@ "preferences_quality_label": "Qualitat de vídeo preferida: ", "newest": "més nou", "No": "No", - "Google verification code": "Codi de verificació de Google", "User ID": "ID d'usuari", "Preferences": "Preferències", "Dark mode: ": "Mode fosc: ", @@ -66,7 +65,7 @@ "Malay": "Malai", "Persian": "Persa", "Slovak": "Eslovac", - "Search": "Busca", + "Search": "Cerca", "Show annotations": "Mostra anotacions", "preferences_region_label": "País del contingut: ", "preferences_sort_label": "Ordena vídeos per: ", @@ -75,7 +74,7 @@ "Title": "Títol", "Belarusian": "Bielorús", "Enable web notifications": "Activa notificacions web", - "search": "cerca", + "search": "Cerca", "Catalan": "Català", "Croatian": "Croat", "preferences_category_admin": "Preferències d'administrador", @@ -122,8 +121,8 @@ "search_filters_features_option_location": "Ubicació", "search_filters_apply_button": "Aplica els filtres seleccionats", "videoinfo_started_streaming_x_ago": "Ha començat el directe fa `x`", - "next_steps_error_message_go_to_youtube": "Anar a YouTube", - "footer_donate_page": "Donar", + "next_steps_error_message_go_to_youtube": "Vés a YouTube", + "footer_donate_page": "Feu un donatiu", "footer_original_source_code": "Codi font original", "videoinfo_watch_on_youTube": "Veure a YouTube", "user_saved_playlists": "`x` llistes de reproducció guardades", @@ -137,7 +136,6 @@ "channel_tab_channels_label": "Canals", "channel_tab_playlists_label": "Llistes de reproducció", "channel_tab_community_label": "Comunitat", - "Invalid TFA code": "Codi TFA no vàlid", "Czech": "Txec", "Default": "Per defecte", "Amharic": "Amàric", @@ -164,7 +162,7 @@ "crash_page_report_issue": "Si cap de les anteriors no ha ajudat, obre un nou issue a GitHub (preferiblement en anglès) i inclou el text següent al missatge (NO tradueixis aquest text):", "generic_subscriptions_count": "{{count}} subscripció", "generic_subscriptions_count_plural": "{{count}} subscripcions", - "error_video_not_in_playlist": "El vídeo sol·licitat no existeix en aquesta llista de reproducció. Fes clic aquí per a la pàgina d'inici de la llista de reproducció.", + "error_video_not_in_playlist": "El vídeo sol·licitat no existeix en aquesta llista de reproducció. Feu clic aquí per a la pàgina d'inici de la llista de reproducció.", "comments_points_count": "{{count}} punt", "comments_points_count_plural": "{{count}} punts", "%A %B %-d, %Y": "%A %B %-d, %Y", @@ -175,7 +173,7 @@ "preferences_unseen_only_label": "Mostra només no vistos: ", "preferences_listen_label": "Escolta per defecte: ", "Import": "Importar", - "Token": "Senyal", + "Token": "Testimoni", "Wilson score: ": "Puntuació de Wilson: ", "search_filters_date_label": "Data de càrrega", "search_filters_features_option_three_sixty": "360°", @@ -184,10 +182,9 @@ "preferences_comments_label": "Comentaris per defecte: ", "`x` uploaded a video": "`x` ha penjat un vídeo", "Released under the AGPLv3 on Github.": "Publicat sota l'AGPLv3 a GitHub.", - "Token manager": "Gestor de tokens", + "Token manager": "Gestor de testimonis", "Watch history": "Historial de reproduccions", - "Cannot change password for Google accounts": "No es pot canviar la contrasenya dels comptes de Google", - "Authorize token?": "Autoritzar senyal?", + "Authorize token?": "Autoritzar testimoni?", "Source available here.": "Font disponible aquí.", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporta subscripcions com a OPML (per a NewPipe i FreeTube)", "Log in": "Inicia sessió", @@ -197,7 +194,7 @@ "Public": "Públic", "View all playlists": "Veure totes les llistes de reproducció", "reddit": "Reddit", - "Manage tokens": "Gestiona senyals", + "Manage tokens": "Gestiona testimonis", "Not a playlist.": "No és una llista de reproducció.", "preferences_local_label": "Vídeos de Proxy: ", "View channel on YouTube": "Veure canal a Youtube", @@ -225,7 +222,6 @@ }, "View Reddit comments": "Veure comentaris de Reddit", "Incorrect password": "Contrasenya incorrecta", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "No es pot iniciar la sessió, assegureu-vos que l'autenticació de dos factors (Autenticador o SMS) estigui activada.", "Erroneous CAPTCHA": "CAPTCHA erroni", "CAPTCHA is a required field": "El CAPTCHA és un camp obligatori", "Korean (auto-generated)": "Coreà (generat automàticament)", @@ -272,7 +268,6 @@ "Khmer": "Khmer", "This channel does not exist.": "Aquest canal no existeix.", "Song: ": "Cançó: ", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Error a l'iniciar sessió. Això pot ser perquè l'autenticació de dos factors no està activada per al vostre compte.", "channel:`x`": "canal: `x`", "Deleted or invalid channel": "Canal suprimit o no vàlid", "Could not get channel info.": "No s'ha pogut obtenir la informació del canal.", @@ -291,17 +286,16 @@ "User ID is a required field": "L'identificador d'usuari és un camp obligatori", "Password is a required field": "La contrasenya és un camp obligatori", "Wrong username or password": "Nom d'usuari o contrasenya incorrectes", - "Please sign in using 'Log in with Google'": "Si us plau, inicieu la sessió amb 'Inicieu sessió amb Google'", "Password cannot be longer than 55 characters": "La contrasenya no pot tenir més de 55 caràcters", "Invidious Private Feed for `x`": "Feed privat Invidious per a `x`", "generic_views_count": "{{count}} visualització", "generic_views_count_plural": "{{count}} visualitzacions", "generic_videos_count": "{{count}} vídeo", "generic_videos_count_plural": "{{count}} vídeos", - "Token is expired, please try again": "La senyal ha caducat, torna-ho a provar", + "Token is expired, please try again": "El testimoni ha caducat, torna-ho a provar", "English": "Anglès", "Kannada": "Kanarès", - "Erroneous token": "Senyal errònia", + "Erroneous token": "Testimoni erroni", "`x` ago": "fa `x`", "Empty playlist": "Llista de reproducció buida", "Playlist does not exist.": "La llista de reproducció no existeix.", @@ -376,7 +370,7 @@ "Clear watch history": "Neteja l'historial de reproduccions", "Mongolian": "Mongol", "preferences_quality_dash_option_best": "Millor", - "Authorize token for `x`?": "Autoritzar senyal per a `x`?", + "Authorize token for `x`?": "Autoritzar testimoni per a `x`?", "Report statistics: ": "Estadístiques de l'informe: ", "Switch Invidious Instance": "Canvia la instància d'Invidious", "History": "Historial", @@ -410,7 +404,7 @@ "Export": "Exportar", "preferences_quality_dash_option_4320p": "4320p", "JavaScript license information": "Informació de la llicència de JavaScript", - "Hidden field \"token\" is a required field": "El camp ocult \"senyal\" és un camp obligatori", + "Hidden field \"token\" is a required field": "El camp ocult \"testimoni\" és un camp obligatori", "Shona": "Xona", "Family friendly? ": "Apte per a tots els públics? ", "preferences_quality_dash_label": "Qualitat de vídeo DASH preferida: ", @@ -436,16 +430,14 @@ "preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_720p": "720p", "preferences_quality_dash_option_480p": "480p", - "Log in with Google": "Inicia sessió amb Google", "preferences_quality_dash_option_1440p": "1440p", "Previous page": "Pàgina anterior", "Only show latest video from channel: ": "Mostra només l'últim vídeo del canal: ", "unsubscribe": "cancel·la la subscripció", "View playlist on YouTube": "Veure llista de reproducció a YouTube", "Import NewPipe subscriptions (.json)": "Importar subscripcions de NewPipe (.json)", - "crash_page_you_found_a_bug": "Sembla que has trobat un error a Invidious!", + "crash_page_you_found_a_bug": "Heu trobat un error a Invidious!", "Subscribe": "Subscriu-me", - "Quota exceeded, try again in a few hours": "S'ha superat la quota, torna-ho a provar d'aquí a unes hores", "generic_count_days": "{{count}} dia", "generic_count_days_plural": "{{count}} dies", "Trending": "Tendència", @@ -468,8 +460,8 @@ "revoke": "revocar", "English (United Kingdom)": "Anglès (Regne Unit)", "preferences_quality_option_hd720": "HD720", - "tokens_count": "{{count}} senyal", - "tokens_count_plural": "{{count}} senyals", + "tokens_count": "{{count}} testimoni", + "tokens_count_plural": "{{count}} testimonis", "subscriptions_unseen_notifs_count": "{{count}} notificació no vista", "subscriptions_unseen_notifs_count_plural": "{{count}} notificacions no vistes", "generic_subscribers_count": "{{count}} subscriptor", @@ -481,5 +473,8 @@ "Top": "Millors", "preferences_max_results_label": "Nombre de vídeos mostrats al feed: ", "Engagement: ": "Atracció: ", - "Redirect homepage to feed: ": "Redirigeix la pàgina d'inici al feed: " + "Redirect homepage to feed: ": "Redirigeix la pàgina d'inici al feed: ", + "Standard YouTube license": "Llicència estàndard de YouTube", + "Download is disabled": "Les baixades s'han inhabilitat", + "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)" } diff --git a/locales/cs.json b/locales/cs.json index 4611c4fd..73ed960d 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -13,8 +13,7 @@ "Previous page": "Předchozí strana", "Clear watch history?": "Smazat historii?", "New password": "Nové heslo", - "New passwords must match": "Hesla se musí schodovat", - "Cannot change password for Google accounts": "Nelze změnit heslo pro účty Google", + "New passwords must match": "Hesla se musí shodovat", "Authorize token?": "Autorizovat token?", "Authorize token for `x`?": "Autorizovat token pro `x`?", "Yes": "Ano", @@ -37,7 +36,6 @@ "source": "zdrojový kód", "Log in": "Přihlásit se", "Log in/register": "Přihlásit se/vytvořit účet", - "Log in with Google": "Přihlásit se s Googlem", "User ID": "ID uživatele", "Password": "Heslo", "Time (h:mm:ss):": "Čas (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Přihlásit se", "Register": "Vytvořit účet", "E-mail": "E-mail", - "Google verification code": "Verifikační číslo Google", "Preferences": "Nastavení", "preferences_category_player": "Nastavení přehravače", "preferences_video_loop_label": "Vždy opakovat: ", @@ -335,7 +332,6 @@ "preferences_quality_dash_option_1440p": "1440p", "invidious": "Invidious", "View more comments on Reddit": "Zobrazit více komentářů na Redditu", - "Invalid TFA code": "Nesprávný TFA kód", "generic_playlists_count_0": "{{count}} playlist", "generic_playlists_count_1": "{{count}} playlisty", "generic_playlists_count_2": "{{count}} playlistů", @@ -349,7 +345,6 @@ "subscriptions_unseen_notifs_count_1": "{{count}} nezobrazená oznámení", "subscriptions_unseen_notifs_count_2": "{{count}} nezobrazených oznámení", "Show replies": "Zobrazit odpovědi", - "Quota exceeded, try again in a few hours": "Kvóta překročena, zkuste to znovu za pár hodin", "Password cannot be longer than 55 characters": "Heslo nesmí být delší než 55 znaků", "comments_view_x_replies_0": "Zobrazit {{count}} odpověď", "comments_view_x_replies_1": "Zobrazit {{count}} odpovědi", @@ -433,7 +428,6 @@ "View YouTube comments": "Zobrazit YouTube komentáře", "Blacklisted regions: ": "Oblasti na černé listině: ", "Wrong username or password": "Nesprávné uživatelské jméno nebo heslo", - "Please sign in using 'Log in with Google'": "Přihlaste se prosím pomocí Googlu", "Password cannot be empty": "Heslo nemůže být prázné", "preferences_category_misc": "Různá nastavení", "preferences_show_nick_label": "Zobrazit přezdívku na vrchu: ", @@ -452,8 +446,6 @@ "([^.,0-9]|^)1([^.,0-9]|$)": "Zobrazit `x` komentář", "": "Zobrazit `x` komentářů" }, - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nepodařilo se přihlásit, ujistěte se, že je povoleno dvoufázové ověřování (autentifikátor nebo SMS).", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Přihlášení selhalo. Toto se může stát, když není na vašem účtu povolené dvoufázové ověřování.", "Could not get channel info.": "Nepodařilo se získat informace o kanálu.", "Could not fetch comments": "Nepodařilo se získat komentáře", "Could not create mix.": "Nepodařilo se vytvořit mix.", @@ -497,5 +489,8 @@ "Artist: ": "Umělec: ", "Album: ": "Album: ", "Channel Sponsor": "Sponzor kanálu", - "Song: ": "Skladba: " + "Song: ": "Skladba: ", + "Standard YouTube license": "Standardní licence YouTube", + "Download is disabled": "Stahování je zakázáno", + "Import YouTube playlist (.csv)": "Importovat YouTube playlist (.csv)" } diff --git a/locales/da.json b/locales/da.json index 2bee6c80..16607546 100644 --- a/locales/da.json +++ b/locales/da.json @@ -14,7 +14,6 @@ "Clear watch history?": "Ryd afspilningshistorik?", "New password": "Nyt kodeord", "New passwords must match": "Nye kodeord skal matche", - "Cannot change password for Google accounts": "Kan ikke skifte kodeord til Google-konti", "Authorize token?": "Godkend token?", "Authorize token for `x`?": "Godkend token til `x`?", "Yes": "Ja", @@ -37,7 +36,6 @@ "source": "kilde", "Log in": "Log på", "Log in/register": "Log på/registrer", - "Log in with Google": "Log på med Google", "User ID": "Bruger ID", "Password": "Kodeord", "Time (h:mm:ss):": "Tid (t:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Log ind", "Register": "Registrer", "E-mail": "E-mail", - "Google verification code": "Google-verifikationskode", "Preferences": "Præferencer", "preferences_category_player": "Afspillerindstillinger", "preferences_video_loop_label": "Altid gentag: ", @@ -159,17 +156,12 @@ "Hide replies": "Skjul svar", "Show replies": "Vis svar", "Incorrect password": "Forkert adgangskode", - "Quota exceeded, try again in a few hours": "Kvota overskredet, prøv igen om et par timer", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Login fejlet, tjek at totrinsbekræftelse (Authenticator eller SMS) er slået til.", - "Invalid TFA code": "Ugyldig TFA kode", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fejlede. Dette kan skyldes, at to-faktor autentificering ikke er aktiveret for din konto.", "Wrong answer": "Forkert svar", "Erroneous CAPTCHA": "Fejlagtig CAPTCHA", "CAPTCHA is a required field": "CAPTCHA er et obligatorisk felt", "User ID is a required field": "Bruger ID er et krævet felt", "Password is a required field": "Adgangskode er et obligatorisk felt", "Wrong username or password": "Forkert brugernavn eller adgangskode", - "Please sign in using 'Log in with Google'": "Log ind via 'Log ind med Google'", "Password cannot be empty": "Adgangskoden må ikke være tom", "Password cannot be longer than 55 characters": "Adgangskoden må ikke være længere end 55 tegn", "Please log in": "Venligst log ind", diff --git a/locales/de.json b/locales/de.json index c2941d6d..66f2ae6f 100644 --- a/locales/de.json +++ b/locales/de.json @@ -14,7 +14,6 @@ "Clear watch history?": "Verlauf löschen?", "New password": "Neues Passwort", "New passwords must match": "Neue Passwörter müssen übereinstimmen", - "Cannot change password for Google accounts": "Ich kann das Passwort deines Google Kontos nicht ändern", "Authorize token?": "Token autorisieren?", "Authorize token for `x`?": "Token für `x` autorisieren?", "Yes": "Ja", @@ -37,7 +36,6 @@ "source": "Quelle", "Log in": "Anmelden", "Log in/register": "Anmelden/registrieren", - "Log in with Google": "Mit Google anmelden", "User ID": "Benutzer-ID", "Password": "Passwort", "Time (h:mm:ss):": "Zeit (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Anmelden", "Register": "Registrieren", "E-mail": "E-Mail", - "Google verification code": "Google-Bestätigungscode", "Preferences": "Einstellungen", "preferences_category_player": "Wiedergabeeinstellungen", "preferences_video_loop_label": "Immer wiederholen: ", @@ -164,17 +161,12 @@ "Hide replies": "Antworten verstecken", "Show replies": "Antworten anzeigen", "Incorrect password": "Falsches Passwort", - "Quota exceeded, try again in a few hours": "Kontingent überschritten, versuche es in ein paar Stunden erneut", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Anmeldung nicht möglich, stellen Sie sicher, dass die Zwei-Faktor-Authentisierung (Authenticator oder SMS) aktiviert ist.", - "Invalid TFA code": "Ungültiger TFA Code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Die Anmeldung ist fehlgeschlagen. Dies kann daran liegen, dass die Zwei-Faktor-Authentisierung für Ihr Konto nicht aktiviert ist.", "Wrong answer": "Ungültige Antwort", "Erroneous CAPTCHA": "Ungültiges CAPTCHA", "CAPTCHA is a required field": "CAPTCHA ist eine erforderliche Eingabe", "User ID is a required field": "Benutzer ID ist eine erforderliche Eingabe", "Password is a required field": "Passwort ist eine erforderliche Eingabe", "Wrong username or password": "Ungültiger Benutzername oder Passwort", - "Please sign in using 'Log in with Google'": "Bitte melden Sie sich mit „Mit Google anmelden“ an", "Password cannot be empty": "Passwort darf nicht leer sein", "Password cannot be longer than 55 characters": "Passwort darf nicht länger als 55 Zeichen sein", "Please log in": "Bitte anmelden", @@ -433,7 +425,7 @@ "comments_points_count_plural": "{{count}} Punkte", "crash_page_you_found_a_bug": "Anscheinend haben Sie einen Fehler in Invidious gefunden!", "generic_count_months": "{{count}} Monat", - "generic_count_months_plural": "{{count}} Monate", + "generic_count_months_plural": "{{count}} Monaten", "Cantonese (Hong Kong)": "Kantonesisch (Hong Kong)", "Chinese (Hong Kong)": "Chinesisch (Hong Kong)", "generic_playlists_count": "{{count}} Wiedergabeliste", @@ -479,5 +471,10 @@ "Artist: ": "Künstler: ", "Album: ": "Album: ", "channel_tab_playlists_label": "Wiedergabelisten", - "channel_tab_channels_label": "Kanäle" + "channel_tab_channels_label": "Kanäle", + "Channel Sponsor": "Kanalsponsor", + "Standard YouTube license": "Standard YouTube-Lizenz", + "Song: ": "Musik: ", + "Download is disabled": "Herunterladen ist deaktiviert", + "Import YouTube playlist (.csv)": "YouTube Playlist Importieren (.csv)" } diff --git a/locales/el.json b/locales/el.json index 8d0c84dd..13cff649 100644 --- a/locales/el.json +++ b/locales/el.json @@ -14,7 +14,6 @@ "Clear watch history?": "Διαγραφή ιστορικού προβολής;", "New password": "Νέος κωδικός πρόσβασης", "New passwords must match": "Οι νέοι κωδικοί πρόσβασης πρέπει να ταιριάζουν", - "Cannot change password for Google accounts": "Δεν επιτρέπεται η αλλαγή κωδικού πρόσβασης λογαριασμών Google", "Authorize token?": "Εξουσιοδότηση διασύνδεσης;", "Authorize token for `x`?": "Εξουσιοδότηση διασύνδεσης με `x`;", "Yes": "Ναι", @@ -37,7 +36,6 @@ "source": "πηγή", "Log in": "Σύνδεση", "Log in/register": "Σύνδεση/εγγραφή", - "Log in with Google": "Σύνδεση με Google", "User ID": "Ταυτότητα χρήστη", "Password": "Κωδικός πρόσβασης", "Time (h:mm:ss):": "Ώρα (ω:λλ:δδ):", @@ -46,7 +44,6 @@ "Sign In": "Σύνδεση", "Register": "Εγγραφή", "E-mail": "Ηλεκτρονικό ταχυδρομείο", - "Google verification code": "Κωδικός επαλήθευσης Google", "Preferences": "Προτιμήσεις", "preferences_category_player": "Προτιμήσεις αναπαραγωγής", "preferences_video_loop_label": "Αυτόματη επανάληψη: ", @@ -155,17 +152,12 @@ "Hide replies": "Απόκρυψη απαντήσεων", "Show replies": "Προβολή απαντήσεων", "Incorrect password": "Λανθασμένος κωδικός πρόσβασης", - "Quota exceeded, try again in a few hours": "Έχετε υπερβεί το όριο προσπαθειών, δοκιμάστε ξανα σε λίγες ώρες", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Αδυναμία σύνδεσης, βεβαιωθείτε πως ο έλεγχος ταυτότητας δύο παραγόντων (με Authenticator ή SMS) είναι ενεργοποιημένος.", - "Invalid TFA code": "Μη έγκυρος κωδικός ελέγχου ταυτότητας δύο παραγόντων", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Αποτυχία σύνδεσης. Ίσως ευθύνεται η έλλειψη ελέγχου ταυτότητας δύο παραγόντων για το λογαριασμό σας.", "Wrong answer": "Λανθασμένη απάντηση", "Erroneous CAPTCHA": "Λανθασμένο CAPTCHA", "CAPTCHA is a required field": "Το CAPTCHA είναι απαιτούμενο πεδίο", "User ID is a required field": "Η ταυτότητα χρήστη είναι απαιτούμενο πεδίο", "Password is a required field": "Ο κωδικός πρόσβασης είναι απαιτούμενο πεδίο", "Wrong username or password": "Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης", - "Please sign in using 'Log in with Google'": "Συνδεθείτε με την επιλογή 'Σύνδεση με Google'", "Password cannot be empty": "Ο κωδικός πρόσβασης δεν γίνεται να είναι κενός", "Password cannot be longer than 55 characters": "Ο κωδικός πρόσβασης δεν γίνεται να υπερβαίνει τους 55 χαρακτήρες", "Please log in": "Συνδεθείτε", diff --git a/locales/en-US.json b/locales/en-US.json index 05811f27..e13ba968 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -24,7 +24,6 @@ "Clear watch history?": "Clear watch history?", "New password": "New password", "New passwords must match": "New passwords must match", - "Cannot change password for Google accounts": "Cannot change password for Google accounts", "Authorize token?": "Authorize token?", "Authorize token for `x`?": "Authorize token for `x`?", "Yes": "Yes", @@ -33,6 +32,7 @@ "Import": "Import", "Import Invidious data": "Import Invidious JSON data", "Import YouTube subscriptions": "Import YouTube/OPML subscriptions", + "Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)", "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", @@ -47,7 +47,6 @@ "source": "source", "Log in": "Log in", "Log in/register": "Log in/register", - "Log in with Google": "Log in with Google", "User ID": "User ID", "Password": "Password", "Time (h:mm:ss):": "Time (h:mm:ss):", @@ -56,7 +55,6 @@ "Sign In": "Sign In", "Register": "Register", "E-mail": "E-mail", - "Google verification code": "Google verification code", "Preferences": "Preferences", "preferences_category_player": "Player preferences", "preferences_video_loop_label": "Always loop: ", @@ -207,17 +205,12 @@ "Hide replies": "Hide replies", "Show replies": "Show replies", "Incorrect password": "Incorrect password", - "Quota exceeded, try again in a few hours": "Quota exceeded, try again in a few hours", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.", - "Invalid TFA code": "Invalid TFA code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login failed. This may be because two-factor authentication is not turned on for your account.", "Wrong answer": "Wrong answer", "Erroneous CAPTCHA": "Erroneous CAPTCHA", "CAPTCHA is a required field": "CAPTCHA is a required field", "User ID is a required field": "User ID is a required field", "Password is a required field": "Password is a required field", "Wrong username or password": "Wrong username or password", - "Please sign in using 'Log in with Google'": "Please sign in using 'Log in with Google'", "Password cannot be empty": "Password cannot be empty", "Password cannot be longer than 55 characters": "Password cannot be longer than 55 characters", "Please log in": "Please log in", diff --git a/locales/eo.json b/locales/eo.json index 9f37c7cb..a4b46bef 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -14,7 +14,6 @@ "Clear watch history?": "Ĉu forigi vidohistorion?", "New password": "Nova pasvorto", "New passwords must match": "Novaj pasvortoj devas kongrui", - "Cannot change password for Google accounts": "Ne eblas ŝanĝi pasvorton por kontoj de Google", "Authorize token?": "Ĉu rajtigi ĵetonon?", "Authorize token for `x`?": "Ĉu rajtigi ĵetonon por `x`?", "Yes": "Jes", @@ -37,7 +36,6 @@ "source": "fonto", "Log in": "Ensaluti", "Log in/register": "Ensaluti/Registriĝi", - "Log in with Google": "Ensaluti al Google", "User ID": "Uzula identigilo", "Password": "Pasvorto", "Time (h:mm:ss):": "Horo (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Ensaluti", "Register": "Registriĝi", "E-mail": "Retpoŝto", - "Google verification code": "Kontrolkodo de Google", "Preferences": "Agordoj", "preferences_category_player": "Spektilaj agordoj", "preferences_video_loop_label": "Ĉiam ripeti: ", @@ -164,17 +161,12 @@ "Hide replies": "Kaŝi respondojn", "Show replies": "Montri respondojn", "Incorrect password": "Malbona pasvorto", - "Quota exceeded, try again in a few hours": "Kvoto transpasita, provu denove post iuj horoj", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ne povas ensaluti, certigu, ke dufaktora aŭtentigo (Authenticator aŭ SMS) estas ebligita.", - "Invalid TFA code": "Nevalida TFA-kodo", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Ensalutado fiaskis. Eble ĉar la dufaktora aŭtentigo estas malebligita en via konto.", "Wrong answer": "Nevalida respondo", "Erroneous CAPTCHA": "Nevalida CAPTCHA", "CAPTCHA is a required field": "CAPTCHA estas deviga kampo", "User ID is a required field": "Uzula identigilo estas deviga kampo", "Password is a required field": "Pasvorto estas deviga kampo", "Wrong username or password": "Nevalida uzantnomo aŭ pasvorto", - "Please sign in using 'Log in with Google'": "Bonvolu ensaluti per 'Ensaluti per Google'", "Password cannot be empty": "Pasvorto ne povas esti malplena", "Password cannot be longer than 55 characters": "Pasvorto ne povas esti pli longa ol 55 signoj", "Please log in": "Bonvolu ensaluti", @@ -479,5 +471,10 @@ "channel_tab_shorts_label": "Mallongaj", "Music in this video": "Muziko en ĉi tiu video", "Artist: ": "Artisto: ", - "Album: ": "Albumo: " + "Album: ": "Albumo: ", + "Channel Sponsor": "Kanala sponsoro", + "Song: ": "Muzikaĵo: ", + "Standard YouTube license": "Implicita YouTube-licenco", + "Download is disabled": "Elŝuto estas malebligita", + "Import YouTube playlist (.csv)": "Importi YouTube-ludliston (.csv)" } diff --git a/locales/es.json b/locales/es.json index bb082c06..b3103a25 100644 --- a/locales/es.json +++ b/locales/es.json @@ -14,7 +14,6 @@ "Clear watch history?": "¿Quiere borrar el historial de reproducción?", "New password": "Nueva contraseña", "New passwords must match": "Las nuevas contraseñas deben coincidir", - "Cannot change password for Google accounts": "No se puede cambiar la contraseña de la cuenta de Google", "Authorize token?": "¿Autorizar el token?", "Authorize token for `x`?": "¿Autorizar el token para `x`?", "Yes": "Sí", @@ -37,7 +36,6 @@ "source": "código fuente", "Log in": "Iniciar sesión", "Log in/register": "Iniciar sesión/Registrarse", - "Log in with Google": "Iniciar sesión en Google", "User ID": "Nombre", "Password": "Contraseña", "Time (h:mm:ss):": "Hora (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Iniciar sesión", "Register": "Registrarse", "E-mail": "Correo", - "Google verification code": "Código de verificación de Google", "Preferences": "Preferencias", "preferences_category_player": "Preferencias del reproductor", "preferences_video_loop_label": "Repetir siempre: ", @@ -164,17 +161,12 @@ "Hide replies": "Ocultar las respuestas", "Show replies": "Mostrar las respuestas", "Incorrect password": "Contraseña incorrecta", - "Quota exceeded, try again in a few hours": "Cuota excedida, prueba otra vez en unas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "No se puede iniciar sesión, asegúrese de que la autentificación de dos factores (autentificador o SMS) esté habilitada.", - "Invalid TFA code": "Código TFA no válido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Error de inicio de sesion. Puede deberse a que la autentificación de dos factores no está habilitada en su cuenta.", "Wrong answer": "Respuesta no válida", "Erroneous CAPTCHA": "CAPTCHA no válido", "CAPTCHA is a required field": "El CAPTCHA es un campo obligatorio", "User ID is a required field": "El nombre es un campo obligatorio", "Password is a required field": "La contraseña es un campo obligatorio", "Wrong username or password": "Nombre o contraseña incorrecto", - "Please sign in using 'Log in with Google'": "Inicie sesión con «Iniciar sesión con Google»", "Password cannot be empty": "La contraseña no puede estar en blanco", "Password cannot be longer than 55 characters": "La contraseña no debe tener más de 55 caracteres", "Please log in": "Inicie sesión, por favor", @@ -413,10 +405,9 @@ "generic_count_weeks": "{{count}} semana", "generic_count_weeks_plural": "{{count}} semanas", "generic_playlists_count": "{{count}} lista de reproducción", - "generic_playlists_count_plural": "{{count}} listas de reproducción", - "generic_videos_count_0": "{{count}} video", - "generic_videos_count_1": "{{count}} videos", - "generic_videos_count_2": "{{count}} videos", + "generic_playlists_count_plural": "{{count}} listas de reproducciones", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} video", "generic_count_months": "{{count}} mes", "generic_count_months_plural": "{{count}} meses", "comments_points_count": "{{count}} punto", @@ -469,8 +460,8 @@ "search_filters_duration_option_none": "Cualquier duración", "search_filters_features_option_vr180": "VR180", "search_filters_apply_button": "Aplicar filtros", - "tokens_count": "{{count}} ficha", - "tokens_count_plural": "{{count}} fichas", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", "search_message_use_another_instance": " También puede buscar en otra instancia.", "Popular enabled: ": "¿Habilitar la sección popular? ", "error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. Haz clic aquí para acceder a la página de inicio de la lista de reproducción.", @@ -482,5 +473,8 @@ "Artist: ": "Artista: ", "Album: ": "Álbum: ", "Song: ": "Canción: ", - "Channel Sponsor": "Patrocinador del canal" + "Channel Sponsor": "Patrocinador del canal", + "Standard YouTube license": "Licencia de YouTube estándar", + "Download is disabled": "La descarga está deshabilitada", + "Import YouTube playlist (.csv)": "Importar lista de reproducción de YouTube (.csv)" } diff --git a/locales/et.json b/locales/et.json index 74338aba..7f652810 100644 --- a/locales/et.json +++ b/locales/et.json @@ -25,7 +25,6 @@ "Clear watch history?": "Kustuta vaatamiste ajalugu?", "New password": "Uus salasõna", "New passwords must match": "Uued salasõnad peavad ühtima", - "Cannot change password for Google accounts": "Google'i kasutaja salasõna ei saa muuta", "Import and Export Data": "Impordi ja ekspordi andmed", "Import": "Impordi", "Import YouTube subscriptions": "Impordi tellimused Youtube'ist/OPML-ist", @@ -38,7 +37,6 @@ "History": "Ajalugu", "JavaScript license information": "JavaScripti litsentsi info", "source": "allikas", - "Log in with Google": "Logi sisse Google'iga", "User ID": "Kasutada ID", "Password": "Salasõna", "Time (h:mm:ss):": "Aeg (h:mm:ss):", @@ -118,12 +116,10 @@ "Hide replies": "Peida vastused", "Show replies": "Näita vastuseid", "Incorrect password": "Vale salasõna", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sisselogimine ei õnnestunud. Asi võib olla selles, et", "Wrong answer": "Vale vastus", "User ID is a required field": "Kasutaja ID on kohustuslik väli", "Password is a required field": "Salasõna on kohustuslik väli", "Wrong username or password": "Vale kasutajanimi või salasõna", - "Please sign in using 'Log in with Google'": "Palun kasutage 'Logi sisse Google'iga'", "Password cannot be longer than 55 characters": "Salasõna ei tohi olla pikem kui 55 tähemärki", "Password cannot be empty": "Salasõna ei tohi olla tühi", "Please log in": "Palun logige sisse", @@ -290,8 +286,6 @@ "": "Vaata `x` kommentaare" }, "Khmer": "Khmeeri", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sisselogimine ei õnnestunud. Kontrollige, kas two-factor authentication (Authenticator või SMS) on sisselülitatud.", - "Invalid TFA code": "Vale TFA-kood", "Bosnian": "Bosnia", "Corsican": "Korsika", "Javanese": "Jaava", diff --git a/locales/eu.json b/locales/eu.json index 9e093a52..8b365270 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -14,7 +14,6 @@ "Clear watch history?": "Garbitu ikusitakoen historia?", "New password": "Pasahitz berria", "New passwords must match": "Pasahitza berriek bat egin behar dute", - "Cannot change password for Google accounts": "Ezin da pasahitza aldatu Google kontuetan", "Authorize token?": "Baimendu tokena?", "Yes": "Bai", "No": "Ez", @@ -36,7 +35,6 @@ "source": "iturburua", "Log in": "Saioa hasi", "Log in/register": "Hasi saioa / Eman izena", - "Log in with Google": "Hasi saioa Googlekin", "User ID": "Erabiltzaile IDa", "Password": "Pasahitza", "Time (h:mm:ss):": "Denbora (h:mm:ss):", @@ -93,7 +91,6 @@ "Import/export data": "Inportatu/exportatu data", "Create playlist": "Zerrenda sortu", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Aditu! JavaScript itzalita dakazula ematen du. Hemen sakatu iruzkinak ikusteko. Denbora luza leikeela kontuan hartu.", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ezinezkoa izena eman. Ziurtatu berresteko bi faktoreak (Authenticator edo SMS) piztuta daudela.", "generic_views_count": "{{count}}ikusia", "generic_views_count_plural": "{{count}}ikusiak", "generic_playlists_count": "{{count}}zerrenda", @@ -136,7 +133,6 @@ "License: ": "Lizentzia: ", "Family friendly? ": "Adeikorra familiarekin? ", "Wilson score: ": "Wilsonen puntuazioa: ", - "Quota exceeded, try again in a few hours": "Kuota gaindituta, ordu batzuren bueltan berriro saiatu", "comments_view_x_replies": "{{count}} erantzuna ikusi", "comments_view_x_replies_plural": "{{count}} erantzunak ikusi", "Catalan": "Katalaniera", @@ -204,7 +200,6 @@ "preferences_category_data": "Dataren lehentasunak", "preferences_default_home_label": "Homepage lehenetsia: ", "preferences_automatic_instance_redirect_label": "berbideratze adibide automatikoa (atzera egin berbideratzeko: invidious.io) ", - "Please sign in using 'Log in with Google'": "'Log in Googlerekin' erabili", "`x` uploaded a video": "' x'(e)k bideo bat igo du", "published - reverse": "argitaratuta - alderantziz", "Could not get channel info.": "Kanalaren adierazpena ezin lortu.", @@ -220,7 +215,6 @@ "Premieres in `x`": "'x'eko estrenaldiak", "Delete playlist `x`?": "'x' zerrenda ezabatu nahi?", "Token is expired, please try again": "Token kadukatua, saiatu berriro", - "Invalid TFA code": "TFA kodea ez da zuzena", "CAPTCHA enabled: ": "CAPTCHA gaitu: ", "Released under the AGPLv3 on Github.": "GitHubeko AGPLv3pean argitaratuta.", "channel:`x`": "Kanal: 'x'", @@ -242,9 +236,7 @@ "preferences_category_subscription": "Harpidetzaren lehentasunak", "Hidden field \"challenge\" is a required field": "\"challenge\" eremu ezkutua beharrezkoa da", "German": "Alemaniarra", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Ezin izena eman. Izan leike zure konturako berresteko bi faktoreak piztuta ez daudela.", "View YouTube comments": "YouTubeko iruzkinak ikusi", - "Google verification code": "Googleren berresteko kodea", "`x` is live": "'x' bizirik darrai", "Password cannot be empty": "Pasahitza ezin da hutsik utzi", "preferences_video_loop_label": "Beti begiztatu: ", diff --git a/locales/fa.json b/locales/fa.json index 56685f64..9b6c625d 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -19,7 +19,6 @@ "Clear watch history?": "پاک کردن تاریخچه نمایش؟", "New password": "گذرواژه تازه", "New passwords must match": "گذارواژه های تازه باید باهم همخوانی داشته باشند", - "Cannot change password for Google accounts": "نمیتوان گذرواژه را برای حساب های کاربری گوگل تغییر داد", "Authorize token?": "توکن دسترسی؟", "Authorize token for `x`?": "توکن دسترسی برای `x`؟", "Yes": "بله", @@ -42,7 +41,6 @@ "source": "منبع", "Log in": "ورود", "Log in/register": "ورود/ثبت نام", - "Log in with Google": "ورود با گوگل", "User ID": "شناسه کاربری", "Password": "گذرواژه", "Time (h:mm:ss):": "زمان (h:mm:ss):", @@ -51,7 +49,6 @@ "Sign In": "ورود", "Register": "ثبت نام", "E-mail": "ایمیل", - "Google verification code": "کد تایید گوگل", "Preferences": "ترجیحات", "preferences_category_player": "ترجیحات نمایش‌دهنده", "preferences_video_loop_label": "همواره ویدئو را بازپخش کن ", @@ -171,17 +168,12 @@ "Hide replies": "مخفی کردن پاسخ ها", "Show replies": "نمایش پاسخ ها", "Incorrect password": "گذرواژه نا درست", - "Quota exceeded, try again in a few hours": "سهمیه بیشتر شده است، چند ساعت بعد دوباره تلاش کنید", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "قادر به ورود نیستید، مطمئن شوید احراز تایید-دو‌مرحله (Authenticator یا پیام‌کوتاه) خاموش باشد.", - "Invalid TFA code": "کد TFA نادرست است", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "ورود با خطا مواجه شد. این ممکن است به خاطر احراز تایید-دو‌مرحله باشد که برای حساب کاربری شما فعال نشده است.", "Wrong answer": "پاسخ غلط", "Erroneous CAPTCHA": "CAPTCHA نا درست", "CAPTCHA is a required field": "CAPTCHA یک فیلد ضروری است", "User ID is a required field": "شناسه کاربری یک فیلد ضروری است", "Password is a required field": "گذرواژه یک فیلد ضروری است", "Wrong username or password": "نام کاربری یا گذرواژه غلط است", - "Please sign in using 'Log in with Google'": "لطفا با استفاده از 'ورود توسط گوگل' وارد شوید", "Password cannot be empty": "گذرواژه نمیتواند خالی باشد", "Password cannot be longer than 55 characters": "گذر واژه نمیتواند از ۵۵ کاراکتر بیشتر باشد", "Please log in": "لطفا وارد شوید", @@ -450,5 +442,8 @@ "Music in this video": "آهنگ در این ویدیو", "Artist: ": "هنرمند: ", "Album: ": "آلبوم: ", - "Song: ": "آهنگ: " + "Song: ": "آهنگ: ", + "Channel Sponsor": "اسپانسر کانال", + "Standard YouTube license": "پروانه استاندارد YouTube", + "search_message_use_another_instance": " شما همچنین می‌توانید در نمونه دیگر هم جستجو کنید." } diff --git a/locales/fi.json b/locales/fi.json index 366a2739..5d8578a5 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -14,7 +14,6 @@ "Clear watch history?": "Tyhjennä katseluhistoria?", "New password": "Uusi salasana", "New passwords must match": "Uusien salasanojen täytyy täsmätä", - "Cannot change password for Google accounts": "Google-tilien salasanaa ei voi vaihtaa", "Authorize token?": "Valuutetaanko tunnus?", "Authorize token for `x`?": "Valtuutetaanko tunnus `x`:lle?", "Yes": "Kyllä", @@ -37,7 +36,6 @@ "source": "lähde", "Log in": "Kirjaudu sisään", "Log in/register": "Kirjaudu sisään/rekisteröidy", - "Log in with Google": "Kirjaudu sisään Googlella", "User ID": "Käyttäjätunnus", "Password": "Salasana", "Time (h:mm:ss):": "Aika (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Kirjaudu sisään", "Register": "Rekisteröidy", "E-mail": "Sähköposti", - "Google verification code": "Google-vahvistuskoodi", "Preferences": "Asetukset", "preferences_category_player": "Soittimen asetukset", "preferences_video_loop_label": "Toista jatkuvasti aina: ", @@ -163,17 +160,12 @@ "Hide replies": "Piilota vastaukset", "Show replies": "Näytä vastaukset", "Incorrect password": "Väärä salasana", - "Quota exceeded, try again in a few hours": "Kiintiö ylitetty, yritä parin tunnin kuluttua uudestaan", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sisäänkirjautuminen epäonnistui. Varmista, että kaksivaiheinen tunnistautuminen (Authenticator tai tekstiviesti) on käytössä.", - "Invalid TFA code": "Virheellinen turvakoodi", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sisäänkirjautuminen epäonnistui. Tämä voi johtua siitä, että kaksivaiheinen tunnistautuminen on pois käytöstä tunnuksellasi.", "Wrong answer": "Väärä vastaus", "Erroneous CAPTCHA": "Virheellinen CAPTCHA", "CAPTCHA is a required field": "CAPTCHA-kenttä vaaditaan", "User ID is a required field": "Käyttäjätunnus vaaditaan", "Password is a required field": "Salasana vaaditaan", "Wrong username or password": "Väärä käyttäjänimi tai salasana", - "Please sign in using 'Log in with Google'": "Ole hyvä ja kirjaudu sisään Google-tunnuksella", "Password cannot be empty": "Salasana ei voi olla tyhjä", "Password cannot be longer than 55 characters": "Salasana ei voi olla yli 55 merkkiä pitkä", "Please log in": "Kirjaudu sisään, ole hyvä", diff --git a/locales/fr.json b/locales/fr.json index 9d3e117f..d2607a49 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -24,7 +24,6 @@ "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?", "New password": "Nouveau mot de passe", "New passwords must match": "Les nouveaux mots de passe doivent correspondre", - "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé depuis Invidious", "Authorize token?": "Autoriser le token ?", "Authorize token for `x`?": "Autoriser le token pour `x` ?", "Yes": "Oui", @@ -47,7 +46,6 @@ "source": "source", "Log in": "Se connecter", "Log in/register": "Se connecter/S'inscrire", - "Log in with Google": "Se connecter avec Google", "User ID": "Identifiant utilisateur", "Password": "Mot de passe", "Time (h:mm:ss):": "Heure (h:mm:ss) :", @@ -56,7 +54,6 @@ "Sign In": "Se connecter", "Register": "S'inscrire", "E-mail": "E-mail", - "Google verification code": "Code de vérification Google", "Preferences": "Préférences", "preferences_category_player": "Préférences du lecteur", "preferences_video_loop_label": "Lire en boucle : ", @@ -179,17 +176,12 @@ "Hide replies": "Masquer les réponses", "Show replies": "Afficher les réponses", "Incorrect password": "Mot de passe incorrect", - "Quota exceeded, try again in a few hours": "Nombre de tentatives de connexion dépassé, réessayez dans quelques heures", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", - "Invalid TFA code": "Code d'authentification à deux facteurs invalide", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Wrong answer": "Réponse invalide", "Erroneous CAPTCHA": "CAPTCHA invalide", "CAPTCHA is a required field": "Veuillez entrer un CAPTCHA", "User ID is a required field": "Veuillez entrer un Identifiant Utilisateur", "Password is a required field": "Veuillez entrer un Mot de passe", "Wrong username or password": "Nom d'utilisateur ou mot de passe invalide", - "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant \"Se connecter avec Google\"", "Password cannot be empty": "Le mot de passe ne peut pas être vide", "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères", "Please log in": "Veuillez vous connecter", @@ -473,8 +465,16 @@ "search_filters_features_option_vr180": "VR180", "search_filters_duration_option_none": "Toutes les durées", "error_video_not_in_playlist": "La vidéo demandée n'existe pas dans cette liste de lecture. Cliquez ici pour retourner à la liste de lecture.", - "channel_tab_shorts_label": "Clips", - "channel_tab_streams_label": "En direct", + "channel_tab_shorts_label": "Vidéos courtes", + "channel_tab_streams_label": "Vidéos en direct", "channel_tab_playlists_label": "Listes de lecture", - "channel_tab_channels_label": "Chaînes" + "channel_tab_channels_label": "Chaînes", + "Song: ": "Chanson : ", + "Artist: ": "Artiste : ", + "Album: ": "Album : ", + "Standard YouTube license": "Licence YouTube Standard", + "Music in this video": "Musique dans cette vidéo", + "Channel Sponsor": "Soutien de la chaîne", + "Download is disabled": "Le téléchargement est désactivé", + "Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)" } diff --git a/locales/he.json b/locales/he.json index ab42313b..6fee93b2 100644 --- a/locales/he.json +++ b/locales/he.json @@ -14,7 +14,6 @@ "Clear watch history?": "לנקות את היסטוריית הצפייה?", "New password": "סיסמה חדשה", "New passwords must match": "על הסיסמאות החדשות להתאים", - "Cannot change password for Google accounts": "לא ניתן לשנות את הסיסמה לחשבונות Google", "Authorize token?": "לאשר את האסימון?", "Authorize token for `x`?": "האם לאשר את האסימון עבור `x`?", "Yes": "כן", @@ -37,7 +36,6 @@ "source": "source", "Log in": "כניסה", "Log in/register": "כניסה/הרשמה", - "Log in with Google": "כניסה עם Google", "User ID": "שם משתמש", "Password": "סיסמה", "Time (h:mm:ss):": "זמן (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "התחברות", "Register": "הרשמה", "E-mail": "דוא״ל", - "Google verification code": "קוד האימות של Google", "Preferences": "העדפות", "preferences_category_player": "העדפות הנגן", "preferences_autoplay_label": "ניגון אוטומטי: ", @@ -137,7 +134,6 @@ "User ID is a required field": "חובה למלא את שדה שם המשתמש", "Password is a required field": "חובה למלא את שדה הסיסמה", "Wrong username or password": "שם משתמש שגוי או סיסמה שגויה", - "Please sign in using 'Log in with Google'": "נא להתחבר בעזרת \"התחברות עם Google\"", "Password cannot be longer than 55 characters": "על אורך הסיסמה להיות 55 תווים לכל היותר", "Please log in": "נא להתחבר", "channel:`x`": "ערוץ:`x`", diff --git a/locales/hi.json b/locales/hi.json index 41335266..dcb7294d 100644 --- a/locales/hi.json +++ b/locales/hi.json @@ -4,7 +4,6 @@ "No": "नहीं", "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML के रूप में सदस्यताएँ निर्यात करें (NewPipe और FreeTube के लिए)", "Log in/register": "लॉग-इन/पंजीकृत करें", - "Log in with Google": "Google के साथ लॉग-इन करें", "preferences_autoplay_label": "अपने आप चलाने की सुविधा: ", "preferences_dark_mode_label": "थीम: ", "preferences_default_home_label": "डिफ़ॉल्ट मुखपृष्ठ: ", @@ -58,7 +57,6 @@ "Clear watch history?": "देखने का इतिहास मिटाएँ?", "New password": "नया पासवर्ड", "New passwords must match": "पासवर्ड्स को मेल खाना होगा", - "Cannot change password for Google accounts": "Google खातों के लिए पासवर्ड नहीं बदल सकते", "Authorize token?": "टोकन को प्रमाणित करें?", "Authorize token for `x`?": "`x` के लिए टोकन को प्रमाणित करें?", "Import and Export Data": "डेटा को आयात और निर्यात करें", @@ -81,7 +79,6 @@ "Password": "पासवर्ड", "Register": "पंजीकृत करें", "E-mail": "ईमेल", - "Google verification code": "Google प्रमाणीकरण कोड", "Time (h:mm:ss):": "समय (घं:मिमि:सेसे):", "Text CAPTCHA": "टेक्स्ट CAPTCHA", "Image CAPTCHA": "चित्र CAPTCHA", @@ -224,15 +221,10 @@ "Hide replies": "जवाब छिपाएँ", "Show replies": "जवाब दिखाएँ", "Incorrect password": "गलत पासवर्ड", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "लॉग-इन नहीं किया जा सका, सुनिश्चित करें कि दो-कारक प्रमाणीकरण (Authenticator या SMS) सक्षम है।", - "Invalid TFA code": "अमान्य TFA कोड", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "लॉग-इन नाकाम रहा। ऐसा इसलिए हो सकता है कि दो-कारक प्रमाणीकरण आपके खाते पर सक्षम नहीं है।", - "Quota exceeded, try again in a few hours": "कोटा पार हो चुका है, कृपया कुछ घंटों में फिर कोशिश करें", "CAPTCHA is a required field": "CAPTCHA एक ज़रूरी फ़ील्ड है", "User ID is a required field": "सदस्य ID एक ज़रूरी फ़ील्ड है", "Password is a required field": "पासवर्ड एक ज़रूरी फ़ील्ड है", "Wrong username or password": "गलत सदस्यनाम या पासवर्ड", - "Please sign in using 'Log in with Google'": "कृपया 'Google के साथ लॉग-इन करें' के साथ साइन-इन करें", "Password cannot be empty": "पासवर्ड खाली नहीं हो सकता", "Password cannot be longer than 55 characters": "पासवर्ड में अधिकतम 55 अक्षर हो सकते हैं", "Invidious Private Feed for `x`": "`x` के लिए Invidious निजी फ़ीड", diff --git a/locales/hr.json b/locales/hr.json index ade732ad..0549fa70 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -14,7 +14,6 @@ "Clear watch history?": "Izbrisati povijest gledanja?", "New password": "Nova lozinka", "New passwords must match": "Nove lozinke se moraju poklapati", - "Cannot change password for Google accounts": "Nije moguće promijeniti lozinku za Google račune", "Authorize token?": "Autorizirati token?", "Authorize token for `x`?": "Autorizirati token za `x`?", "Yes": "Da", @@ -37,7 +36,6 @@ "source": "izvor", "Log in": "Prijavi se", "Log in/register": "Prijavi se/registriraj se", - "Log in with Google": "Prijavi se pomoću Googlea", "User ID": "Korisnički ID", "Password": "Lozinka", "Time (h:mm:ss):": "Vrijeme (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Prijavi se", "Register": "Registriraj se", "E-mail": "E-mail adresa", - "Google verification code": "Googleov potvrdni kod", "Preferences": "Postavke", "preferences_category_player": "Postavke playera", "preferences_video_loop_label": "Uvijek ponavljaj: ", @@ -164,17 +161,12 @@ "Hide replies": "Sakrij odgovore", "Show replies": "Prikaži odgovore", "Incorrect password": "Neispravna lozinka", - "Quota exceeded, try again in a few hours": "Kvota je prekoračena. Pokušaj ponovo za par sati", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Prijava neuspjela. Provjeri da je dvofaktorska autentifikacija uključena (Authenticator ili SMS).", - "Invalid TFA code": "Neispravan TFA kod", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prijava neuspjela. Možda zato što za tvoj račun nije uključena dvofaktorska autentifikacija.", "Wrong answer": "Krivi odgovor", "Erroneous CAPTCHA": "Neispravan CAPTCHA", "CAPTCHA is a required field": "CAPTCHA je obavezno polje", "User ID is a required field": "Korisnički ID je obavezno polje", "Password is a required field": "Polje lozinke je obavezno polje", "Wrong username or password": "Krivo korisničko ime ili lozinka", - "Please sign in using 'Log in with Google'": "Za prijavu koristi „Prijavi se pomoću Googlea”", "Password cannot be empty": "Polje lozinke ne smije ostati prazno", "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova", "Please log in": "Prijavi se", @@ -497,5 +489,8 @@ "Album: ": "Album: ", "Artist: ": "Izvođač: ", "Channel Sponsor": "Sponzor kanala", - "Song: ": "Pjesma: " + "Song: ": "Pjesma: ", + "Standard YouTube license": "Standardna YouTube licenca", + "Download is disabled": "Preuzimanje je deaktivirano", + "Import YouTube playlist (.csv)": "Uvezi YouTube zbirku (.csv)" } diff --git a/locales/hu-HU.json b/locales/hu-HU.json index f93930e0..1899b71c 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -24,7 +24,6 @@ "Clear watch history?": "Törölve legyen a megnézett videók naplója?", "New password": "Új jelszó", "New passwords must match": "Az új jelszavaknak egyezniük kell.", - "Cannot change password for Google accounts": "A Google-fiók jelszavát nem lehet megváltoztatni.", "Authorize token?": "Engedélyezve legyen a token?", "Authorize token for `x`?": "Engedélyezve legyen a token erre? „`x`”", "Yes": "Igen", @@ -47,7 +46,6 @@ "source": "forrás", "Log in": "Bejelentkezés", "Log in/register": "Bejelentkezés/Regisztrálás", - "Log in with Google": "Bejelentkezés Google-fiókkal", "User ID": "Felhasználói azonosító", "Password": "Jelszó", "Time (h:mm:ss):": "A pontos idő (ó:pp:mm):", @@ -56,7 +54,6 @@ "Sign In": "Bejelentkezés", "Register": "Regisztrálás", "E-mail": "E-mail-cím", - "Google verification code": "A Google ellenőrző kódja", "Preferences": "Beállítások", "preferences_category_player": "Lejátszó beállításai", "preferences_video_loop_label": "Videó állandó ismétlése: ", @@ -173,16 +170,12 @@ "Hide replies": "Válaszok elrejtése", "Show replies": "Válaszok mutatása", "Incorrect password": "A jelszó nem megfelelő", - "Quota exceeded, try again in a few hours": "A kvótát meghaladták. Néhány órával később próbáld meg újból betölteni.", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nem sikerült bejelentkezni. A kétlépcsős (hitelesítő vagy szöveges üzenet általi) hitelesítésnek bekapcsolva kell lennie.", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Nem sikerült bejelentkezni. Ennek oka lehet, hogy a kétlépcsős hitelesítés nincs bekapcsolva a fiók beállításaiban.", "Wrong answer": "Nem jól válaszoltál.", "Erroneous CAPTCHA": "A CAPTCHA hibás.", "CAPTCHA is a required field": "A CAPTCHA-mezőt ki kell tölteni.", "User ID is a required field": "A felhasználói azonosítót meg kell adni.", "Password is a required field": "Meg kell adni egy jelszót.", "Wrong username or password": "Vagy a felhasználói név, vagy pedig a jelszó nem megfelelő.", - "Please sign in using 'Log in with Google'": "A „Bejelentkezés Google-el” gombbal jelentkezz be.", "Password cannot be empty": "A jelszót nem lehet kihagyni.", "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél.", "Please log in": "Kérjük, jelentkezz be.", @@ -419,7 +412,6 @@ "Switch Invidious Instance": "Váltás másik Invidious-oldalra", "Urdu": "urdu", "search_filters_date_option_week": "Ezen a héten", - "Invalid TFA code": "A kétlépéses hitelesítés kódja nem megfelelő", "footer_documentation": "Dokumentáció", "search_filters_features_option_hd": "HD", "next_steps_error_message_go_to_youtube": "Ugrás a YouTube-ra", diff --git a/locales/id.json b/locales/id.json index 51d6d55c..ef677251 100644 --- a/locales/id.json +++ b/locales/id.json @@ -19,7 +19,6 @@ "Clear watch history?": "Bersihkan riwayat tontonan?", "New password": "Kata sandi baru", "New passwords must match": "Kata sandi baru harus cocok", - "Cannot change password for Google accounts": "Tidak dapat mengganti kata sandi untuk akun Google", "Authorize token?": "Otorisasi token?", "Authorize token for `x`?": "Otorisasi token untuk `x`?", "Yes": "Ya", @@ -42,7 +41,6 @@ "source": "sumber", "Log in": "Masuk", "Log in/register": "Masuk/Daftar", - "Log in with Google": "Masuk dengan Google", "User ID": "ID Pengguna", "Password": "Kata Sandi", "Time (h:mm:ss):": "Waktu (j:mm:dd):", @@ -51,7 +49,6 @@ "Sign In": "Masuk", "Register": "Daftar", "E-mail": "Surel", - "Google verification code": "Kode verifikasi Google", "Preferences": "Preferensi", "preferences_category_player": "Preferensi pemutar", "preferences_video_loop_label": "Selalu ulangi: ", @@ -171,17 +168,12 @@ "Hide replies": "Sembunyikan balasan", "Show replies": "Lihat balasan", "Incorrect password": "Kata sandi salah", - "Quota exceeded, try again in a few hours": "Kuota penuh, coba lagi dalam beberapa jam", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Tidak dapat masuk, pastikan autentikasi dua-faktor (autentikator atau SMS) sudah nyala.", - "Invalid TFA code": "Kode TFA tidak valid", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Gagal masuk. Ini mungkin disebabkan autentikasi dua-faktor tidak dinyalakan untuk akun Anda.", "Wrong answer": "Jawaban salah", "Erroneous CAPTCHA": "CAPTCHA salah", "CAPTCHA is a required field": "CAPTCHA perlu diisi", "User ID is a required field": "ID pengguna perlu diisi", "Password is a required field": "Kata sandi perlu diisi", "Wrong username or password": "Nama pengguna atau kata sandi salah", - "Please sign in using 'Log in with Google'": "Harap masuk menggunakan 'Masuk dengan Google'", "Password cannot be empty": "Kata sandi tidak boleh kosong", "Password cannot be longer than 55 characters": "Kata sandi tidak boleh lebih dari 55 karakter", "Please log in": "Harap masuk", @@ -453,5 +445,6 @@ "crash_page_switch_instance": "mencoba untuk menggunakan peladen lainnya", "crash_page_read_the_faq": "baca Soal Sering Ditanya (SSD/FAQ)", "crash_page_search_issue": "mencari isu yang ada di GitHub", - "crash_page_report_issue": "Jika yang di atas tidak membantu, buka isu baru di GitHub (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):" + "crash_page_report_issue": "Jika yang di atas tidak membantu, buka isu baru di GitHub (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):", + "Popular enabled: ": "Populer diaktifkan: " } diff --git a/locales/is.json b/locales/is.json index 3282eb50..ea4c4693 100644 --- a/locales/is.json +++ b/locales/is.json @@ -14,7 +14,6 @@ "Clear watch history?": "Hreinsa áhorfssögu?", "New password": "Nýtt lykilorð", "New passwords must match": "Nýtt lykilorð verður að passa", - "Cannot change password for Google accounts": "Ekki er hægt að breyta lykilorði fyrir Google reikninga", "Authorize token?": "Leyfa tákn?", "Authorize token for `x`?": "Leyfa tákn fyrir `x`?", "Yes": "Já", @@ -37,7 +36,6 @@ "source": "uppspretta", "Log in": "Skrá inn", "Log in/register": "Innskráning/nýskráning", - "Log in with Google": "Skrá inn með Google", "User ID": "Notandakenni", "Password": "Lykilorð", "Time (h:mm:ss):": "Tími (h:mm: ss):", @@ -46,7 +44,6 @@ "Sign In": "Skrá inn", "Register": "Nýskrá", "E-mail": "Tölvupóstur", - "Google verification code": "Google staðfestingarkóði", "Preferences": "Kjörstillingar", "preferences_category_player": "Kjörstillingar spilara", "preferences_video_loop_label": "Alltaf lykkja: ", @@ -155,17 +152,12 @@ "Hide replies": "Fela svör", "Show replies": "Sýna svör", "Incorrect password": "Rangt lykilorð", - "Quota exceeded, try again in a few hours": "Kvóti fór yfir, reyndu aftur eftir nokkrar klukkustundir", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ekki er hægt að skrá þig inn, vertu viss um að tvíþætt staðfesting (Authenticator eða SMS) sé kveikt á.", - "Invalid TFA code": "Ógildur TFA kóði", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Innskráning mistókst. Þetta gæti verið vegna þess að tvíþátta staðfesting er ekki kveikt á reikningnum þínum.", "Wrong answer": "Rangt svar", "Erroneous CAPTCHA": "Rangt CAPTCHA", "CAPTCHA is a required field": "CAPTCHA er nauðsynlegur reitur", "User ID is a required field": "Notandakenni er nauðsynlegur reitur", "Password is a required field": "Lykilorð er nauðsynlegur reitur", "Wrong username or password": "Rangt notandanafn eða lykilorð", - "Please sign in using 'Log in with Google'": "Vinsamlegast skráðu þig inn með því að nota 'Innskráning með Google'", "Password cannot be empty": "Lykilorð má ekki vera autt", "Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir", "Please log in": "Vinsamlegast skráðu þig inn", diff --git a/locales/it.json b/locales/it.json index c60f760b..a3d0f5da 100644 --- a/locales/it.json +++ b/locales/it.json @@ -20,7 +20,6 @@ "Clear watch history?": "Eliminare la cronologia dei video guardati?", "New password": "Nuova password", "New passwords must match": "Le nuove password devono corrispondere", - "Cannot change password for Google accounts": "Non è possibile modificare la password per gli account Google", "Authorize token?": "Autorizzare gettone?", "Authorize token for `x`?": "Autorizzare gettone per `x`?", "Yes": "Sì", @@ -43,7 +42,6 @@ "source": "sorgente", "Log in": "Accedi", "Log in/register": "Accedi/Registrati", - "Log in with Google": "Accedi con Google", "User ID": "ID utente", "Password": "Password", "Time (h:mm:ss):": "Orario (h:mm:ss):", @@ -52,7 +50,6 @@ "Sign In": "Accedi", "Register": "Registrati", "E-mail": "E-mail", - "Google verification code": "Codice di verifica Google", "Preferences": "Preferenze", "preferences_category_player": "Preferenze del riproduttore", "preferences_video_loop_label": "Ripeti sempre: ", @@ -169,17 +166,12 @@ "Hide replies": "Nascondi le risposte", "Show replies": "Mostra le risposte", "Incorrect password": "Password sbagliata", - "Quota exceeded, try again in a few hours": "Limite superato, prova di nuovo fra qualche ora", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossibile autenticarsi, controlla che l'autenticazione in due passaggi (Authenticator o SMS) sia attiva.", - "Invalid TFA code": "Codice di autenticazione a due fattori non valido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fallito. L'errore potrebbe essere causato dal fatto che la verifica in due passaggi non è attiva sul tuo account.", "Wrong answer": "Risposta errata", "Erroneous CAPTCHA": "CAPTCHA errato", "CAPTCHA is a required field": "Il CAPTCHA è un campo obbligatorio", "User ID is a required field": "L'ID utente è obbligatorio", "Password is a required field": "La password è un campo obbligatorio", "Wrong username or password": "Nome utente o password errati", - "Please sign in using 'Log in with Google'": "Per favore accedi con «Entra con Google»", "Password cannot be empty": "La password non può essere vuota", "Password cannot be longer than 55 characters": "La password non può contenere più di 55 caratteri", "Please log in": "Per favore, accedi", @@ -479,5 +471,10 @@ "channel_tab_community_label": "Comunità", "Music in this video": "Musica in questo video", "Artist: ": "Artista: ", - "Album: ": "Album: " + "Album: ": "Album: ", + "Download is disabled": "Il download è disabilitato", + "Song: ": "Canzone: ", + "Standard YouTube license": "Licenza standard di YouTube", + "Channel Sponsor": "Sponsor del canale", + "Import YouTube playlist (.csv)": "Importa playlist di YouTube (.csv)" } diff --git a/locales/ja.json b/locales/ja.json index 8a4537d4..8adcbf6a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1,15 +1,15 @@ { "generic_views_count_0": "{{count}} 回視聴", - "generic_videos_count_0": "{{count}} 個の動画", - "generic_playlists_count_0": "{{count}} 個の再生リスト", + "generic_videos_count_0": "{{count}}本の動画", + "generic_playlists_count_0": "{{count}}個の再生リスト", "generic_subscribers_count_0": "{{count}} 人の登録者", - "generic_subscriptions_count_0": "{{count}} 個の登録チャンネル", + "generic_subscriptions_count_0": "{{count}}個の登録チャンネル", "LIVE": "ライブ", "Shared `x` ago": "`x`前に公開", "Unsubscribe": "登録解除", "Subscribe": "登録", - "View channel on YouTube": "YouTube でチャンネルを見る", - "View playlist on YouTube": "YouTube で再生リストを見る", + "View channel on YouTube": "YouTube でチャンネルを表示", + "View playlist on YouTube": "YouTube で再生リストを表示", "newest": "新しい順", "oldest": "古い順", "popular": "人気順", @@ -19,7 +19,6 @@ "Clear watch history?": "再生履歴を削除しますか?", "New password": "新しいパスワード", "New passwords must match": "新しいパスワードが一致していません", - "Cannot change password for Google accounts": "Google アカウントのパスワードは変更できません", "Authorize token?": "トークンを認証しますか?", "Authorize token for `x`?": "トークン `x` を認証しますか?", "Yes": "はい", @@ -42,7 +41,6 @@ "source": "ソース", "Log in": "ログイン", "Log in/register": "ログイン/登録", - "Log in with Google": "Google でログイン", "User ID": "ユーザー ID", "Password": "パスワード", "Time (h:mm:ss):": "時間 (時:分分:秒秒):", @@ -51,16 +49,15 @@ "Sign In": "サインイン", "Register": "登録", "E-mail": "メールアドレス", - "Google verification code": "Google 認証コード", "Preferences": "設定", "preferences_category_player": "プレイヤーの設定", "preferences_video_loop_label": "常にループ: ", "preferences_autoplay_label": "自動再生: ", - "preferences_continue_label": "次の動画を再生: ", + "preferences_continue_label": "次の動画を自動再生: ", "preferences_continue_autoplay_label": "次の動画を自動再生: ", - "preferences_listen_label": "デフォルトで音声モードを使用: ", - "preferences_local_label": "動画視聴にプロキシーを経由: ", - "preferences_speed_label": "標準の再生速度: ", + "preferences_listen_label": "音声モードを使用: ", + "preferences_local_label": "動画視聴にプロキシを経由: ", + "preferences_speed_label": "再生速度の初期値: ", "preferences_quality_label": "優先する画質: ", "preferences_volume_label": "プレイヤーの音量: ", "preferences_comments_label": "デフォルトのコメント: ", @@ -69,7 +66,7 @@ "preferences_captions_label": "優先する字幕: ", "Fallback captions: ": "フォールバック時の字幕: ", "preferences_related_videos_label": "関連動画を表示: ", - "preferences_annotations_label": "デフォルトでアノテーションを表示: ", + "preferences_annotations_label": "最初からアノテーションを表示: ", "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ", "preferences_vr_mode_label": "対話的な360°動画 (WebGL が必要): ", "preferences_category_visual": "外観設定", @@ -82,7 +79,7 @@ "preferences_category_misc": "ほかの設定", "preferences_automatic_instance_redirect_label": "インスタンスの自動転送 (redirect.invidious.ioにフォールバック): ", "preferences_category_subscription": "登録チャンネル設定", - "preferences_annotations_subscribed_label": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", + "preferences_annotations_subscribed_label": "最初から登録チャンネルのアノテーションを表示 ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", "preferences_max_results_label": "フィードに表示する動画の量: ", "preferences_sort_label": "動画を並び替え: ", @@ -109,8 +106,8 @@ "Delete account": "アカウントを削除", "preferences_category_admin": "管理者設定", "preferences_default_home_label": "ホームに表示するページ: ", - "preferences_feed_menu_label": "フィードメニュー: ", - "preferences_show_nick_label": "ニックネームを一番上に表示する: ", + "preferences_feed_menu_label": "フィードのメニュー: ", + "preferences_show_nick_label": "ログイン名を上部に表示: ", "Top enabled: ": "トップページを有効化: ", "CAPTCHA enabled: ": "CAPTCHA を有効化: ", "Login enabled: ": "ログインを有効化: ", @@ -120,18 +117,18 @@ "Subscription manager": "登録チャンネルの管理", "Token manager": "トークンの管理", "Token": "トークン", - "tokens_count_0": "{{count}} 個のトークン", + "tokens_count_0": "{{count}}個のトークン", "Import/export": "インポート/エクスポート", "unsubscribe": "登録解除", "revoke": "取り消す", "Subscriptions": "登録チャンネル", - "subscriptions_unseen_notifs_count_0": "{{count}} 個の未読通知", + "subscriptions_unseen_notifs_count_0": "{{count}}件の未読通知", "search": "検索", "Log out": "ログアウト", "Released under the AGPLv3 on Github.": "GitHub 上で AGPLv3 の元で公開", "Source available here.": "ソースはここで閲覧可能です。", "View JavaScript license information.": "JavaScript ライセンス情報", - "View privacy policy.": "プライバシーポリシー", + "View privacy policy.": "個人情報保護方針", "Trending": "急上昇", "Public": "公開", "Unlisted": "限定公開", @@ -142,11 +139,11 @@ "Delete playlist": "再生リストを削除", "Create playlist": "再生リストを作成", "Title": "タイトル", - "Playlist privacy": "再生リストの公開設定", + "Playlist privacy": "再生リストの公開状態", "Editing playlist `x`": "再生リスト `x` を編集中", "Show more": "もっと見る", "Show less": "表示を少なく", - "Watch on YouTube": "YouTube で視聴", + "Watch on YouTube": "YouTubeで視聴", "Switch Invidious Instance": "Invidious インスタンスの変更", "Hide annotations": "アノテーションを隠す", "Show annotations": "アノテーションを表示", @@ -161,27 +158,22 @@ "Premieres in `x`": "`x`後にプレミア公開", "Premieres `x`": "`x`にプレミア公開", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "やあ!君は JavaScript を無効にしているのかな?ここをクリックしてコメントを見れるけど、読み込みには少し時間がかかることがあるのを覚えておいてね。", - "View YouTube comments": "YouTube のコメントを見る", + "View YouTube comments": "YouTube のコメントを表示", "View more comments on Reddit": "Reddit でコメントをもっと見る", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件のコメントを見る", - "": "`x` 件のコメントを見る" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件のコメントを表示", + "": "`x` 件のコメントを表示" }, - "View Reddit comments": "Reddit のコメントを見る", + "View Reddit comments": "Reddit のコメントを表示", "Hide replies": "返信を非表示", "Show replies": "返信を表示", "Incorrect password": "パスワードが間違っています", - "Quota exceeded, try again in a few hours": "試行を制限中です。数時間後にやり直してください", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "ログインできませんでした。2段階認証 (認証アプリまたは SMS) が有効になっていることを確認してください。", - "Invalid TFA code": "TFA (2段階認証) コードが無効です", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "ログインに失敗しました。あなたのアカウントで2段階認証が有効になっていない可能性があります。", "Wrong answer": "回答が間違っています", "Erroneous CAPTCHA": "CAPTCHA が間違っています", "CAPTCHA is a required field": "CAPTCHA は必須項目です", "User ID is a required field": "ユーザー ID は必須項目です", "Password is a required field": "パスワードは必須項目です", "Wrong username or password": "ユーザー名またはパスワードが間違っています", - "Please sign in using 'Log in with Google'": "「Google でログイン」を使用してログインしてください", "Password cannot be empty": "パスワードは空にできません", "Password cannot be longer than 55 characters": "パスワードは55文字より長くできません", "Please log in": "ログインしてください", @@ -314,7 +306,7 @@ "Zulu": "ズール語", "generic_count_years_0": "{{count}}年", "generic_count_months_0": "{{count}}か月", - "generic_count_weeks_0": "{{count}}週", + "generic_count_weeks_0": "{{count}}週間", "generic_count_days_0": "{{count}}日", "generic_count_hours_0": "{{count}}時間", "generic_count_minutes_0": "{{count}}分", @@ -326,8 +318,8 @@ "About": "このサービスについて", "Rating: ": "評価: ", "preferences_locale_label": "言語: ", - "View as playlist": "再生リストで見る", - "Default": "デフォルト", + "View as playlist": "再生リストとして閲覧", + "Default": "標準", "Music": "音楽", "Gaming": "ゲーム", "News": "ニュース", @@ -347,7 +339,7 @@ "search_filters_sort_option_relevance": "関連度", "search_filters_sort_option_rating": "評価", "search_filters_sort_option_date": "アップロード日", - "search_filters_sort_option_views": "再生回数", + "search_filters_sort_option_views": "視聴回数", "search_filters_type_label": "種類", "search_filters_duration_label": "再生時間", "search_filters_features_label": "特徴", @@ -375,7 +367,7 @@ "next_steps_error_message_refresh": "再読込", "next_steps_error_message_go_to_youtube": "YouTubeへ", "search_filters_duration_option_short": "4 分未満", - "footer_documentation": "文書", + "footer_documentation": "説明書", "footer_source_code": "ソースコード", "footer_original_source_code": "元のソースコード", "footer_modfied_source_code": "改変して使用", @@ -383,7 +375,7 @@ "search_filters_duration_option_long": "20 分以上", "preferences_region_label": "地域: ", "footer_donate_page": "寄付する", - "preferences_quality_dash_label": "優先するDash画質 : ", + "preferences_quality_dash_label": "優先するDASH画質: ", "preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_144p": "144p", @@ -403,11 +395,11 @@ "none": "なし", "download_subtitles": "字幕 - `x` (.vtt)", "search_filters_features_option_purchased": "購入済み", - "preferences_quality_option_dash": "DASH (適応品質)", + "preferences_quality_option_dash": "DASH (適応的画質)", "preferences_quality_dash_option_worst": "最悪", "preferences_quality_dash_option_best": "最高", "videoinfo_started_streaming_x_ago": "`x`前に配信を開始", - "videoinfo_watch_on_youTube": "YouTube上で見る", + "videoinfo_watch_on_youTube": "YouTubeで視聴", "user_created_playlists": "`x`個の作成した再生リスト", "Video unavailable": "動画は利用できません", "Chinese": "中国語", @@ -442,9 +434,9 @@ "crash_page_switch_instance": "別のインスタンスを使用を試す", "crash_page_read_the_faq": "よくある質問 (FAQ) を読む", "Popular enabled: ": "人気動画を有効化 ", - "search_message_use_another_instance": " 別のインスタンス上でも検索できます。", + "search_message_use_another_instance": " 別のインスタンス上での検索も可能です。", "search_filters_apply_button": "選択したフィルターを適用", - "user_saved_playlists": "`x` 個の保存した再生リスト", + "user_saved_playlists": "`x`個の保存済みの再生リスト", "crash_page_you_found_a_bug": "Invidious のバグのようです!", "crash_page_refresh": "ページを更新を試す", "preferences_watch_history_label": "再生履歴を有効化 ", @@ -465,5 +457,8 @@ "Artist: ": "アーティスト: ", "Album: ": "アルバム: ", "Song: ": "曲: ", - "Channel Sponsor": "チャンネルのスポンサー" + "Channel Sponsor": "チャンネルのスポンサー", + "Standard YouTube license": "標準 Youtube ライセンス", + "Download is disabled": "ダウンロード: このインスタンスでは未対応", + "Import YouTube playlist (.csv)": "YouTube 再生リストをインポート (.csv)" } diff --git a/locales/ko.json b/locales/ko.json index d4f3a711..9c8db5a1 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -32,7 +32,6 @@ "preferences_video_loop_label": "항상 반복: ", "preferences_category_player": "플레이어 설정", "Preferences": "설정", - "Google verification code": "구글 인증 코드", "E-mail": "이메일", "Register": "회원가입", "Sign In": "로그인", @@ -42,11 +41,10 @@ "Time (h:mm:ss):": "시각 (h:mm:ss):", "Password": "비밀번호", "User ID": "사용자 ID", - "Log in with Google": "구글로 로그인", "Log in/register": "로그인/회원가입", "Log in": "로그인", "source": "출처", - "JavaScript license information": "자바스크립트 라이센스 정보", + "JavaScript license information": "자바스크립트 라이선스 정보", "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", "History": "역사", "Delete account?": "계정을 삭제 하시겠습니까?", @@ -65,7 +63,6 @@ "Yes": "예", "Authorize token for `x`?": "`x` 에 대한 토큰을 승인하시겠습니까?", "Authorize token?": "토큰을 승인하시겠습니까?", - "Cannot change password for Google accounts": "구글 계정의 비밀번호를 변경할 수 없습니다", "New passwords must match": "새 비밀번호는 일치해야 합니다", "New password": "새 비밀번호", "Clear watch history?": "재생 기록을 삭제 하시겠습니까?", @@ -112,11 +109,10 @@ "This channel does not exist.": "이 채널은 존재하지 않습니다.", "Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널", "channel:`x`": "채널:`x`", - "Invalid TFA code": "유효하지 않은 TFA 코드", "Show replies": "댓글 보기", "Hide replies": "댓글 숨기기", "Incorrect password": "잘못된 비밀번호", - "License: ": "라이센스: ", + "License: ": "라이선스: ", "Genre: ": "장르: ", "Editing playlist `x`": "재생목록 `x` 수정하기", "Playlist privacy": "재생목록 공개 범위", @@ -135,7 +131,7 @@ "Unlisted": "목록에 없음", "Public": "공개", "View privacy policy.": "개인정보 처리방침 보기.", - "View JavaScript license information.": "자바스크립트 라이센스 정보 보기.", + "View JavaScript license information.": "자바스크립트 라이선스 정보 보기.", "Source available here.": "소스는 여기에서 사용할 수 있습니다.", "Log out": "로그아웃", "search": "검색", @@ -249,7 +245,6 @@ "Engagement: ": "약속: ", "Wilson score: ": "Wilson Score: ", "Family friendly? ": "전연령 영상입니까? ", - "Quota exceeded, try again in a few hours": "한도량을 초과했습니다. 몇 시간 후에 다시 시도하세요", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x`개의 댓글 보기", "": "`x`개의 댓글 보기" @@ -272,7 +267,6 @@ "Bulgarian": "불가리아어", "Bosnian": "보스니아어", "Belarusian": "벨라루스어", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "로그인할 수 없습니다. 이중 인증(Authenticator 또는 SMS)이 켜져 있는지 확인하세요.", "View more comments on Reddit": "레딧에서 더 많은 댓글 보기", "View YouTube comments": "유튜브 댓글 보기", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.", @@ -282,13 +276,11 @@ "Please log in": "로그인하세요", "Password cannot be longer than 55 characters": "비밀번호는 55자 이하여야 합니다", "Password cannot be empty": "비밀번호는 비워둘 수 없습니다", - "Please sign in using 'Log in with Google'": "'구글로 로그인'을 사용하여 로그인하세요", "Wrong username or password": "잘못된 사용자 이름 또는 비밀번호", "Password is a required field": "비밀번호는 필수 입력란입니다", "User ID is a required field": "사용자 ID는 필수 입력란입니다", "CAPTCHA is a required field": "캡차는 필수 입력란입니다", "Erroneous CAPTCHA": "잘못된 캡차", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "로그인 실패. 계정에 이중 인증이 설정되어 있지 않기 때문일 수 있습니다.", "Blacklisted regions: ": "차단된 지역: ", "Playlists": "재생목록", "View as playlist": "재생목록으로 보기", @@ -460,5 +452,13 @@ "channel_tab_shorts_label": "쇼츠", "channel_tab_streams_label": "실시간 스트리밍", "channel_tab_channels_label": "채널", - "channel_tab_playlists_label": "재생목록" + "channel_tab_playlists_label": "재생목록", + "Standard YouTube license": "표준 유튜브 라이선스", + "Song: ": "제목: ", + "Channel Sponsor": "채널 스폰서", + "Album: ": "앨범: ", + "Music in this video": "동영상 속 음악", + "Artist: ": "아티스트: ", + "Download is disabled": "다운로드가 비활성화 되어있음", + "Import YouTube playlist (.csv)": "유튜브 플레이리스트 가져오기 (.csv)" } diff --git a/locales/lt.json b/locales/lt.json index 9bfcfdba..740be7b6 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -14,7 +14,6 @@ "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", @@ -37,7 +36,6 @@ "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):", @@ -46,7 +44,6 @@ "Sign In": "Prisijungti", "Register": "Registruotis", "E-mail": "El. paštas", - "Google verification code": "Google patvirtinimo kodas", "Preferences": "Pasirinktys", "preferences_category_player": "Grotuvo pasirinktys", "preferences_video_loop_label": "Visada kartoti: ", @@ -164,17 +161,12 @@ "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", @@ -488,5 +480,6 @@ "preferences_save_player_pos_label": "Išsaugoti atkūrimo padėtį: ", "videoinfo_youTube_embed_link": "Įterpti", "videoinfo_invidious_embed_link": "Įterpti nuorodą", - "crash_page_refresh": "pabandėte atnaujinti puslapį" + "crash_page_refresh": "pabandėte atnaujinti puslapį", + "Album: ": "Albumas " } diff --git a/locales/nb-NO.json b/locales/nb-NO.json index d29cca43..1e0e9e77 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -14,7 +14,6 @@ "Clear watch history?": "Tøm visningshistorikk?", "New password": "Nytt passord", "New passwords must match": "Nye passordfelter må stemme overens", - "Cannot change password for Google accounts": "Kan ikke endre passord for Google-kontoer", "Authorize token?": "Identitetsbekreft symbol?", "Authorize token for `x`?": "Identitetsbekreft symbol for `x`?", "Yes": "Ja", @@ -37,7 +36,6 @@ "source": "kilde", "Log in": "Logg inn", "Log in/register": "Logg inn/registrer", - "Log in with Google": "Logg inn med Google", "User ID": "Bruker-ID", "Password": "Passord", "Time (h:mm:ss):": "Tid (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Innlogging", "Register": "Registrer", "E-mail": "E-post", - "Google verification code": "Google-bekreftelseskode", "Preferences": "Innstillinger", "preferences_category_player": "Avspillerinnstillinger", "preferences_video_loop_label": "Alltid gjenta: ", @@ -164,17 +161,12 @@ "Hide replies": "Skjul svar", "Show replies": "Vis svar", "Incorrect password": "Feil passord", - "Quota exceeded, try again in a few hours": "Kvote overskredet, prøv igjen om et par timer", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kunne ikke logge inn, forsikre deg om at tofaktor-identitetsbekreftelse (Authenticator eller SMS) er skrudd på.", - "Invalid TFA code": "Ugyldig tofaktorkode", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Innlogging mislyktes. Dette kan være fordi tofaktor-identitetsbekreftelse er skrudd av på kontoen din.", "Wrong answer": "Ugyldig svar", "Erroneous CAPTCHA": "Ugyldig CAPTCHA", "CAPTCHA is a required field": "CAPTCHA er et påkrevd felt", "User ID is a required field": "Bruker-ID er et påkrevd felt", "Password is a required field": "Passord er et påkrevd felt", "Wrong username or password": "Ugyldig brukernavn eller passord", - "Please sign in using 'Log in with Google'": "Logg inn ved bruk av \"Google-innlogging\"", "Password cannot be empty": "Passordet kan ikke være tomt", "Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn", "Please log in": "Logg inn", @@ -472,5 +464,17 @@ "search_filters_apply_button": "Bruk valgte filtre", "search_filters_date_option_none": "Siden begynnelsen", "search_filters_features_option_vr180": "VR180", - "error_video_not_in_playlist": "Forespurt video finnes ikke i denne spillelisten. Trykk her for spillelistens hjemmeside." + "error_video_not_in_playlist": "Forespurt video finnes ikke i denne spillelisten. Trykk her for spillelistens hjemmeside.", + "Standard YouTube license": "Standard YouTube-lisens", + "Song: ": "Sang: ", + "channel_tab_streams_label": "Direktesendinger", + "channel_tab_shorts_label": "Kortvideoer", + "channel_tab_playlists_label": "Spillelister", + "Music in this video": "Musikk i denne videoen", + "channel_tab_channels_label": "Kanaler", + "Artist: ": "Artist: ", + "Album: ": "Album: ", + "Download is disabled": "Nedlasting er avskrudd", + "Channel Sponsor": "Kanalsponsor", + "Import YouTube playlist (.csv)": "Importer YouTube-spilleliste (.csv)" } diff --git a/locales/nl.json b/locales/nl.json index dfc68671..aa5da731 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -14,7 +14,6 @@ "Clear watch history?": "Wil je de kijkgeschiedenis wissen?", "New password": "Nieuw wachtwoord", "New passwords must match": "De nieuwe wachtwoorden moeten overeenkomen", - "Cannot change password for Google accounts": "Kan het wachtwoord van Google-accounts niet wijzigen", "Authorize token?": "Wil je de toegangssleutel machtigen?", "Authorize token for `x`?": "Wil je de toegangssleutel machtigen voor `x`?", "Yes": "Ja", @@ -37,7 +36,6 @@ "source": "bron", "Log in": "Inloggen", "Log in/register": "Inloggen/Registreren", - "Log in with Google": "Inloggen met Google", "User ID": "Gebruikers-id", "Password": "Wachtwoord", "Time (h:mm:ss):": "Tijd (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Inloggen", "Register": "Registreren", "E-mail": "E-mailadres", - "Google verification code": "Google-verificatiecode", "Preferences": "Instellingen", "preferences_category_player": "Spelerinstellingen", "preferences_video_loop_label": "Altijd herhalen: ", @@ -159,17 +156,12 @@ "Hide replies": "Antwoorden verbergen", "Show replies": "Antwoorden tonen", "Incorrect password": "Wachtwoord is onjuist", - "Quota exceeded, try again in a few hours": "Quota overschreden; probeer het over een paar uur opnieuw", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kan niet inloggen. Zorg ervoor dat authenticatie in twee stappen (Authenticator of sms) is ingeschakeld.", - "Invalid TFA code": "Onjuiste TFA-code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Inloggen mislukt. Wellicht is authenticatie in twee stappen niet ingeschakeld op je account.", "Wrong answer": "Onjuist antwoord", "Erroneous CAPTCHA": "Onjuiste CAPTCHA", "CAPTCHA is a required field": "CAPTCHA is vereist", "User ID is a required field": "Gebruikers-id is vereist", "Password is a required field": "Wachtwoord is vereist", "Wrong username or password": "Onjuiste gebruikersnaam of wachtwoord", - "Please sign in using 'Log in with Google'": "Log in via 'Inloggen met Google'", "Password cannot be empty": "Het wachtwoordveld mag niet leeg zijn", "Password cannot be longer than 55 characters": "Het wachtwoord mag niet langer dan 55 tekens zijn", "Please log in": "Log in", diff --git a/locales/pl.json b/locales/pl.json index 3c713e70..e237db8b 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -14,7 +14,6 @@ "Clear watch history?": "Wyczyścić historię?", "New password": "Nowe hasło", "New passwords must match": "Nowe hasła muszą być identyczne", - "Cannot change password for Google accounts": "Nie można zmienić hasła do konta Google", "Authorize token?": "Autoryzować token?", "Authorize token for `x`?": "Autoryzować token dla `x`?", "Yes": "Tak", @@ -37,7 +36,6 @@ "source": "źródło", "Log in": "Zaloguj", "Log in/register": "Zaloguj/Zarejestruj", - "Log in with Google": "Zaloguj do Google", "User ID": "ID użytkownika", "Password": "Hasło", "Time (h:mm:ss):": "Godzina (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Zaloguj się", "Register": "Zarejestruj się", "E-mail": "E-mail", - "Google verification code": "Kod weryfikacyjny Google", "Preferences": "Preferencje", "preferences_category_player": "Ustawienia odtwarzacza", "preferences_video_loop_label": "Zawsze zapętlaj: ", @@ -163,17 +160,12 @@ "Hide replies": "Ukryj odpowiedzi", "Show replies": "Pokaż odpowiedzi", "Incorrect password": "Niepoprawne hasło", - "Quota exceeded, try again in a few hours": "Przekroczony limit zapytań, spróbuj ponownie za kilka godzin", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nie udało się zalogować, upewnij się, że dwuetapowe uwierzytelnianie (Autentykator lub SMS) jest aktywne.", - "Invalid TFA code": "Niepoprawny kod TFA", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Nie udało się zalogować. To może być spowodowane wyłączoną dwustopniową autoryzacją na twoim koncie.", "Wrong answer": "Niepoprawna odpowiedź", "Erroneous CAPTCHA": "CAPTCHA wykonane błędnie", "CAPTCHA is a required field": "CAPTCHA jest polem wymaganym", "User ID is a required field": "ID użytkownika jest polem wymaganym", "Password is a required field": "Hasło jest polem wymaganym", "Wrong username or password": "Niepoprawny login lub hasło", - "Please sign in using 'Log in with Google'": "Zaloguj się używając \"Zaloguj się przez Google\"", "Password cannot be empty": "Hasło nie może być puste", "Password cannot be longer than 55 characters": "Hasło nie może być dłuższe niż 55 znaków", "Please log in": "Proszę się zalogować", @@ -498,5 +490,7 @@ "Artist: ": "Wykonawca: ", "Album: ": "Album: ", "Song: ": "Piosenka: ", - "Channel Sponsor": "Sponsor kanału" + "Channel Sponsor": "Sponsor kanału", + "Standard YouTube license": "Standardowa licencja YouTube", + "Import YouTube playlist (.csv)": "Importuj playlistę YouTube (.csv)" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index ec00d46e..81290398 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -14,7 +14,6 @@ "Clear watch history?": "Limpar histórico de reprodução?", "New password": "Nova senha", "New passwords must match": "Nova senha deve ser igual", - "Cannot change password for Google accounts": "Não é possível alterar sua senha de contas do Google", "Authorize token?": "Autorizar o token?", "Authorize token for `x`?": "Autorizar o token para `x`?", "Yes": "Sim", @@ -37,7 +36,6 @@ "source": "código-fonte", "Log in": "Entrar", "Log in/register": "Entrar/Registrar", - "Log in with Google": "Entrar com conta Google", "User ID": "Usuário", "Password": "Senha", "Time (h:mm:ss):": "Hora (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Entrar", "Register": "Registrar", "E-mail": "E-mail", - "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", "preferences_category_player": "Preferências do reprodutor", "preferences_video_loop_label": "Repetir sempre: ", @@ -166,17 +163,12 @@ "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", "Incorrect password": "Senha incorreta", - "Quota exceeded, try again in a few hours": "Cota excedida, tente novamente em algumas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não foi possível fazer login, sua autenticação em dois passos (app autenticador ou sms) deve estar ativada.", - "Invalid TFA code": "Código TFA inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falha no login. Isso pode acontecer porque a autenticação em dois passos está desativada para sua conta.", "Wrong answer": "Resposta incorreta", "Erroneous CAPTCHA": "CAPTCHA inválido", "CAPTCHA is a required field": "O CAPTCHA é um campo obrigatório", "User ID is a required field": "O nome de usuário é um campo obrigatório", "Password is a required field": "A senha é um campo obrigatório", "Wrong username or password": "Nome de usuário ou senha inválidos", - "Please sign in using 'Log in with Google'": "Por favor, entre usando 'Entrar com conta Google'", "Password cannot be empty": "A senha não pode ficar em branco", "Password cannot be longer than 55 characters": "A senha não pode ter mais que 55 caracteres", "Please log in": "Por favor, inicie sua sessão", @@ -479,5 +471,10 @@ "channel_tab_streams_label": "Ao Vivo", "Music in this video": "Música neste vídeo", "Artist: ": "Artista: ", - "Album: ": "Álbum: " + "Album: ": "Álbum: ", + "Standard YouTube license": "Licença padrão do YouTube", + "Song: ": "Música: ", + "Channel Sponsor": "Patrocinador do Canal", + "Download is disabled": "Download está desativado", + "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)" } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 43834d70..3834c9e2 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -14,7 +14,6 @@ "Clear watch history?": "Limpar histórico de reprodução?", "New password": "Nova palavra-chave", "New passwords must match": "As novas palavra-chaves devem corresponder", - "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google", "Authorize token?": "Autorizar token?", "Authorize token for `x`?": "Autorizar token para `x`?", "Yes": "Sim", @@ -37,7 +36,6 @@ "source": "código-fonte", "Log in": "Iniciar sessão", "Log in/register": "Iniciar sessão/registar", - "Log in with Google": "Iniciar sessão com o Google", "User ID": "Utilizador", "Password": "Palavra-chave", "Time (h:mm:ss):": "Tempo (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Iniciar sessão", "Register": "Registar", "E-mail": "E-mail", - "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", "preferences_category_player": "Preferências do reprodutor", "preferences_video_loop_label": "Repetir sempre: ", @@ -166,17 +163,12 @@ "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", "Incorrect password": "Palavra-chave incorreta", - "Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.", - "Invalid TFA code": "Código TFA inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).", "Wrong answer": "Resposta errada", "Erroneous CAPTCHA": "CAPTCHA inválido", "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", "User ID is a required field": "O nome de utilizador é um campo obrigatório", "Password is a required field": "Palavra-chave é um campo obrigatório", "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto", - "Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'", "Password cannot be empty": "A palavra-chave não pode estar vazia", "Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres", "Please log in": "Por favor, inicie sessão", diff --git a/locales/pt.json b/locales/pt.json index 310381ae..dfa411c3 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -63,8 +63,6 @@ "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.", "Could not create mix.": "Não foi possível criar a mistura.", "Deleted or invalid channel": "Canal eliminado ou inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.", "Delete playlist": "Eliminar lista de reprodução", "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?", @@ -81,7 +79,6 @@ "Log in/register": "Iniciar sessão/registar", "Delete account?": "Eliminar conta?", "Import and Export Data": "Importar e exportar dados", - "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google", "Filipino": "Filipino", "Estonian": "Estónio", "Esperanto": "Esperanto", @@ -125,15 +122,12 @@ "Please log in": "Por favor, inicie sessão", "Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres", "Password cannot be empty": "A palavra-chave não pode estar vazia", - "Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'", "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto", "Password is a required field": "Palavra-chave é um campo obrigatório", "User ID is a required field": "O nome de utilizador é um campo obrigatório", "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", "Erroneous CAPTCHA": "CAPTCHA inválido", "Wrong answer": "Resposta errada", - "Invalid TFA code": "Código TFA inválido", - "Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas", "Incorrect password": "Palavra-chave incorreta", "Show replies": "Mostrar respostas", "Hide replies": "Ocultar respostas", @@ -232,7 +226,6 @@ "preferences_video_loop_label": "Repetir sempre: ", "preferences_category_player": "Preferências do reprodutor", "Preferences": "Preferências", - "Google verification code": "Código de verificação do Google", "E-mail": "E-mail", "Register": "Registar", "Image CAPTCHA": "Imagem CAPTCHA", @@ -240,7 +233,6 @@ "Time (h:mm:ss):": "Tempo (h:mm:ss):", "Password": "Palavra-chave", "User ID": "Utilizador", - "Log in with Google": "Iniciar sessão com o Google", "Log in": "Iniciar sessão", "source": "código-fonte", "JavaScript license information": "Informação de licença do JavaScript", @@ -481,5 +473,8 @@ "Artist: ": "Artista: ", "Album: ": "Álbum: ", "Song: ": "Canção: ", - "Channel Sponsor": "Patrocinador do canal" + "Channel Sponsor": "Patrocinador do canal", + "Standard YouTube license": "Licença padrão do YouTube", + "Download is disabled": "A descarga está desativada", + "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)" } diff --git a/locales/ro.json b/locales/ro.json index 0f6407d6..85bf746f 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -14,7 +14,6 @@ "Clear watch history?": "Doriți să ștergeți istoricul?", "New password": "Parola nouă", "New passwords must match": "Câmpurile \"Parolă nouă\" trebuie să fie identice", - "Cannot change password for Google accounts": "Parola pentru un cont Google nu poate fi schimbată de pe Invidious", "Authorize token?": "Autorizați token-ul?", "Authorize token for `x`?": "Autorizați token-ul pentru `x` ?", "Yes": "Da", @@ -37,7 +36,6 @@ "source": "sursă", "Log in": "Conectați-vă", "Log in/register": "Conectați-vă/Creați-vă un cont", - "Log in with Google": "Conectați-vă cu Google", "User ID": "ID Utilizator", "Password": "Parolă", "Time (h:mm:ss):": "Ora (h:mm:ss) :", @@ -46,7 +44,6 @@ "Sign In": "Conectați-vă", "Register": "Înregistrați-vă", "E-mail": "E-mail", - "Google verification code": "Cod de verificare Google", "Preferences": "Preferințe", "preferences_category_player": "Setări de redare", "preferences_video_loop_label": "Reluați videoclipul la nesfârșit: ", @@ -155,17 +152,12 @@ "Hide replies": "Ascundeți replicile", "Show replies": "Afișați replicile", "Incorrect password": "Parolă incorectă", - "Quota exceeded, try again in a few hours": "Numărul de tentative de conectare a fost depășit. Va rugăm să încercați din nou în câteva ore", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Conectare eșuată. Dacă nu reușiți să vă conectați, verificați dacă ați activat autentificarea cu doi factori (Autentificator sau SMS).", - "Invalid TFA code": "Codul de autentificare cu doi factori este invalid", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Conectare eșuată. Acest lucru ar putea fi cauzat de faptul că nu ați activat autentificarea cu doi factori.", "Wrong answer": "Răspuns invalid", "Erroneous CAPTCHA": "CAPTCHA invalid", "CAPTCHA is a required field": "Câmpul CAPTCHA este obligatoriu", "User ID is a required field": "Câmpul ID Utilizator este obligatoriu", "Password is a required field": "Câmpul Parolă este obligatoriu", "Wrong username or password": "Nume de utilizator sau parolă invalidă", - "Please sign in using 'Log in with Google'": "Vă rog conectați-vă folosind \"Conectați-vă cu Google\"", "Password cannot be empty": "Parola nu poate fi goală", "Password cannot be longer than 55 characters": "Parola nu poate să conțină mai mult de 55 de caractere", "Please log in": "Vă rog conectați-vă", diff --git a/locales/ru.json b/locales/ru.json index 7ca5cf1f..a93207ad 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -14,7 +14,6 @@ "Clear watch history?": "Очистить историю просмотров?", "New password": "Новый пароль", "New passwords must match": "Новые пароли не совпадают", - "Cannot change password for Google accounts": "Изменить пароль аккаунта Google невозможно", "Authorize token?": "Авторизовать токен?", "Authorize token for `x`?": "Авторизовать токен для `x`?", "Yes": "Да", @@ -30,23 +29,21 @@ "Export subscriptions as OPML": "Экспортировать подписки в формате OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)", "Export data as JSON": "Экспортировать данные Invidious в формате JSON", - "Delete account?": "Удалить аккаунт?", + "Delete account?": "Удалить учётку?", "History": "История", "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube", "JavaScript license information": "Информация о лицензиях JavaScript", "source": "источник", "Log in": "Войти", "Log in/register": "Войти или зарегистрироваться", - "Log in with Google": "Войти через Google", - "User ID": "ID пользователя", + "User ID": "ИД пользователя", "Password": "Пароль", "Time (h:mm:ss):": "Время (ч:мм:сс):", "Text CAPTCHA": "Текстовая капча (англ.)", "Image CAPTCHA": "Капча-картинка", "Sign In": "Войти", "Register": "Зарегистрироваться", - "E-mail": "Электронная почта", - "Google verification code": "Код подтверждения Google", + "E-mail": "Эл. почта", "Preferences": "Настройки", "preferences_category_player": "Настройки проигрывателя", "preferences_video_loop_label": "Всегда повторять: ", @@ -69,11 +66,11 @@ "preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ", "preferences_category_visual": "Настройки сайта", "preferences_player_style_label": "Стиль проигрывателя: ", - "Dark mode: ": "Темное оформление: ", + "Dark mode: ": "Тёмное оформление: ", "preferences_dark_mode_label": "Тема: ", - "dark": "темная", + "dark": "тёмная", "light": "светлая", - "preferences_thin_mode_label": "Облегченное оформление: ", + "preferences_thin_mode_label": "Облегчённое оформление: ", "preferences_category_misc": "Прочие настройки", "preferences_automatic_instance_redirect_label": "Автоматическая смена зеркала (переход на redirect.invidious.io): ", "preferences_category_subscription": "Настройки подписок", @@ -147,13 +144,13 @@ "License: ": "Лицензия: ", "Family friendly? ": "Семейный просмотр: ", "Wilson score: ": "Оценка Уилсона: ", - "Engagement: ": "Вовлеченность: ", + "Engagement: ": "Вовлечённость: ", "Whitelisted regions: ": "Доступно в регионах: ", "Blacklisted regions: ": "Недоступно в регионах: ", "Shared `x`": "Опубликовано `x`", "Premieres in `x`": "Премьера через `x`", "Premieres `x`": "Премьера `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии. Но учтите: они могут загружаться немного медленнее.", + "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": { @@ -164,39 +161,34 @@ "Hide replies": "Скрыть ответы", "Show replies": "Показать ответы", "Incorrect password": "Неправильный пароль", - "Quota exceeded, try again in a few hours": "Лимит превышен, попробуйте снова через несколько часов", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Не удалось войти. Проверьте, не включена ли двухфакторная аутентификация (по коду или смс).", - "Invalid TFA code": "Неправильный код двухфакторной аутентификации", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.", "Wrong answer": "Неправильный ответ", "Erroneous CAPTCHA": "Неправильная капча", "CAPTCHA is a required field": "Необходимо решить капчу", - "User ID is a required field": "Необходимо ввести ID пользователя", + "User ID is a required field": "Необходимо ввести идентификатор пользователя", "Password is a required field": "Необходимо ввести пароль", "Wrong username or password": "Неправильный логин или пароль", - "Please sign in using 'Log in with Google'": "Пожалуйста, нажмите «Войти через Google»", "Password cannot be empty": "Пароль не может быть пустым", "Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов", "Please log in": "Пожалуйста, войдите", "Invidious Private Feed for `x`": "Приватная лента Invidious для `x`", "channel:`x`": "канал: `x`", - "Deleted or invalid channel": "Канал удален или не найден", + "Deleted or invalid channel": "Канал удалён или не найден", "This channel does not exist.": "Такого канала не существует.", - "Could not get channel info.": "Не удается получить информацию об этом канале.", - "Could not fetch comments": "Не удается загрузить комментарии", + "Could not get channel info.": "Не удаётся получить информацию об этом канале.", + "Could not fetch comments": "Не удаётся загрузить комментарии", "`x` ago": "`x` назад", - "Load more": "Загрузить еще", + "Load more": "Загрузить ещё", "Could not create mix.": "Не удалось создать микс.", "Empty playlist": "Плейлист пуст", "Not a playlist.": "Это не плейлист.", "Playlist does not exist.": "Плейлист не существует.", - "Could not pull trending pages.": "Не удается загрузить страницы «в тренде».", + "Could not pull trending pages.": "Не удаётся загрузить страницы «в тренде».", "Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле «challenge»", "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»", "Erroneous challenge": "Неправильный ответ в «challenge»", "Erroneous token": "Неправильный токен", "No such user": "Пользователь не найден", - "Token is expired, please try again": "Срок действия токена истек, попробуйте позже", + "Token is expired, please try again": "Срок действия токена истёк, попробуйте позже", "English": "Английский", "English (auto-generated)": "Английский (созданы автоматически)", "Afrikaans": "Африкаанс", @@ -213,7 +205,7 @@ "Burmese": "Бирманский", "Catalan": "Каталонский", "Cebuano": "Себуанский", - "Chinese (Simplified)": "Китайский (упрощенный)", + "Chinese (Simplified)": "Китайский (упрощённый)", "Chinese (Traditional)": "Китайский (традиционный)", "Corsican": "Корсиканский", "Croatian": "Хорватский", @@ -328,14 +320,14 @@ "channel_tab_videos_label": "Видео", "Playlists": "Плейлисты", "channel_tab_community_label": "Сообщество", - "search_filters_sort_option_relevance": "по актуальности", - "search_filters_sort_option_rating": "по рейтингу", - "search_filters_sort_option_date": "по дате загрузки", - "search_filters_sort_option_views": "по просмотрам", + "search_filters_sort_option_relevance": "актуальности", + "search_filters_sort_option_rating": "рейтингу", + "search_filters_sort_option_date": "дате загрузки", + "search_filters_sort_option_views": "просмотрам", "search_filters_type_label": "Тип", "search_filters_duration_label": "Длительность", "search_filters_features_label": "Дополнительно", - "search_filters_sort_label": "Сортировать", + "search_filters_sort_label": "Сортировать по", "search_filters_date_option_hour": "Последний час", "search_filters_date_option_today": "Сегодня", "search_filters_date_option_week": "Эта неделя", @@ -379,7 +371,7 @@ "Turkish (auto-generated)": "Турецкий (созданы автоматически)", "Vietnamese (auto-generated)": "Вьетнамский (созданы автоматически)", "footer_documentation": "Документация", - "adminprefs_modified_source_code_url_label": "URL-адрес репозитория измененного исходного кода", + "adminprefs_modified_source_code_url_label": "Ссылка на репозиторий с измененными исходными кодами", "none": "ничего", "videoinfo_watch_on_youTube": "Смотреть на YouTube", "videoinfo_youTube_embed_link": "Версия для встраивания", @@ -453,8 +445,8 @@ "Portuguese (Brazil)": "Португальский (Бразилия)", "footer_source_code": "Исходный код", "footer_original_source_code": "Оригинальный исходный код", - "footer_modfied_source_code": "Измененный исходный код", - "user_saved_playlists": "`x` сохраненных плейлистов", + "footer_modfied_source_code": "Изменённый исходный код", + "user_saved_playlists": "`x` сохранённых плейлистов", "crash_page_search_issue": "поискали похожую проблему на GitHub", "comments_points_count_0": "{{count}} плюс", "comments_points_count_1": "{{count}} плюса", @@ -491,9 +483,14 @@ "error_video_not_in_playlist": "Запрошенного видео нет в этом плейлисте. Нажмите тут, чтобы вернуться к странице плейлиста.", "channel_tab_playlists_label": "Плейлисты", "channel_tab_channels_label": "Каналы", - "channel_tab_streams_label": "Живое вещание", + "channel_tab_streams_label": "Стримы", "channel_tab_shorts_label": "Shorts", "Music in this video": "Музыка в этом видео", "Artist: ": "Исполнитель: ", - "Album: ": "Альбом: " + "Album: ": "Альбом: ", + "Song: ": "Композиция: ", + "Standard YouTube license": "Стандартная лицензия YouTube", + "Channel Sponsor": "Спонсор канала", + "Download is disabled": "Загрузка отключена", + "Import YouTube playlist (.csv)": "Импорт плейлиста YouTube (.csv)" } diff --git a/locales/si.json b/locales/si.json index 69501343..19f34fac 100644 --- a/locales/si.json +++ b/locales/si.json @@ -14,7 +14,6 @@ "oldest": "පැරණිතම", "popular": "ජනප්‍රිය", "last": "අවසන්", - "Cannot change password for Google accounts": "Google ගිණුම් සඳහා මුරපදය වෙනස් කළ නොහැක", "Authorize token?": "ටෝකනය අනුමත කරනවා ද?", "Authorize token for `x`?": "`x` සඳහා ටෝකනය අනුමත කරනවා ද?", "Yes": "ඔව්", @@ -31,7 +30,6 @@ "An alternative front-end to YouTube": "YouTube සඳහා විකල්ප ඉදිරිපස අන්තයක්", "source": "මූලාශ්‍රය", "Log in/register": "පුරන්න/ලියාපදිංචිවන්න", - "Log in with Google": "Google සමඟ පුරන්න", "Password": "මුරපදය", "Time (h:mm:ss):": "වේලාව (h:mm:ss):", "Sign In": "පුරන්න", @@ -86,7 +84,6 @@ "User ID": "පරිශීලක කේතය", "Text CAPTCHA": "CAPTCHA පෙල", "Image CAPTCHA": "CAPTCHA රූපය", - "Google verification code": "Google සත්‍යාපන කේතය", "E-mail": "විද්‍යුත් තැපෑල", "preferences_quality_label": "කැමති වීඩියෝ ගුණත්වය: ", "preferences_quality_option_hd720": "HD720", diff --git a/locales/sk.json b/locales/sk.json index cdb3a596..7346dc58 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -12,7 +12,6 @@ "Clear watch history?": "Vymazať históriu sledovania?", "New password": "Nové heslo", "New passwords must match": "Nové heslá sa musia zhodovať", - "Cannot change password for Google accounts": "Heslo pre účty Google sa nedá zmeniť", "Authorize token?": "Autorizovať token?", "Yes": "Áno", "No": "Nie", @@ -34,7 +33,6 @@ "source": "zdroj", "Log in": "Prihlásiť sa", "Log in/register": "Prihlásiť sa/Registrovať", - "Log in with Google": "Prihlásiť sa pomocou účtu Google", "User ID": "ID používateľa", "Password": "Heslo", "Time (h:mm:ss):": "Čas (h:mm:ss):", @@ -43,7 +41,6 @@ "Sign In": "Prihlásiť sa", "Register": "Registrovať", "E-mail": "E-mail", - "Google verification code": "Overovací kód Google", "Preferences": "Nastavenia", "preferences_category_player": "Nastavenia prehrávača", "preferences_video_loop_label": "Vždy opakovať: ", diff --git a/locales/sl.json b/locales/sl.json index 47f295e0..45f63c6b 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -8,7 +8,6 @@ "Clear watch history?": "Izbrisati zgodovino ogledov?", "New password": "Novo geslo", "New passwords must match": "Nova gesla se morajo ujemati", - "Cannot change password for Google accounts": "Ni mogoče spremeniti gesla za račune Google", "Authorize token?": "Naj odobrim žeton?", "Yes": "Da", "Import and Export Data": "Uvoz in izvoz podatkov", @@ -22,7 +21,6 @@ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvozi naročnine kot OPML (za NewPipe in FreeTube)", "Log in": "Prijava", "Log in/register": "Prijava/registracija", - "Log in with Google": "Prijavi se z Googlom", "User ID": "ID uporabnika", "Password": "Geslo", "Time (h:mm:ss):": "Čas (h:mm:ss):", @@ -32,7 +30,6 @@ "Sign In": "Prijavi se", "Register": "Registriraj se", "E-mail": "E-pošta", - "Google verification code": "Googlova koda za preverjanje", "Preferences": "Nastavitve", "preferences_video_loop_label": "Vedno v zanki: ", "preferences_autoplay_label": "Samodejno predvajanje: ", @@ -120,9 +117,6 @@ "([^.,0-9]|^)1([^.,0-9]|$)": "Poglej `x` komentar", "": "Poglej `x` komentarjev" }, - "Quota exceeded, try again in a few hours": "Kvota je presežena, poskusi znova čez nekaj ur", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ne morem se prijaviti, preveri, ali je vklopljeno dvofaktorsko preverjanje pristnosti (avtentikator ali SMS).", - "Please sign in using 'Log in with Google'": "Prijavi se z uporabo »Prijava z Googlom«", "Password cannot be empty": "Geslo ne sme biti prazno", "`x` ago": "`x` nazaj", "Load more": "Naloži več", @@ -348,8 +342,6 @@ "View Reddit comments": "Oglej si komentarje na Redditu", "This channel does not exist.": "Ta kanal ne obstaja.", "Hide replies": "Skrij odgovore", - "Invalid TFA code": "Neveljavna koda TFA", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prijava ni uspela. To je lahko zato, ker za tvoj račun ni vklopljeno dvofaktorsko preverjanje pristnosti.", "Invidious Private Feed for `x`": "Invidious zasebni vir za `x`", "Deleted or invalid channel": "Izbrisan ali neveljaven kanal", "Empty playlist": "Prazen seznam predvajanja", @@ -511,5 +503,10 @@ "channel_tab_streams_label": "Prenosi v živo", "Artist: ": "Umetnik/ca: ", "Music in this video": "Glasba v tem videoposnetku", - "Album: ": "Album: " + "Album: ": "Album: ", + "Song: ": "Pesem: ", + "Standard YouTube license": "Standardna licenca YouTube", + "Channel Sponsor": "Sponzor kanala", + "Download is disabled": "Prenos je onemogočen", + "Import YouTube playlist (.csv)": "Uvoz seznama predvajanja YouTube (.csv)" } diff --git a/locales/sq.json b/locales/sq.json index 7f29a035..d28eb784 100644 --- a/locales/sq.json +++ b/locales/sq.json @@ -35,12 +35,10 @@ "videoinfo_youTube_embed_link": "Trupëzojeni", "videoinfo_invidious_embed_link": "Lidhje Trupëzimi", "oldest": "më të vjetrat", - "Cannot change password for Google accounts": "S’mund të ndryshojë fjalëkalimin për llogari Google", "New passwords must match": "Fjalëkalimet e rinj duhet të përputhen me njëri-tjetrin", "Authorize token?": "Të autorizohet token-i?", "Authorize token for `x`?": "Të autorizohet token-i për `x`?", "Log in/register": "Hyni/regjistrohuni", - "Log in with Google": "Hyni me Google", "User ID": "ID Përdoruesi", "Password": "Fjalëkalim", "Time (h:mm:ss):": "Kohë (h:mm:ss):", @@ -156,19 +154,14 @@ "Whitelisted regions: ": "Rajone të lejuara: ", "Premieres `x`": "Premiera `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.": "Njatjeta! Duket sikur keni JavaScript-in të çaktivizuar. Klikoni këtu që të shihni komentet, mbani parasysh se mund të duhet pak më tepër kohë që të ngarkohen.", - "Quota exceeded, try again in a few hours": "Janë tejkaluar kuotat, riprovoni pas pak orësh", "Blacklisted regions: ": "Rajone të palejuara: ", "Premieres in `x`": "Premiera në `x`", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "S’arrihet të bëhet hyrja, sigurohuni se mirëfilltësimi dyfaktorësh (me Mirëfilltësues apo SMS) është i aktivizuar.", "Wrong answer": "Përgjigje e gabuar", - "Invalid TFA code": "Kod MDF i pavlefshëm", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Dështoi hyrja. Kjo mund të vijë ngaqë për llogarinë tuaj s’është aktivizuar mirëfilltësimi dyfaktorësh.", "Erroneous CAPTCHA": "CAPTCHA e gabuar", "CAPTCHA is a required field": "CAPTCHA është fushë e domosdoshme", "User ID is a required field": "ID-ja e përdoruesit është fushë e domosdoshme", "Password is a required field": "Fusha e fjalëkalimit është e domosdoshme", "Wrong username or password": "Emër përdoruesi ose fjalëkalim i gabuar", - "Please sign in using 'Log in with Google'": "Ju lutemi, bëni hyrjen duke përdorur “Bëni hyrjen me Google”", "Password cannot be empty": "Fjalëkalimi s’mund të jetë i zbrazët", "Password cannot be longer than 55 characters": "Fjalëkalimi s’mund të jetë më i gjatë se 55 shenja", "Please log in": "Ju lutemi, bëni hyrjen", @@ -303,7 +296,6 @@ "Previous page": "Faqja e mëparshme", "Clear watch history?": "Të spastrohet historiku i parjeve?", "New password": "Fjalëkalim i ri", - "Google verification code": "Kod verifikimi Google", "preferences_related_videos_label": "Shfaq video të afërta: ", "preferences_annotations_label": "Si parazgjedhje, shfaqi shënimet: ", "preferences_show_nick_label": "Shfaqe nofkën në krye: ", diff --git a/locales/sr.json b/locales/sr.json index fd19c493..a2853b68 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -14,7 +14,6 @@ "Clear watch history?": "Izbrisati povest pregledanja?", "New password": "Nova lozinka", "New passwords must match": "Nove lozinke moraju biti istovetne", - "Cannot change password for Google accounts": "Nije moguće promeniti lozinku za Google naloge", "Authorize token?": "Ovlasti žeton?", "Authorize token for `x`?": "Ovlasti žeton za `x`?", "Yes": "Da", @@ -37,7 +36,6 @@ "source": "izvor", "Log in": "Prijavi se", "Log in/register": "Prijavi se/Otvori nalog", - "Log in with Google": "Prijavi se pomoću Google-a", "User ID": "Korisnički ID", "Password": "Lozinka", "Time (h:mm:ss):": "Vreme (č:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Prijava", "Register": "Otvori nalog", "E-mail": "E-pošta", - "Google verification code": "Google-ova overna koda", "Preferences": "Podešavanja", "preferences_category_player": "Podešavanja reproduktora", "preferences_video_loop_label": "Uvek ponavljaj: ", @@ -57,13 +54,11 @@ "preferences_local_label": "Prikaz video zapisa preko posrednika: ", "Playlist privacy": "Podešavanja privatnosti plej liste", "Editing playlist `x`": "Izmena plej liste `x`", - "Please sign in using 'Log in with Google'": "Molimo Vas da se prijavite pomoću 'Log in with Google'", "Playlist does not exist.": "Nepostojeća plej lista.", "Erroneous challenge": "Pogrešan izazov", "Maltese": "Malteški", "Download": "Preuzmi", "Download as: ": "Preuzmi kao: ", - "Quota exceeded, try again in a few hours": "Kvota je premašena, molimo vas da pokušate ponovo za par sati", "Bangla": "Bangla/Bengalski", "preferences_quality_dash_label": "Preferirani kvalitet DASH video formata: ", "Token manager": "Upravljanje žetonima", @@ -182,7 +177,6 @@ "": "Prikaži `x` komentara" }, "View Reddit comments": "Prikaži Reddit komentare", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Neuspešna prijava, proverite da li ste upalili dvofaktornu autentikaciju (Autentikator ili SMS).", "CAPTCHA is a required field": "CAPTCHA je obavezno polje", "Croatian": "Hrvatski", "Estonian": "Estonski", @@ -283,8 +277,6 @@ "Wrong answer": "Pogrešan odgovor", "preferences_quality_label": "Preferirani video kvalitet: ", "Hide replies": "Sakrij odgovore", - "Invalid TFA code": "Nevažeća TFA koda", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Neuspešna prijava! Ovo se možda dešava jer dvofaktorna autentikacija nije omogućena na vašem nalogu.", "Erroneous CAPTCHA": "Pogrešna CAPTCHA", "Erroneous token": "Pogrešan žeton", "Czech": "Češki", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index bef9915d..218f31c9 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -14,7 +14,6 @@ "Clear watch history?": "Избрисати повест прегледања?", "New password": "Нова лозинка", "New passwords must match": "Нове лозинке морају бити истоветне", - "Cannot change password for Google accounts": "Није могуће променити лозинку за Google налоге", "Authorize token?": "Овласти жетон?", "Authorize token for `x`?": "Овласти жетон за `x`?", "Yes": "Да", @@ -37,7 +36,6 @@ "source": "извор", "Log in": "Пријави се", "Log in/register": "Пријави се/Отворите налог", - "Log in with Google": "Пријави се помоћу Google-а", "User ID": "Кориснички ИД", "Password": "Лозинка", "Time (h:mm:ss):": "Време (ч:мм:сс):", @@ -46,7 +44,6 @@ "Sign In": "Пријава", "Register": "Отвори налог", "E-mail": "Е-пошта", - "Google verification code": "Google-ова оверна кода", "Preferences": "Подешавања", "preferences_category_player": "Подешавања репродуктора", "preferences_video_loop_label": "Увек понављај: ", @@ -150,8 +147,6 @@ "Burmese": "Бурмански", "preferences_quality_dash_label": "Преферирани квалитет DASH видео формата: ", "Erroneous token": "Погрешан жетон", - "Quota exceeded, try again in a few hours": "Квота је премашена, молимо вас да покушате поново за пар сати", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Неуспешна пријава, проверите да ли сте упалили двофакторну аутентикацију (Аутентикатор или СМС).", "CAPTCHA is a required field": "CAPTCHA је обавезно поље", "No such user": "Непостојећи корисник", "Chinese (Traditional)": "Кинески (Традиционални)", @@ -164,7 +159,6 @@ "preferences_show_nick_label": "Прикажи надимке на врху: ", "Report statistics: ": "Извештавај о статистици: ", "Show more": "Прикажи више", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Неуспешна пријава! Ово се можда дешава јер двофакторна аутентикација није омогућена на vашем налогу.", "Wrong answer": "Погрешан одговор", "Hidden field \"token\" is a required field": "Сакривено \"token\" поље је обавезно", "English": "Енглески", @@ -198,7 +192,6 @@ "User ID is a required field": "Кориснички ИД је обавезно поље", "Password is a required field": "Лозинка је обавезно поље", "Wrong username or password": "Погрешно корисничко име или лозинка", - "Please sign in using 'Log in with Google'": "Молимо Вас да се пријавите помоћу 'Log in with Google'", "Password cannot be empty": "Лозинка не може бити празна", "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 карактера", "Invidious Private Feed for `x`": "Инвидиоус Приватни Довод за `x`", @@ -324,7 +317,6 @@ "Released under the AGPLv3 on Github.": "Избачено под лиценцом AGPLv3 на GitHub-у.", "Afrikaans": "Африканс", "preferences_automatic_instance_redirect_label": "Аутоматско пребацивање на другу инстанцу у случају отказивања (пречи ће назад на редирецт.инвидиоус.ио): ", - "Invalid TFA code": "Неважећа TFA кода", "Please log in": "Молимо вас да се пријавите", "English (auto-generated)": "Енглески (аутоматски генерисано)", "Hindi": "Хинди", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 39e94fd3..a319fffd 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -14,7 +14,6 @@ "Clear watch history?": "Töm visningshistorik?", "New password": "Nytt lösenord", "New passwords must match": "Nya lösenord måste stämma överens", - "Cannot change password for Google accounts": "Kan inte ändra lösenord på Google-konton", "Authorize token?": "Auktorisera åtkomsttoken?", "Authorize token for `x`?": "Auktorisera åtkomsttoken för `x`?", "Yes": "Ja", @@ -37,7 +36,6 @@ "source": "källa", "Log in": "Logga in", "Log in/register": "Logga in/registrera", - "Log in with Google": "Logga in med Google", "User ID": "Användar-ID", "Password": "Lösenord", "Time (h:mm:ss):": "Tid (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Inloggning", "Register": "Registrera", "E-mail": "E-post", - "Google verification code": "Google-bekräftelsekod", "Preferences": "Inställningar", "preferences_category_player": "Spelarinställningar", "preferences_video_loop_label": "Loopa alltid: ", @@ -162,17 +159,12 @@ "Hide replies": "Dölj svar", "Show replies": "Visa svar", "Incorrect password": "Fel lösenord", - "Quota exceeded, try again in a few hours": "Kvoten överskriden, försök igen om ett par timmar", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kunde inte logga in, försäkra dig om att tvåfaktors-autentisering (Authenticator eller SMS) är påslagen.", - "Invalid TFA code": "Ogiltig tvåfaktor-kod", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Inloggning misslyckades. Detta kan vara för att tvåfaktors-autentisering inte är påslaget på ditt konto.", "Wrong answer": "Fel svar", "Erroneous CAPTCHA": "Ogiltig CAPTCHA", "CAPTCHA is a required field": "CAPTCHA är ett obligatoriskt fält", "User ID is a required field": "Användar-ID är ett obligatoriskt fält", "Password is a required field": "Lösenord är ett obligatoriskt fält", "Wrong username or password": "Ogiltigt användarnamn eller lösenord", - "Please sign in using 'Log in with Google'": "Logga in genom \"Google-inloggning\"", "Password cannot be empty": "Lösenordet kan inte vara tomt", "Password cannot be longer than 55 characters": "Lösenordet kan inte vara längre än 55 tecken", "Please log in": "Logga in", diff --git a/locales/tr.json b/locales/tr.json index 6e0bc175..22732a51 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -14,7 +14,6 @@ "Clear watch history?": "İzleme geçmişi temizlensin mi?", "New password": "Yeni Parola", "New passwords must match": "Yeni Parolalar Eşleşmek Zorunda", - "Cannot change password for Google accounts": "Google Hesapları İçin Parola Değiştirilemez", "Authorize token?": "Belirteç yetkilendirilsin mi?", "Authorize token for `x`?": "`x` için belirteç yetkilendirilsin mi?", "Yes": "Evet", @@ -37,7 +36,6 @@ "source": "Kaynak", "Log in": "Oturum Aç", "Log in/register": "Oturum Aç/Kayıt Ol", - "Log in with Google": "Google İle Oturum Aç", "User ID": "Kullanıcı Kimliği", "Password": "Parola", "Time (h:mm:ss):": "Zaman (h:mm:ss):", @@ -46,7 +44,6 @@ "Sign In": "Oturum Aç", "Register": "Kayıt Ol", "E-mail": "E-Posta", - "Google verification code": "Google Doğrulama Kodu", "Preferences": "Tercihler", "preferences_category_player": "Oynatıcı Tercihleri", "preferences_video_loop_label": "Sürekli Döngü: ", @@ -164,17 +161,12 @@ "Hide replies": "Cevapları Gizle", "Show replies": "Cevapları Göster", "Incorrect password": "Yanlış Parola", - "Quota exceeded, try again in a few hours": "Kota aşıldı, birkaç saat içinde tekrar deneyin.", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Oturum açılamadı, iki faktörlü kimlik doğrulamanın (Kimlik Doğrulayıcı ya da SMS) açık olduğundan emin olun.", - "Invalid TFA code": "Geçersiz TFA Kodu", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Giriş başarısız. Bunun nedeni, hesabınız için iki faktörlü kimlik doğrulamanın açık olmaması olabilir.", "Wrong answer": "Yanlış Cevap", "Erroneous CAPTCHA": "Hatalı CAPTCHA", "CAPTCHA is a required field": "CAPTCHA Zorunlu Bir Alandır", "User ID is a required field": "Kullanıcı Kimliği Zorunlu Bir Alandır", "Password is a required field": "Parola Zorunlu Bir Alandır", "Wrong username or password": "Yanlış Kullanıcı Adı ya da Parola", - "Please sign in using 'Log in with Google'": "Lütfen 'Google İle Giriş Yap' Seçeneğini Kullanarak Oturum Açın", "Password cannot be empty": "Parola Boş Olamaz", "Password cannot be longer than 55 characters": "Parola 55 Karakterden Uzun Olamaz", "Please log in": "Lütfen Oturum Açın", @@ -481,5 +473,8 @@ "Music in this video": "Bu videodaki müzik", "Artist: ": "Sanatçı: ", "Channel Sponsor": "Kanal Sponsoru", - "Song: ": "Şarkı: " + "Song: ": "Şarkı: ", + "Standard YouTube license": "Standart YouTube lisansı", + "Download is disabled": "İndirme devre dışı", + "Import YouTube playlist (.csv)": "YouTube Oynatma Listesini İçe Aktar (.csv)" } diff --git a/locales/uk.json b/locales/uk.json index 4d748e7f..308b10ca 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -14,7 +14,6 @@ "Clear watch history?": "Очистити історію переглядів?", "New password": "Новий пароль", "New passwords must match": "Нові паролі не співпадають", - "Cannot change password for Google accounts": "Змінити пароль обліківки Google неможливо", "Authorize token?": "Авторизувати токен?", "Authorize token for `x`?": "Авторизувати токен для `x`?", "Yes": "Так", @@ -37,7 +36,6 @@ "source": "джерело", "Log in": "Увійти", "Log in/register": "Увійти або зареєструватися", - "Log in with Google": "Увійти через Google", "User ID": "ID користувача", "Password": "Пароль", "Time (h:mm:ss):": "Час (г:хх:сс):", @@ -46,7 +44,6 @@ "Sign In": "Увійти", "Register": "Зареєструватися", "E-mail": "Електронна пошта", - "Google verification code": "Код підтвердження Google", "Preferences": "Налаштування", "preferences_category_player": "Налаштування програвача", "preferences_video_loop_label": "Завжди повторювати: ", @@ -155,17 +152,12 @@ "Hide replies": "Сховати відповіді", "Show replies": "Показати відповіді", "Incorrect password": "Неправильний пароль", - "Quota exceeded, try again in a few hours": "Ліміт перевищено, спробуйте знову за декілька годин", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Не вдається увійти. Перевірте, чи не ввімкнена двофакторна аутентифікація (за кодом чи смс).", - "Invalid TFA code": "Неправильний код двофакторної автентифікації", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не вдається увійти. Це може бути через те, що у вашій обліківці не ввімкнена двофакторна аутентифікація.", "Wrong answer": "Неправильна відповідь", "Erroneous CAPTCHA": "Неправильна капча", "CAPTCHA is a required field": "Необхідно пройти CAPTCHA", "User ID is a required field": "Необхідно ввести ID користувача", "Password is a required field": "Необхідно ввести пароль", "Wrong username or password": "Неправильний логін чи пароль", - "Please sign in using 'Log in with Google'": "Будь ласка, натисніть «Увійти через Google»", "Password cannot be empty": "Пароль не може бути порожнім", "Password cannot be longer than 55 characters": "Пароль не може бути довшим за 55 знаків", "Please log in": "Будь ласка, увійдіть", @@ -497,5 +489,8 @@ "Artist: ": "Виконавець: ", "Album: ": "Альбом: ", "Song: ": "Пісня: ", - "Channel Sponsor": "Спонсор каналу" + "Channel Sponsor": "Спонсор каналу", + "Standard YouTube license": "Стандартна ліцензія YouTube", + "Download is disabled": "Завантаження вимкнено", + "Import YouTube playlist (.csv)": "Імпорт списку відтворення YouTube (.csv)" } diff --git a/locales/vi.json b/locales/vi.json index 3f7125c4..d79c684c 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -1,10 +1,10 @@ { "generic_videos_count_0": "{{count}} video", - "generic_subscribers_count_0": "{{count}} subscribers", + "generic_subscribers_count_0": "{{count}} người theo dõi", "LIVE": "TRỰC TIẾP", "Shared `x` ago": "Đã chia sẻ` x` trước", - "Unsubscribe": "Hủy đăng ký", - "Subscribe": "Đăng ký", + "Unsubscribe": "Hủy theo dõi", + "Subscribe": "Theo dõi", "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", @@ -16,22 +16,21 @@ "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 Invidious data": "Nhập dữ liệu Invidious JSON", + "Import YouTube subscriptions": "Nhập dữ liệu thuê bao YouTube/OPML", "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", + "Export data as JSON": "Xuất dữ liệu Invidious 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", @@ -39,7 +38,6 @@ "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):", @@ -48,36 +46,35 @@ "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", - "preferences_category_player": "Tùy chọn người chơi", + "preferences_category_player": "Tùy chọn trình phát video", "preferences_video_loop_label": "Luôn lặp lại: ", "preferences_autoplay_label": "Tự chạy: ", - "preferences_continue_label": "Phát tiếp theo theo mặc định: ", + "preferences_continue_label": "Phát kế tiếp theo mặc định: ", "preferences_continue_autoplay_label": "Tự động phát video tiếp theo: ", "preferences_listen_label": "Nghe theo mặc định: ", "preferences_local_label": "Video proxy: ", "preferences_speed_label": "Tốc độ mặc định: ", "preferences_quality_label": "Chất lượng video ưa thích: ", - "preferences_volume_label": "Khối lượng trình phát: ", + "preferences_volume_label": "Âm lượng trình phát video: ", "preferences_comments_label": "Nhận xét mặc định: ", "youtube": "YouTube", - "reddit": "reddit", + "reddit": "Reddit", "preferences_captions_label": "Phụ đề mặc định: ", "Fallback captions: ": "Phụ đề dự phòng: ", "preferences_related_videos_label": "Hiển thị các video có liên quan: ", "preferences_annotations_label": "Hiển thị chú thích theo mặc định: ", "preferences_extend_desc_label": "Tự động mở rộng mô tả video: ", - "preferences_vr_mode_label": "Video 360 độ tương tác: ", + "preferences_vr_mode_label": "Video 360 độ tương tác (yêu cầu WebGL): ", "preferences_category_visual": "Tùy chọn hình ảnh", - "preferences_player_style_label": "Phong cách người chơi: ", + "preferences_player_style_label": "Phong cách trình phát: ", "Dark mode: ": "Chế độ tối: ", "preferences_dark_mode_label": "Chủ đề: ", "dark": "tối", "light": "ánh sáng", "preferences_thin_mode_label": "Chế độ mỏng: ", "preferences_category_misc": "Tùy chọn khác", - "preferences_automatic_instance_redirect_label": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Tự động chuyển hướng phiên bản (dự phòng về redirect.invidious.io): ", "preferences_category_subscription": "Tùy chọn đăng ký", "preferences_annotations_subscribed_label": "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: ", @@ -117,14 +114,14 @@ "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", - "search": "Tìm kiếm", + "search": "tìm kiếm", "Log out": "Đăng xuất", "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ư", + "Unlisted": "Không hiển thị", "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", @@ -152,17 +149,12 @@ "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", @@ -345,6 +337,51 @@ "generic_playlists_count": "{{count}} danh sách phát", "generic_views_count": "{{count}} lượt xem", "View `x` comments": { - "": "Xem `x` bình luận" - } + "": "Xem `x` bình luận", + "([^.,0-9]|^)1([^.,0-9]|$)": "Hiển thị `x`bình luận" + }, + "Song: ": "Ca khúc: ", + "Premieres in `x`": "Trình chiếu lần đầu vào `x`", + "preferences_quality_dash_option_worst": "Thấp nhất", + "preferences_watch_history_label": "Bật lịch sử video đã xem ", + "preferences_quality_option_hd720": "HD720", + "unsubscribe": "hủy đăng kí", + "revoke": "gỡ bỏ", + "preferences_quality_dash_label": "Chất lượng video DASH ưa thích ", + "preferences_quality_dash_option_auto": "Tự động", + "Subscriptions": "Thuê bao", + "View YouTube comments": "Hiển thị bình luận trên YouTube", + "View more comments on Reddit": "Hiển thị thêm bình luận từ Reddit", + "Music in this video": "Nhạc trong video này", + "Artist: ": "Nghệ sĩ: ", + "Premieres `x`": "Phát lần đầu `x`", + "preferences_region_label": "Nội dung theo quốc gia ", + "search_message_change_filters_or_query": "Thử mở rộng nội dung tìm kiếm hoặc thay đổi bộ lọc.", + "preferences_quality_option_small": "Nhỏ", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "preferences_quality_dash_option_240p": "240p", + "Import/export": "Xuất/nhập dữ liệu", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_option_dash": "DASH (tự tối ưu chất lượng)", + "generic_subscriptions_count_0": "{{count}} thuê bao", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_2160p": "2160p", + "search_message_no_results": "Tìm kiếm không có kết quả.", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_option_medium": "Trung bình", + "Load more": "Hiển thị thêm", + "comments_points_count_0": "{{count}} điểm", + "Import YouTube playlist (.csv)": "Nhập danh sách phát YouTube (.csv)", + "preferences_quality_dash_option_best": "Tốt nhất", + "preferences_quality_dash_option_360p": "360p", + "subscriptions_unseen_notifs_count_0": "{{count}} thông báo chưa đọc", + "Released under the AGPLv3 on Github.": "Phát hành dưới giấy phép AGPLv3 trên GitHub.", + "search_message_use_another_instance": " Bạn cũng có thể tìm kiếm ở một phiên bản khác.", + "Standard YouTube license": "Giấy phép YouTube thông thường", + "Album: ": "Album: ", + "preferences_save_player_pos_label": "Lưu vị trí xem cuối cùng ", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Xin chào! Có vẻ như bạn đã tắt JavaScript. Bấm vào đây để xem bình luận, lưu ý rằng thời gian tải có thể lâu hơn." } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f202cf88..58b834fa 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -19,7 +19,6 @@ "Clear watch history?": "清除观看历史?", "New password": "新密码", "New passwords must match": "新密码必须匹配", - "Cannot change password for Google accounts": "无法为 Google 账户更改密码", "Authorize token?": "授权令牌?", "Authorize token for `x`?": "`x` 的授权令牌?", "Yes": "是", @@ -42,7 +41,6 @@ "source": "source", "Log in": "登录", "Log in/register": "登录/注册", - "Log in with Google": "使用 Google 账户登录", "User ID": "用户 ID", "Password": "密码", "Time (h:mm:ss):": "时间 (h:mm:ss):", @@ -51,7 +49,6 @@ "Sign In": "登录", "Register": "注册", "E-mail": "E-mail", - "Google verification code": "Google 验证代码", "Preferences": "偏好设置", "preferences_category_player": "播放器偏好设置", "preferences_video_loop_label": "始终循环: ", @@ -171,17 +168,12 @@ "Hide replies": "隐藏回复", "Show replies": "显示回复", "Incorrect password": "密码错误", - "Quota exceeded, try again in a few hours": "已超出限额,请于几小时后重试", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "无法登录。请确认你的短信或验证器的二步验证已打开。", - "Invalid TFA code": "无效的二步验证码", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "登录失败。可能是因为二步验证未打开。", "Wrong answer": "错误的回复", "Erroneous CAPTCHA": "验证码错误", "CAPTCHA is a required field": "验证码必填", "User ID is a required field": "用户名必填", "Password is a required field": "密码必填", "Wrong username or password": "用户名或密码错误", - "Please sign in using 'Log in with Google'": "请通过谷歌账户登录", "Password cannot be empty": "密码不能为空", "Password cannot be longer than 55 characters": "密码长度不能大于 55", "Please log in": "请登录", @@ -465,5 +457,8 @@ "channel_tab_shorts_label": "短视频", "channel_tab_channels_label": "频道", "Song: ": "歌曲: ", - "Channel Sponsor": "频道赞助者" + "Channel Sponsor": "频道赞助者", + "Standard YouTube license": "标准 YouTube 许可证", + "Download is disabled": "已禁用下载", + "Import YouTube playlist (.csv)": "导入 YouTube 播放列表(.csv)" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 54090d3d..7da2d762 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -19,7 +19,6 @@ "Clear watch history?": "清除觀看歷史?", "New password": "新密碼", "New passwords must match": "新密碼必須符合", - "Cannot change password for Google accounts": "無法變更 Google 帳號的密碼", "Authorize token?": "授權 token?", "Authorize token for `x`?": "`x` 的授權 token?", "Yes": "是", @@ -42,7 +41,6 @@ "source": "來源", "Log in": "登入", "Log in/register": "登入/註冊", - "Log in with Google": "使用 Google 登入", "User ID": "使用者 ID", "Password": "密碼", "Time (h:mm:ss):": "時間 (h:mm:ss):", @@ -51,7 +49,6 @@ "Sign In": "登入", "Register": "註冊", "E-mail": "電子郵件", - "Google verification code": "Google 驗證碼", "Preferences": "偏好設定", "preferences_category_player": "播放器偏好設定", "preferences_video_loop_label": "總是循環播放: ", @@ -171,17 +168,12 @@ "Hide replies": "隱藏回覆", "Show replies": "顯示回覆", "Incorrect password": "不正確的密碼", - "Quota exceeded, try again in a few hours": "超過限額,請在幾個小時後再試一次", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "無法登入,請確定雙因素驗證(驗證器或簡訊)已開啟。", - "Invalid TFA code": "無效的 TFA 代碼", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "登入失敗。這可能是因為您的帳號未開啟雙因素驗證的關係。", "Wrong answer": "錯誤的答案", "Erroneous CAPTCHA": "錯誤的 CAPTCHA", "CAPTCHA is a required field": "CAPTCHA 為必填欄位", "User ID is a required field": "使用者 ID 為必填欄位", "Password is a required field": "密碼為必填欄位", "Wrong username or password": "錯誤的使用者名稱或密碼", - "Please sign in using 'Log in with Google'": "請使用「以 Google 登入」來登入", "Password cannot be empty": "密碼不能為空", "Password cannot be longer than 55 characters": "密碼不能長於55個字元", "Please log in": "請登入", @@ -465,5 +457,8 @@ "Album: ": "專輯: ", "Music in this video": "此影片中的音樂", "Channel Sponsor": "頻道贊助者", - "Song: ": "歌曲: " + "Song: ": "歌曲: ", + "Standard YouTube license": "標準 YouTube 授權條款", + "Download is disabled": "已停用下載", + "Import YouTube playlist (.csv)": "匯入 YouTube 播放清單 (.csv)" } diff --git a/mocks b/mocks index cb16e034..11ec372f 160000 --- a/mocks +++ b/mocks @@ -1 +1 @@ -Subproject commit cb16e0343c8f94182615610bfe3c503db89717a7 +Subproject commit 11ec372f72747c09d48ffef04843f72be67d5b54 diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index f81cd29a..142e1653 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -23,18 +23,6 @@ Spectator.describe "Helper" do end end - describe "#produce_comment_continuation" do - it "correctly produces a continuation token for comments" do - expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - expect(produce_comment_continuation("29-q7YnyUmY", "")).to eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D") - - expect(produce_comment_continuation("CvFH_6DNRCY", "")).to eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D") - end - end - describe "#produce_channel_community_continuation" do it "correctly produces a continuation token for a channel community" do expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") diff --git a/spec/invidious/utils_spec.cr b/spec/invidious/utils_spec.cr new file mode 100644 index 00000000..7c2c2711 --- /dev/null +++ b/spec/invidious/utils_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" + +Spectator.describe "Utils" do + describe "decode_date" do + it "parses short dates (en-US)" do + expect(decode_date("1s ago")).to be_close(Time.utc - 1.second, 500.milliseconds) + expect(decode_date("2min ago")).to be_close(Time.utc - 2.minutes, 500.milliseconds) + expect(decode_date("3h ago")).to be_close(Time.utc - 3.hours, 500.milliseconds) + expect(decode_date("4d ago")).to be_close(Time.utc - 4.days, 500.milliseconds) + expect(decode_date("5w ago")).to be_close(Time.utc - 5.weeks, 500.milliseconds) + expect(decode_date("6mo ago")).to be_close(Time.utc - 6.months, 500.milliseconds) + expect(decode_date("7y ago")).to be_close(Time.utc - 7.years, 500.milliseconds) + end + + it "parses short dates (en-GB)" do + expect(decode_date("55s ago")).to be_close(Time.utc - 55.seconds, 500.milliseconds) + expect(decode_date("44min ago")).to be_close(Time.utc - 44.minutes, 500.milliseconds) + expect(decode_date("22hr ago")).to be_close(Time.utc - 22.hours, 500.milliseconds) + expect(decode_date("1day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) + expect(decode_date("2days ago")).to be_close(Time.utc - 2.days, 500.milliseconds) + expect(decode_date("3wk ago")).to be_close(Time.utc - 3.weeks, 500.milliseconds) + expect(decode_date("11mo ago")).to be_close(Time.utc - 11.months, 500.milliseconds) + expect(decode_date("11yr ago")).to be_close(Time.utc - 11.years, 500.milliseconds) + end + + it "parses long forms (singular)" do + expect(decode_date("1 second ago")).to be_close(Time.utc - 1.second, 500.milliseconds) + expect(decode_date("1 minute ago")).to be_close(Time.utc - 1.minute, 500.milliseconds) + expect(decode_date("1 hour ago")).to be_close(Time.utc - 1.hour, 500.milliseconds) + expect(decode_date("1 day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) + expect(decode_date("1 week ago")).to be_close(Time.utc - 1.week, 500.milliseconds) + expect(decode_date("1 month ago")).to be_close(Time.utc - 1.month, 500.milliseconds) + expect(decode_date("1 year ago")).to be_close(Time.utc - 1.year, 500.milliseconds) + end + + it "parses long forms (plural)" do + expect(decode_date("5 seconds ago")).to be_close(Time.utc - 5.seconds, 500.milliseconds) + expect(decode_date("17 minutes ago")).to be_close(Time.utc - 17.minutes, 500.milliseconds) + expect(decode_date("23 hours ago")).to be_close(Time.utc - 23.hours, 500.milliseconds) + expect(decode_date("3 days ago")).to be_close(Time.utc - 3.days, 500.milliseconds) + expect(decode_date("2 weeks ago")).to be_close(Time.utc - 2.weeks, 500.milliseconds) + expect(decode_date("9 months ago")).to be_close(Time.utc - 9.months, 500.milliseconds) + expect(decode_date("8 years ago")).to be_close(Time.utc - 8.years, 500.milliseconds) + end + end +end diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index cbe80010..a6a3e60a 100644 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -17,8 +17,8 @@ Spectator.describe "parse_video_info" do # Basic video infos expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island") - expect(info["views"].as_i).to eq(115_784_415) - expect(info["likes"].as_i).to eq(4_932_790) + expect(info["views"].as_i).to eq(126_573_823) + expect(info["likes"].as_i).to eq(5_157_654) # For some reason the video length from VideoDetails and the # one from microformat differs by 1s... @@ -48,12 +48,12 @@ Spectator.describe "parse_video_info" do expect(info["relatedVideos"].as_a.size).to eq(20) - expect(info["relatedVideos"][0]["id"]).to eq("iogcY_4xGjo") - expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $1,000,000 Hotel Room!") + expect(info["relatedVideos"][0]["id"]).to eq("Hwybp38GnZw") + expect(info["relatedVideos"][0]["title"]).to eq("I Built Willy Wonka's Chocolate Factory!") expect(info["relatedVideos"][0]["author"]).to eq("MrBeast") expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") - expect(info["relatedVideos"][0]["view_count"]).to eq("172972109") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("172M") + expect(info["relatedVideos"][0]["view_count"]).to eq("179877630") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("179M") expect(info["relatedVideos"][0]["author_verified"]).to eq("true") # Description @@ -76,11 +76,11 @@ Spectator.describe "parse_video_info" do expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/ytc/AL5GRJUfhQdJS6n-YJtsAf-ouS2myDavDOq_zXBfebal3Q=s48-c-k-c0x00ffffff-no-rj" + "https://yt3.ggpht.com/ytc/AL5GRJVuqw82ERvHzsmBxL7avr1dpBtsVIXcEzBPZaloFg=s48-c-k-c0x00ffffff-no-rj" ) expect(info["authorVerified"].as_bool).to be_true - expect(info["subCountText"].as_s).to eq("135M") + expect(info["subCountText"].as_s).to eq("143M") end it "parses a regular video with no descrition/comments" do @@ -99,7 +99,7 @@ Spectator.describe "parse_video_info" do # Basic video infos expect(info["title"].as_s).to eq("Chris Rea - Auberge") - expect(info["views"].as_i).to eq(10_698_554) + expect(info["views"].as_i).to eq(10_943_126) expect(info["likes"].as_i).to eq(0) expect(info["lengthSeconds"].as_i).to eq(283_i64) expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") @@ -132,21 +132,21 @@ Spectator.describe "parse_video_info" do # Related videos - expect(info["relatedVideos"].as_a.size).to eq(18) + expect(info["relatedVideos"].as_a.size).to eq(19) - expect(info["relatedVideos"][0]["id"]).to eq("rfyZrJUmzxU") - expect(info["relatedVideos"][0]["title"]).to eq("cheb mami - bekatni") - expect(info["relatedVideos"][0]["author"]).to eq("pelitovic") - expect(info["relatedVideos"][0]["ucid"]).to eq("UCsp6vFyJeGoLxgn-AsHp1tw") - expect(info["relatedVideos"][0]["view_count"]).to eq("13863619") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("13M") + expect(info["relatedVideos"][0]["id"]).to eq("Ww3KeZ2_Yv4") + expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea") + expect(info["relatedVideos"][0]["author"]).to eq("PanMusic") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCsKAPSuh1iNbLWUga_igPyA") + expect(info["relatedVideos"][0]["view_count"]).to eq("31581") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("31K") expect(info["relatedVideos"][0]["author_verified"]).to eq("false") # Description expect(info["description"].as_s).to eq(" ") expect(info["shortDescription"].as_s).to be_empty - expect(info["descriptionHtml"].as_s).to eq("

") + expect(info["descriptionHtml"].as_s).to eq("") # Video metadata diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr index 9dd22b97..25e08c51 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -86,9 +86,10 @@ Spectator.describe "parse_video_info" do expect(info["description"].as_s).to start_with(description_start_text) expect(info["shortDescription"].as_s).to start_with(description_start_text) - expect(info["descriptionHtml"].as_s).to start_with( - "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - aura.com/pbd" - ) + # TODO: Update mocks right before the start of PDB podcast, either on friday or saturday (time unknown) + # expect(info["descriptionHtml"].as_s).to start_with( + # "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - aura.com/pbd" + # ) # Video metadata diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr index bf05f9ec..6589acad 100644 --- a/spec/parsers_helper.cr +++ b/spec/parsers_helper.cr @@ -13,7 +13,7 @@ require "../src/invidious/helpers/utils" require "../src/invidious/videos" require "../src/invidious/videos/*" -require "../src/invidious/comments" +require "../src/invidious/comments/content" require "../src/invidious/helpers/serialized_yt_data" require "../src/invidious/yt_backend/extractors" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index f8bfa718..b3060acf 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -7,7 +7,6 @@ require "../src/invidious/helpers/*" require "../src/invidious/channels/*" require "../src/invidious/videos/caption" require "../src/invidious/videos" -require "../src/invidious/comments" require "../src/invidious/playlists" require "../src/invidious/search/ctoken" require "../src/invidious/trending" diff --git a/src/invidious.cr b/src/invidious.cr index d4f8e0fb..84e1895d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -43,6 +43,7 @@ require "./invidious/videos/*" require "./invidious/jsonify/**" require "./invidious/*" +require "./invidious/comments/*" require "./invidious/channels/*" require "./invidious/user/*" require "./invidious/search/*" @@ -57,11 +58,10 @@ end alias IV = Invidious CONFIG = Config.load -HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +HMAC_KEY = CONFIG.hmac_key PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") -LOGIN_URL = URI.parse("https://accounts.google.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") YT_URL = URI.parse("https://www.youtube.com") diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 63dd2194..c3d6124f 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -159,12 +159,18 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + 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) + rss = XML.parse(rss) - author = rss.xpath_node(%q(//feed/title)) + author = rss.xpath_node("//default:feed/default:title", namespaces) if !author raise InfoException.new("Deleted or invalid channel") end @@ -192,15 +198,23 @@ def fetch_channel(ucid, pull_all_videos : Bool) videos, continuation = IV::Channel::Tabs.get_videos(channel) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//feed/entry").each do |entry| - 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 + rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content + + published = Time.parse_rfc3339( + entry.xpath_node("default:published", namespaces).not_nil!.content + ) + updated = Time.parse_rfc3339( + entry.xpath_node("default:updated", namespaces).not_nil!.content + ) + + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + + views = entry + .xpath_node("media:group/media:community/media:statistics", namespaces) + .try &.["views"]?.try &.to_i64? || 0_i64 channel_video = videos .select(SearchVideo) diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index ce34ff82..aac4bc8a 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -31,18 +31,16 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) 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 = YoutubeAPI.browse(continuation) - body = body["response"]["continuationContents"]["itemSectionContinuation"]? || - body["response"]["continuationContents"]["backstageCommentsContinuation"]? + body = body.dig?("continuationContents", "itemSectionContinuation") || + body.dig?("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"]? @@ -125,49 +123,13 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) 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 - Invidious::JSONify::APIv1.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_count(locale, "generic_views_count", view_count, NumberFormatting::Short) - end - when .has_key?("backstageImageRenderer") + case attachment.as_h + when .has_key?("videoRenderer") + parse_item(attachment) + .as(SearchVideo) + .to_json(locale, json) + when .has_key?("backstageImageRenderer") + json.object do attachment = attachment["backstageImageRenderer"] json.field "type", "image" @@ -188,7 +150,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - when .has_key?("pollRenderer") + end + when .has_key?("pollRenderer") + json.object do attachment = attachment["pollRenderer"] json.field "type", "poll" json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) @@ -221,7 +185,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - when .has_key?("postMultiImageRenderer") + end + when .has_key?("postMultiImageRenderer") + json.object do attachment = attachment["postMultiImageRenderer"] json.field "type", "multiImage" json.field "images" do @@ -245,7 +211,13 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - else + end + when .has_key?("playlistRenderer") + parse_item(attachment) + .as(SearchPlaylist) + .to_json(locale, json) + else + json.object do json.field "type", "unknown" json.field "error", "Unrecognized attachment type." end @@ -270,17 +242,15 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", extract_channel_community_cursor(continuation) + if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + json.field "continuation", extract_channel_community_cursor(cont.as_s) end end end if format == "html" response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) + content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode) response = JSON.build do |json| json.object do diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 12ed4a7d..beb86e08 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -20,7 +20,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so case sort_by when "newest" then 1_i64 when "popular" then 2_i64 - when "oldest" then 3_i64 # Broken as of 10/2022 :c + when "oldest" then 4_i64 else 1_i64 # Fallback to "newest" end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr deleted file mode 100644 index b15d63d4..00000000 --- a/src/invidious/comments.cr +++ /dev/null @@ -1,762 +0,0 @@ -class RedditThing - include JSON::Serializable - - property kind : String - property data : RedditComment | RedditLink | RedditMore | RedditListing -end - -class RedditComment - include JSON::Serializable - - property author : String - property body_html : String - property replies : RedditThing | String - property score : Int32 - property depth : Int32 - property permalink : String - - @[JSON::Field(converter: RedditComment::TimeConverter)] - property created_utc : Time - - module TimeConverter - def self.from_json(value : JSON::PullParser) : Time - Time.unix(value.read_float.to_i) - end - - def self.to_json(value : Time, json : JSON::Builder) - json.number(value.to_unix) - end - end -end - -struct RedditLink - include JSON::Serializable - - property author : String - property score : Int32 - property subreddit : String - property num_comments : Int32 - property id : String - property permalink : String - property title : String -end - -struct RedditMore - include JSON::Serializable - - property children : Array(String) - property count : Int32 - property depth : Int32 -end - -class RedditListing - include JSON::Serializable - - property children : Array(RedditThing) - property modhash : String -end - -def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top") - case cursor - when nil, "" - ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) - when .starts_with? "ADSJ" - ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) - else - ctoken = cursor - end - - client_config = YoutubeAPI::ClientConfig.new(region: region) - response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) - contents = nil - - if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? - header = nil - on_response_received_endpoints.as_a.each do |item| - if item["reloadContinuationItemsCommand"]? - case item["reloadContinuationItemsCommand"]["slot"] - when "RELOAD_CONTINUATION_SLOT_HEADER" - header = item["reloadContinuationItemsCommand"]["continuationItems"][0] - when "RELOAD_CONTINUATION_SLOT_BODY" - # continuationItems is nil when video has no comments - contents = item["reloadContinuationItemsCommand"]["continuationItems"]? - end - elsif item["appendContinuationItemsAction"]? - contents = item["appendContinuationItemsAction"]["continuationItems"] - end - end - elsif response["continuationContents"]? - response = response["continuationContents"] - if response["commentRepliesContinuation"]? - body = response["commentRepliesContinuation"] - else - body = response["itemSectionContinuation"] - end - contents = body["contents"]? - header = body["header"]? - else - raise NotFoundException.new("Comments not found.") - end - - if !contents - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json - end - end - - continuation_item_renderer = nil - contents.as_a.reject! do |item| - if item["continuationItemRenderer"]? - continuation_item_renderer = item["continuationItemRenderer"] - true - end - end - - response = JSON.build do |json| - json.object do - if header - count_text = header["commentsHeaderRenderer"]["countText"] - comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 0 - json.field "commentCount", comment_count - end - - json.field "videoId", id - - json.field "comments" do - json.array do - contents.as_a.each do |node| - json.object do - if node["commentThreadRenderer"]? - node = node["commentThreadRenderer"] - end - - if node["replies"]? - node_replies = node["replies"]["commentRepliesRenderer"] - end - - if node["comment"]? - node_comment = node["comment"]["commentRenderer"] - else - node_comment = node["commentRenderer"] - end - - content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" - author = node_comment["authorText"]?.try &.["simpleText"]? || "" - - json.field "verified", (node_comment["authorCommentBadge"]? != nil) - - json.field "author", author - json.field "authorThumbnails" do - json.array do - node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| - json.object do - json.field "url", thumbnail["url"] - json.field "width", thumbnail["width"] - json.field "height", thumbnail["height"] - end - end - end - end - - if node_comment["authorEndpoint"]? - json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = node_comment["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 - - json.field "content", html_to_content(content_html) - json.field "contentHtml", content_html - - json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) - json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) - if node_comment["sponsorCommentBadge"]? - # Sponsor icon thumbnails always have one object and there's only ever the url property in it - json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s - end - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] - - json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i - json.field "commentId", node_comment["commentId"] - json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] - - if comment_action_buttons_renderer["creatorHeart"]? - hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] - json.field "creatorHeart" do - json.object do - json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] - json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] - end - end - end - - if node_replies && !response["commentRepliesContinuation"]? - if node_replies["continuations"]? - continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - elsif node_replies["contents"]? - continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s - end - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", node_comment["replyCount"]? || 1 - json.field "continuation", continuation - end - end - end - end - end - end - end - - if continuation_item_renderer - if continuation_item_renderer["continuationEndpoint"]? - continuation_endpoint = continuation_item_renderer["continuationEndpoint"] - elsif continuation_item_renderer["button"]? - continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] - end - if continuation_endpoint - json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s - end - 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 - - if response["commentCount"]? - json.field "commentCount", response["commentCount"] - else - json.field "commentCount", 0 - end - end - end - end - - return response -end - -def fetch_reddit_comments(id, sort_by = "confidence") - client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} - - # TODO: Use something like #479 for a static list of instances to use here - query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) - search_results = client.get("/search.json?#{query}", headers) - - if search_results.status_code == 200 - search_results = RedditThing.from_json(search_results.body) - - # For videos that have more than one thread, choose the one with the highest score - threads = search_results.data.as(RedditListing).children - thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) - result = thread.try do |t| - body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body - Array(RedditThing).from_json(body) - end - result ||= [] of RedditThing - elsif search_results.status_code == 302 - # Previously, if there was only one result then the API would redirect to that result. - # Now, it appears it will still return a listing so this section is likely unnecessary. - - result = client.get(search_results.headers["Location"], headers).body - result = Array(RedditThing).from_json(result) - - thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) - else - raise NotFoundException.new("Comments not found.") - end - - client.close - - comments = result[1]?.try(&.data.as(RedditListing).children) - comments ||= [] of RedditThing - return comments, thread -end - -def template_youtube_comments(comments, locale, thin_mode, is_replies = false) - String.build do |html| - root = comments["comments"].as_a - root.each do |child| - if child["replies"]? - replies_count_text = translate_count(locale, - "comments_view_x_replies", - child["replies"]["replyCount"].as_i64 || 0, - NumberFormatting::Separator - ) - - replies_html = <<-END_HTML -
-
- -
- END_HTML - end - - if !thin_mode - author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" - else - author_thumbnail = "" - end - - author_name = HTML.escape(child["author"].as_s) - sponsor_icon = "" - if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool - author_name += " " - elsif child["verified"]?.try &.as_bool - author_name += " " - end - - if child["isSponsor"]?.try &.as_bool - sponsor_icon = String.build do |str| - str << %() - end - end - html << <<-END_HTML -
-
- -
-
-

- - #{author_name} - - #{sponsor_icon} -

#{child["contentHtml"]}

- END_HTML - - if child["attachment"]? - attachment = child["attachment"] - - case attachment["type"] - when "image" - attachment = attachment["imageThumbnails"][1] - - html << <<-END_HTML -
-
- -
-
- END_HTML - when "video" - html << <<-END_HTML -
-
-
- END_HTML - - if attachment["error"]? - html << <<-END_HTML -

#{attachment["error"]}

- END_HTML - else - html << <<-END_HTML - - END_HTML - end - - html << <<-END_HTML -
-
-
- END_HTML - else nil # Ignore - end - end - - html << <<-END_HTML - #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} - | - END_HTML - - if comments["videoId"]? - html << <<-END_HTML - [YT] - | - END_HTML - elsif comments["authorId"]? - html << <<-END_HTML - [YT] - | - END_HTML - end - - html << <<-END_HTML - #{number_with_separator(child["likeCount"])} - END_HTML - - if child["creatorHeart"]? - if !thin_mode - creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" - else - creator_thumbnail = "" - end - - html << <<-END_HTML - -
- -
-
-
-
-
- END_HTML - end - - html << <<-END_HTML -

- #{replies_html} -
-
- END_HTML - end - - if comments["continuation"]? - html << <<-END_HTML -
- -
- END_HTML - end - end -end - -def template_reddit_comments(root, locale) - String.build do |html| - root.each do |child| - if child.data.is_a?(RedditComment) - child = child.data.as(RedditComment) - body_html = HTML.unescape(child.body_html) - - replies_html = "" - if child.replies.is_a?(RedditThing) - replies = child.replies.as(RedditThing) - replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale) - end - - if child.depth > 0 - html << <<-END_HTML -
-
-
-
- END_HTML - else - html << <<-END_HTML -
-
- END_HTML - end - - html << <<-END_HTML -

- [ − ] - #{child.author} - #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} - #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} - #{translate(locale, "permalink")} -

-
- #{body_html} - #{replies_html} -
-
-
- END_HTML - end - end - end -end - -def replace_links(html) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes(%q(//a)).each do |anchor| - url = URI.parse(anchor["href"]) - - if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") - if url.host.try &.ends_with? "youtu.be" - url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" - else - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? - else - anchor["href"] = url.request_target - end - end - elsif url.to_s == "#" - begin - length_seconds = decode_length_seconds(anchor.content) - rescue ex - length_seconds = decode_time(anchor.content) - end - - if length_seconds > 0 - anchor["href"] = "javascript:void(0)" - anchor["onclick"] = "player.currentTime(#{length_seconds})" - else - anchor["href"] = url.request_target - end - end - end - - html = html.xpath_node(%q(//body)).not_nil! - if node = html.xpath_node(%q(./p)) - html = node - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - -def fill_links(html, scheme, host) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes("//a").each do |match| - url = URI.parse(match["href"]) - # Reddit links don't have host - if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" - url.scheme = scheme - url.host = host - match["href"] = url - end - end - - if host == "www.youtube.com" - html = html.xpath_node(%q(//body/p)).not_nil! - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - -def text_to_parsed_content(text : String) : JSON::Any - nodes = [] of JSON::Any - # For each line convert line to array of nodes - text.split('\n').each do |line| - # In first case line is just a simple node before - # check patterns inside line - # { 'text': line } - currentNodes = [] of JSON::Any - initialNode = {"text" => line} - currentNodes << (JSON.parse(initialNode.to_json)) - - # For each match with url pattern, get last node and preserve - # last node before create new node with url information - # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } - line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| - # Retrieve last node and update node without match - lastNode = currentNodes[currentNodes.size - 1].as_h - splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) - lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) - # Create new node with match and navigation infos - currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} - currentNodes << (JSON.parse(currentNode.to_json)) - # If text remain after match create new simple node with text after match - afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""} - currentNodes << (JSON.parse(afterNode.to_json)) - end - - # After processing of matches inside line - # Add \n at end of last node for preserve carriage return - lastNode = currentNodes[currentNodes.size - 1].as_h - lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) - - # Finally add final nodes to nodes returned - currentNodes.each do |node| - nodes << (node) - end - end - return JSON.parse({"runs" => nodes}.to_json) -end - -def parse_content(content : JSON::Any, video_id : String? = "") : String - content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
") } || "" -end - -def content_to_comment_html(content, video_id : String? = "") - html_array = content.map do |run| - # Sometimes, there is an empty element. - # See: https://github.com/iv-org/invidious/issues/3096 - next if run.as_h.empty? - - text = HTML.escape(run["text"].as_s) - - if run["navigationEndpoint"]? - if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s - url = URI.parse(url) - displayed_url = text - - if url.host == "youtu.be" - url = "/watch?v=#{url.request_target.lstrip('/')}" - elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") - if url.path == "/redirect" - # Sometimes, links can be corrupted (why?) so make sure to fallback - # nicely. See https://github.com/iv-org/invidious/issues/2682 - url = url.query_params["q"]? || "" - displayed_url = url - else - url = url.request_target - displayed_url = "youtube.com#{url}" - end - end - - text = %(#{reduce_uri(displayed_url)}) - elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? - start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i - link_video_id = watch_endpoint["videoId"].as_s - - url = "/watch?v=#{link_video_id}" - url += "&t=#{start_time}" if !start_time.nil? - - # If the current video ID (passed through from the caller function) - # is the same as the video ID in the link, add HTML attributes for - # the JS handler function that bypasses page reload. - # - # See: https://github.com/iv-org/invidious/issues/3063 - if link_video_id == video_id - start_time ||= 0 - text = %(#{reduce_uri(text)}) - else - text = %(#{text}) - end - elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s - if text.starts_with?(/\s?[@#]/) - # Handle "pings" in comments and hasthags differently - # See: - # - https://github.com/iv-org/invidious/issues/3038 - # - https://github.com/iv-org/invidious/issues/3062 - text = %(#{text}) - else - text = %(#{reduce_uri(url)}) - end - end - end - - text = "#{text}" if run["bold"]? - text = "#{text}" if run["strikethrough"]? - text = "#{text}" if run["italics"]? - - # check for custom emojis - if run["emoji"]? - if run["emoji"]["isCustomEmoji"]?.try &.as_bool - if emojiImage = run.dig?("emoji", "image") - emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text - emojiThumb = emojiImage["thumbnails"][0] - text = String.build do |str| - str << %() << emojiAlt << ) - end - else - # Hide deleted channel emoji - text = "" - end - end - end - - text - end - - return html_array.join("").delete('\ufeff') -end - -def produce_comment_continuation(video_id, cursor = "", sort_by = "top") - object = { - "2:embedded" => { - "2:string" => video_id, - "25:varint" => 0_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - "40:embedded" => { - "1:varint" => 4_i64, - "3:string" => "https://www.youtube.com", - "4:string" => "", - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "1:string" => cursor, - "4:embedded" => { - "4:string" => video_id, - "6:varint" => 0_i64, - }, - "5:varint" => 20_i64, - }, - } - - case sort_by - when "top" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - when "new", "newest" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 - else # top - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - end - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr new file mode 100644 index 00000000..c8cdc2df --- /dev/null +++ b/src/invidious/comments/content.cr @@ -0,0 +1,89 @@ +def text_to_parsed_content(text : String) : JSON::Any + nodes = [] of JSON::Any + # For each line convert line to array of nodes + text.split('\n').each do |line| + # In first case line is just a simple node before + # check patterns inside line + # { 'text': line } + currentNodes = [] of JSON::Any + initialNode = {"text" => line} + currentNodes << (JSON.parse(initialNode.to_json)) + + # For each match with url pattern, get last node and preserve + # last node before create new node with url information + # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } + line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| + # Retrieve last node and update node without match + lastNode = currentNodes[currentNodes.size - 1].as_h + splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) + lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + # Create new node with match and navigation infos + currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} + currentNodes << (JSON.parse(currentNode.to_json)) + # If text remain after match create new simple node with text after match + afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} + currentNodes << (JSON.parse(afterNode.to_json)) + end + + # After processing of matches inside line + # Add \n at end of last node for preserve carriage return + lastNode = currentNodes[currentNodes.size - 1].as_h + lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + + # Finally add final nodes to nodes returned + currentNodes.each do |node| + nodes << (node) + end + end + return JSON.parse({"runs" => nodes}.to_json) +end + +def parse_content(content : JSON::Any, video_id : String? = "") : String + content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
") } || "" +end + +def content_to_comment_html(content, video_id : String? = "") + html_array = content.map do |run| + # Sometimes, there is an empty element. + # See: https://github.com/iv-org/invidious/issues/3096 + next if run.as_h.empty? + + text = HTML.escape(run["text"].as_s) + + if navigationEndpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigationEndpoint, text, video_id) + end + + text = "#{text}" if run["bold"]? + text = "#{text}" if run["strikethrough"]? + text = "#{text}" if run["italics"]? + + # check for custom emojis + if run["emoji"]? + if run["emoji"]["isCustomEmoji"]?.try &.as_bool + if emojiImage = run.dig?("emoji", "image") + emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text + emojiThumb = emojiImage["thumbnails"][0] + text = String.build do |str| + str << %() << emojiAlt << ) + end + else + # Hide deleted channel emoji + text = "" + end + end + end + + text + end + + return html_array.join("").delete('\ufeff') +end diff --git a/src/invidious/comments/links_util.cr b/src/invidious/comments/links_util.cr new file mode 100644 index 00000000..f89b86d3 --- /dev/null +++ b/src/invidious/comments/links_util.cr @@ -0,0 +1,76 @@ +module Invidious::Comments + extend self + + def replace_links(html) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes(%q(//a)).each do |anchor| + url = URI.parse(anchor["href"]) + + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" + else + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end + end + elsif url.to_s == "#" + begin + length_seconds = decode_length_seconds(anchor.content) + rescue ex + length_seconds = decode_time(anchor.content) + end + + if length_seconds > 0 + anchor["href"] = "javascript:void(0)" + anchor["onclick"] = "player.currentTime(#{length_seconds})" + else + anchor["href"] = url.request_target + end + end + end + + html = html.xpath_node(%q(//body)).not_nil! + if node = html.xpath_node(%q(./p)) + html = node + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end + + def fill_links(html, scheme, host) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes("//a").each do |match| + url = URI.parse(match["href"]) + # Reddit links don't have host + if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" + url.scheme = scheme + url.host = host + match["href"] = url + end + end + + if host == "www.youtube.com" + html = html.xpath_node(%q(//body/p)).not_nil! + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end +end diff --git a/src/invidious/comments/reddit.cr b/src/invidious/comments/reddit.cr new file mode 100644 index 00000000..ba9c19f1 --- /dev/null +++ b/src/invidious/comments/reddit.cr @@ -0,0 +1,41 @@ +module Invidious::Comments + extend self + + def fetch_reddit(id, sort_by = "confidence") + client = make_client(REDDIT_URL) + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} + + # TODO: Use something like #479 for a static list of instances to use here + query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) + search_results = client.get("/search.json?#{query}", headers) + + if search_results.status_code == 200 + search_results = RedditThing.from_json(search_results.body) + + # For videos that have more than one thread, choose the one with the highest score + threads = search_results.data.as(RedditListing).children + thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) + result = thread.try do |t| + body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body + Array(RedditThing).from_json(body) + end + result ||= [] of RedditThing + elsif search_results.status_code == 302 + # Previously, if there was only one result then the API would redirect to that result. + # Now, it appears it will still return a listing so this section is likely unnecessary. + + result = client.get(search_results.headers["Location"], headers).body + result = Array(RedditThing).from_json(result) + + thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) + else + raise NotFoundException.new("Comments not found.") + end + + client.close + + comments = result[1]?.try(&.data.as(RedditListing).children) + comments ||= [] of RedditThing + return comments, thread + end +end diff --git a/src/invidious/comments/reddit_types.cr b/src/invidious/comments/reddit_types.cr new file mode 100644 index 00000000..796a1183 --- /dev/null +++ b/src/invidious/comments/reddit_types.cr @@ -0,0 +1,57 @@ +class RedditThing + include JSON::Serializable + + property kind : String + property data : RedditComment | RedditLink | RedditMore | RedditListing +end + +class RedditComment + include JSON::Serializable + + property author : String + property body_html : String + property replies : RedditThing | String + property score : Int32 + property depth : Int32 + property permalink : String + + @[JSON::Field(converter: RedditComment::TimeConverter)] + property created_utc : Time + + module TimeConverter + def self.from_json(value : JSON::PullParser) : Time + Time.unix(value.read_float.to_i) + end + + def self.to_json(value : Time, json : JSON::Builder) + json.number(value.to_unix) + end + end +end + +struct RedditLink + include JSON::Serializable + + property author : String + property score : Int32 + property subreddit : String + property num_comments : Int32 + property id : String + property permalink : String + property title : String +end + +struct RedditMore + include JSON::Serializable + + property children : Array(String) + property count : Int32 + property depth : Int32 +end + +class RedditListing + include JSON::Serializable + + property children : Array(RedditThing) + property modhash : String +end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr new file mode 100644 index 00000000..1ba1b534 --- /dev/null +++ b/src/invidious/comments/youtube.cr @@ -0,0 +1,250 @@ +module Invidious::Comments + extend self + + def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top") + case cursor + when nil, "" + ctoken = Comments.produce_continuation(id, cursor: "", sort_by: sort_by) + when .starts_with? "ADSJ" + ctoken = Comments.produce_continuation(id, cursor: cursor, sort_by: sort_by) + else + ctoken = cursor + end + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + contents = nil + + if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? + header = nil + on_response_received_endpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + # continuationItems is nil when video has no comments + contents = item["reloadContinuationItemsCommand"]["continuationItems"]? + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end + end + elsif response["continuationContents"]? + response = response["continuationContents"] + if response["commentRepliesContinuation"]? + body = response["commentRepliesContinuation"] + else + body = response["itemSectionContinuation"] + end + contents = body["contents"]? + header = body["header"]? + else + raise NotFoundException.new("Comments not found.") + end + + if !contents + if format == "json" + return {"comments" => [] of String}.to_json + else + return {"contentHtml" => "", "commentCount" => 0}.to_json + end + end + + continuation_item_renderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuation_item_renderer = item["continuationItemRenderer"] + true + end + end + + response = JSON.build do |json| + json.object do + if header + count_text = header["commentsHeaderRenderer"]["countText"] + comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i? || 0 + json.field "commentCount", comment_count + end + + json.field "videoId", id + + json.field "comments" do + json.array do + contents.as_a.each do |node| + json.object do + if node["commentThreadRenderer"]? + node = node["commentThreadRenderer"] + end + + if node["replies"]? + node_replies = node["replies"]["commentRepliesRenderer"] + end + + if node["comment"]? + node_comment = node["comment"]["commentRenderer"] + else + node_comment = node["commentRenderer"] + end + + content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" + author = node_comment["authorText"]?.try &.["simpleText"]? || "" + + json.field "verified", (node_comment["authorCommentBadge"]? != nil) + + json.field "author", author + json.field "authorThumbnails" do + json.array do + node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + + if node_comment["authorEndpoint"]? + json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = node_comment["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 + + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) + json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) + if node_comment["sponsorCommentBadge"]? + # Sponsor icon thumbnails always have one object and there's only ever the url property in it + json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s + end + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] + + json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i + json.field "commentId", node_comment["commentId"] + json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] + + if comment_action_buttons_renderer["creatorHeart"]? + hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] + json.field "creatorHeart" do + json.object do + json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] + json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] + end + end + end + + if node_replies && !response["commentRepliesContinuation"]? + if node_replies["continuations"]? + continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + elsif node_replies["contents"]? + continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s + end + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", node_comment["replyCount"]? || 1 + json.field "continuation", continuation + end + end + end + end + end + end + end + + if continuation_item_renderer + if continuation_item_renderer["continuationEndpoint"]? + continuation_endpoint = continuation_item_renderer["continuationEndpoint"] + elsif continuation_item_renderer["button"]? + continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] + end + if continuation_endpoint + json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s + end + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = Frontend::Comments.template_youtube(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + + if response["commentCount"]? + json.field "commentCount", response["commentCount"] + else + json.field "commentCount", 0 + end + end + end + end + + return response + end + + def produce_continuation(video_id, cursor = "", sort_by = "top") + object = { + "2:embedded" => { + "2:string" => video_id, + "25:varint" => 0_i64, + "28:varint" => 1_i64, + "36:embedded" => { + "5:varint" => -1_i64, + "8:varint" => 0_i64, + }, + "40:embedded" => { + "1:varint" => 4_i64, + "3:string" => "https://www.youtube.com", + "4:string" => "", + }, + }, + "3:varint" => 6_i64, + "6:embedded" => { + "1:string" => cursor, + "4:embedded" => { + "4:string" => video_id, + "6:varint" => 0_i64, + }, + "5:varint" => 20_i64, + }, + } + + case sort_by + when "top" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + when "new", "newest" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 + else # top + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + end + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end +end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 772bc5a3..534b7ea5 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -86,7 +86,7 @@ class Config property https_only : Bool? property login_only : Bool? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property hmac_key : String? + property hmac_key : String = "" # Domain to be used for links to resources on the site where an absolute URL is required property domain : String? # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) @@ -205,6 +205,16 @@ class Config end {% end %} + # HMAC_key is mandatory + # See: https://github.com/iv-org/invidious/issues/3854 + if config.hmac_key.empty? + puts "Config: 'hmac_key' is required/can't be empty" + exit(1) + elsif config.hmac_key == "CHANGE_ME!!" + puts "Config: The value of 'hmac_key' needs to be changed!!" + exit(1) + end + # Build database_url from db.* if it's not set directly if config.database_url.to_s.empty? if db = config.db @@ -217,7 +227,7 @@ class Config path: db.dbname, ) else - puts "Config : Either database_url or db.* is required" + puts "Config: Either database_url or db.* is required" exit(1) end end diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 0a4a4fd8..d54e6a76 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -52,7 +52,7 @@ module Invidious::Database::Users def mark_watched(user : User, vid : String) request = <<-SQL UPDATE users - SET watched = array_append(watched, $1) + SET watched = array_append(array_remove(watched, $1), $1) WHERE email = $2 SQL diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr new file mode 100644 index 00000000..b5647bae --- /dev/null +++ b/src/invidious/frontend/comments_reddit.cr @@ -0,0 +1,50 @@ +module Invidious::Frontend::Comments + extend self + + def template_reddit(root, locale) + String.build do |html| + root.each do |child| + if child.data.is_a?(RedditComment) + child = child.data.as(RedditComment) + body_html = HTML.unescape(child.body_html) + + replies_html = "" + if child.replies.is_a?(RedditThing) + replies = child.replies.as(RedditThing) + replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale) + end + + if child.depth > 0 + html << <<-END_HTML +
+
+
+
+ END_HTML + else + html << <<-END_HTML +
+
+ END_HTML + end + + html << <<-END_HTML +

+ [ − ] + #{child.author} + #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} + #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} + #{translate(locale, "permalink")} +

+
+ #{body_html} + #{replies_html} +
+
+
+ END_HTML + end + end + end + end +end diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr new file mode 100644 index 00000000..41f43f04 --- /dev/null +++ b/src/invidious/frontend/comments_youtube.cr @@ -0,0 +1,160 @@ +module Invidious::Frontend::Comments + extend self + + def template_youtube(comments, locale, thin_mode, is_replies = false) + String.build do |html| + root = comments["comments"].as_a + root.each do |child| + if child["replies"]? + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replies"]["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML +
+
+ +
+ END_HTML + end + + if !thin_mode + author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" + else + author_thumbnail = "" + end + + author_name = HTML.escape(child["author"].as_s) + sponsor_icon = "" + if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool + author_name += " " + elsif child["verified"]?.try &.as_bool + author_name += " " + end + + if child["isSponsor"]?.try &.as_bool + sponsor_icon = String.build do |str| + str << %() + end + end + html << <<-END_HTML +
+
+ +
+
+

+ + #{author_name} + + #{sponsor_icon} +

#{child["contentHtml"]}

+ END_HTML + + if child["attachment"]? + attachment = child["attachment"] + + case attachment["type"] + when "image" + attachment = attachment["imageThumbnails"][1] + + html << <<-END_HTML +
+
+ +
+
+ END_HTML + when "video" + if attachment["error"]? + html << <<-END_HTML +
+

#{attachment["error"]}

+
+ END_HTML + else + html << <<-END_HTML +
+ +
+ END_HTML + end + else nil # Ignore + end + end + + html << <<-END_HTML +

+ #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} + | + END_HTML + + if comments["videoId"]? + html << <<-END_HTML + [YT] + | + END_HTML + elsif comments["authorId"]? + html << <<-END_HTML + [YT] + | + END_HTML + end + + html << <<-END_HTML + #{number_with_separator(child["likeCount"])} + END_HTML + + if child["creatorHeart"]? + if !thin_mode + creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" + else + creator_thumbnail = "" + end + + html << <<-END_HTML +   + + + + + + + + + END_HTML + end + + html << <<-END_HTML +

+ #{replies_html} +
+
+ END_HTML + end + + if comments["continuation"]? + html << <<-END_HTML + + END_HTML + end + end + end +end diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr index bc329205..d9d584c9 100644 --- a/src/invidious/hashtag.cr +++ b/src/invidious/hashtag.cr @@ -17,21 +17,18 @@ module Invidious::Hashtag "80226972:embedded" => { "2:string" => "FEhashtag", "3:base64" => { - "1:varint" => cursor.to_i64, - }, - "7:base64" => { - "325477796:embedded" => { - "1:embedded" => { - "2:0:embedded" => { - "2:string" => '#' + hashtag, - "4:varint" => 0_i64, - "11:string" => "", - }, - "4:string" => "browse-feedFEhashtag", - }, - "2:string" => hashtag, + "1:varint" => 60_i64, # result count + "15:base64" => { + "1:varint" => cursor.to_i64, + "2:varint" => 0_i64, + }, + "93:2:embedded" => { + "1:string" => hashtag, + "2:varint" => 0_i64, + "3:varint" => 1_i64, }, }, + "35:string" => "browse-feedFEhashtag", }, } diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index c3b53339..23ff0da9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,31 +22,6 @@ struct Annotation property annotations : String end -def login_req(f_req) - data = { - # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard - # Generally this is much longer (>1250 characters), see also - # https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb . - # For now this can be empty. - "bgRequest" => %|["identifier",""]|, - "pstMsg" => "1", - "checkConnection" => "youtube", - "checkedDomains" => "youtube", - "hl" => "en", - "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|, - "f.req" => f_req, - "flowName" => "GlifWebSignIn", - "flowEntry" => "ServiceLogin", - # "cookiesDisabled" => "false", - # "gmscoreversion" => "undefined", - # "continue" => "https://accounts.google.com/ManageAccount", - # "azt" => "", - # "bgHash" => "", - } - - return HTTP::Params.encode(data) -end - def html_to_content(description_html : String) description = description_html.gsub(/(
)|()/, { "
": "\n", diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index c1874780..7c12ad0e 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -84,6 +84,7 @@ struct SearchVideo json.field "descriptionHtml", self.description_html json.field "viewCount", self.views + json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short) json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 500a2582..a006d602 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -111,24 +111,27 @@ def decode_date(string : String) else nil # Continue end - # String matches format "20 hours ago", "4 months ago"... - date = string.split(" ")[-3, 3] - delta = date[0].to_i + # String matches format "20 hours ago", "4 months ago", "20s ago", "15min ago"... + match = string.match(/(?\d+) ?(?[smhdwy]\w*) ago/) - case date[1] - when .includes? "second" + raise "Could not parse #{string}" if match.nil? + + delta = match["count"].to_i + + case match["span"] + when .starts_with? "s" # second(s) delta = delta.seconds - when .includes? "minute" + when .starts_with? "mi" # minute(s) delta = delta.minutes - when .includes? "hour" + when .starts_with? "h" # hour(s) delta = delta.hours - when .includes? "day" + when .starts_with? "d" # day(s) delta = delta.days - when .includes? "week" + when .starts_with? "w" # week(s) delta = delta.weeks - when .includes? "month" + when .starts_with? "mo" # month(s) delta = delta.months - when .includes? "year" + when .starts_with? "y" # year(s) delta = delta.years else raise "Could not parse #{string}" @@ -389,3 +392,56 @@ def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = " end return str end + +# Get the html link from a NavigationEndpoint or an innertubeCommand +def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) + if url = endpoint.dig?("urlEndpoint", "url").try &.as_s + url = URI.parse(url) + displayed_url = text + + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") + if url.path == "/redirect" + # Sometimes, links can be corrupted (why?) so make sure to fallback + # nicely. See https://github.com/iv-org/invidious/issues/2682 + url = url.query_params["q"]? || "" + displayed_url = url + else + url = url.request_target + displayed_url = "youtube.com#{url}" + end + end + + text = %(#{reduce_uri(displayed_url)}) + elsif watch_endpoint = endpoint.dig?("watchEndpoint") + start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i + link_video_id = watch_endpoint["videoId"].as_s + + url = "/watch?v=#{link_video_id}" + url += "&t=#{start_time}" if !start_time.nil? + + # If the current video ID (passed through from the caller function) + # is the same as the video ID in the link, add HTML attributes for + # the JS handler function that bypasses page reload. + # + # See: https://github.com/iv-org/invidious/issues/3063 + if link_video_id == video_id + start_time ||= 0 + text = %(#{reduce_uri(text)}) + else + text = %(#{text}) + end + elsif url = endpoint.dig?("commandMetadata", "webCommandMetadata", "url").try &.as_s + if text.starts_with?(/\s?[@#]/) + # Handle "pings" in comments and hasthags differently + # See: + # - https://github.com/iv-org/invidious/issues/3038 + # - https://github.com/iv-org/invidious/issues/3062 + text = %(#{text}) + else + text = %(#{reduce_uri(text)}) + end + end + return text +end diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index 524a3624..b6b673f7 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -2,7 +2,7 @@ module Invidious::Jobs JOBS = [] of BaseJob # Automatically generate a structure that wraps the various - # jobs' configs, so that the follwing YAML config can be used: + # jobs' configs, so that the following YAML config can be used: # # jobs: # job_name: diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 3f342b92..823ca85b 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -97,7 +97,7 @@ def template_mix(mix)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 57f1f53e..013be268 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -507,7 +507,7 @@ def template_playlist(playlist)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 5aa4452c..9d930841 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -42,11 +42,6 @@ module Invidious::Routes::Account sid = sid.as(String) token = env.params.body["csrf_token"]? - # We don't store passwords for Google accounts - if !user.password - return error_template(400, "Cannot change password for Google accounts") - end - begin validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex @@ -54,7 +49,7 @@ module Invidious::Routes::Account end password = env.params.body["password"]? - if !password + if password.nil? || password.empty? return error_template(401, "Password is a required field") end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index ce2ee812..a35d2f2b 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -178,10 +178,6 @@ module Invidious::Routes::API::V1::Authenticated Invidious::Database::Users.subscribe_channel(user, ucid) end - # For Google accounts, access tokens don't have enough information to - # make a request on the user's behalf, which is why we don't sync with - # YouTube. - env.response.status_code = 204 end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 21451d33..9fb283c2 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -55,4 +55,32 @@ module Invidious::Routes::API::V1::Search return error_json(500, ex) end end + + def self.hashtag(env) + hashtag = env.params.url["hashtag"] + + page = env.params.query["page"]?.try &.to_i? || 1 + + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + env.response.content_type = "application/json" + + begin + results = Invidious::Hashtag.fetch(hashtag, page, region) + rescue ex + return error_json(400, ex) + end + + JSON.build do |json| + json.object do + json.field "results" do + json.array do + results.each do |item| + item.to_json(locale, json) + end + end + end + end + end + end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index f312211e..af4fc806 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -333,7 +333,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "top" begin - comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) rescue ex : NotFoundException return error_json(404, ex) rescue ex @@ -345,7 +345,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "confidence" begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + comments, reddit_thread = Comments.fetch_reddit(id, sort_by: sort_by) rescue ex comments = nil reddit_thread = nil @@ -361,9 +361,9 @@ module Invidious::Routes::API::V1::Videos return reddit_thread.to_json else - content_html = template_reddit_comments(comments, locale) - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) + content_html = Frontend::Comments.template_reddit(comments, locale) + content_html = Comments.fill_links(content_html, "https", "www.reddit.com") + content_html = Comments.replace_links(content_html) response = { "title" => reddit_thread.title, "permalink" => reddit_thread.permalink, diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 64430d83..ea1af277 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -80,49 +80,23 @@ module Invidious::Routes::BeforeAll raise "Cannot use token as SID" end - # Invidious users only have SID - if !env.request.cookies.has_key? "SSID" - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) + if email = Database::SessionIDs.select_email(sid) + user = Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) - preferences = user.preferences - env.set "preferences", preferences + preferences = user.preferences + env.set "preferences", preferences - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - else - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - begin - user, sid = get_user(sid, headers, false) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - rescue ex - end + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user end end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index d3969d29..16621994 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -278,6 +278,7 @@ module Invidious::Routes::Channels return error_template(500, ex) end + env.set "search", "channel:#{ucid} " return {locale, user, subscriptions, continuation, ucid, channel} end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fb482e33..fc62c5a3 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -83,10 +83,6 @@ module Invidious::Routes::Feeds headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - if !user.password - user, sid = get_user(sid, headers) - end - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) max_results ||= user.preferences.max_results max_results ||= CONFIG.default_user_preferences.max_results diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 6454131a..d0f7ac22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -24,9 +24,6 @@ module Invidious::Routes::Login captcha_type = env.params.query["captcha"]? captcha_type ||= "image" - tfa = env.params.query["tfa"]? - prompt = nil - templated "user/login" end @@ -47,283 +44,18 @@ module Invidious::Routes::Login account_type ||= "invidious" case account_type - when "google" - tfa_code = env.params.body["tfa"]?.try &.lchop("G-") - traceback = IO::Memory.new - - # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 - begin - client = nil # Declare variable - {% unless flag?(:disable_quic) %} - client = CONFIG.use_quic ? QUIC::Client.new(LOGIN_URL) : HTTP::Client.new(LOGIN_URL) - {% else %} - client = HTTP::Client.new(LOGIN_URL) - {% end %} - - headers = HTTP::Headers.new - - login_page = client.get("/ServiceLogin") - headers = login_page.cookies.add_request_headers(headers) - - lookup_req = { - email, nil, [] of String, nil, "US", nil, nil, 2, false, true, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - email, - }.to_json - - traceback << "Getting lookup..." - - headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" - headers["Google-Accounts-XSRF"] = "1" - - response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) - lookup_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.
    " - - user_hash = lookup_results[0][2] - - if token = env.params.body["token"]? - answer = env.params.body["answer"]? - captcha = {token, answer} - else - captcha = nil - end - - challenge_req = { - user_hash, nil, 1, nil, - {1, nil, nil, nil, - {password, captcha, true}, - }, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - }.to_json - - traceback << "Getting challenge..." - - response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.
    " - - headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) - - if challenge_results[0][3]?.try &.== 7 - return error_template(423, "Account has temporarily been disabled") - end - - if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s - account_type = "google" - captcha_type = "image" - prompt = nil - tfa = tfa_code - captcha = {tokens: [token], question: ""} - - return templated "user/login" - end - - if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" - return error_template(401, "Incorrect password") - end - - prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? - if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type - traceback << "Handling prompt #{prompt_type}.
    " - case prompt_type - when "TWO_STEP_VERIFICATION" - prompt_type = 2 - else # "LOGIN_CHALLENGE" - prompt_type = 4 - end - - # Prefer Authenticator app and SMS over unsupported protocols - if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 - tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] - - traceback << "Selecting challenge #{tfa[8]}..." - select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json - - tl = challenge_results[1][2] - - tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body - tfa = tfa[5..-1] - tfa = JSON.parse(tfa)[0][-1] - - traceback << "done.
    " - else - traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.
    " - tfa = challenge_results[0][-1][0][0] - end - - if tfa[5] == "QUOTA_EXCEEDED" - return error_template(423, "Quota exceeded, try again in a few hours") - end - - if !tfa_code - account_type = "google" - captcha_type = "image" - - case tfa[8] - when 6, 9 - prompt = "Google verification code" - when 12 - prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - when 15 - prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - else - prompt = "Google verification code" - end - - tfa = nil - captcha = nil - return templated "user/login" - end - - tl = challenge_results[1][2] - - request_type = tfa[8] - case request_type - when 6 # Authenticator app - tfa_req = { - user_hash, nil, 2, nil, - {6, nil, nil, nil, nil, - {tfa_code, false}, - }, - }.to_json - when 9 # Voice or text message - tfa_req = { - user_hash, nil, 2, nil, - {9, nil, nil, nil, nil, nil, nil, nil, - {nil, tfa_code, false, 2}, - }, - }.to_json - when 12 # Recovery email - tfa_req = { - user_hash, nil, 4, nil, - {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - when 15 # Security question - tfa_req = { - user_hash, nil, 5, nil, - {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - else - return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") - end - - traceback << "Submitting challenge..." - - response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || - (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") - return error_template(401, "Invalid TFA code") - end - - traceback << "done.
    " - end - - traceback << "Logging in..." - - location = URI.parse(challenge_results[0][-1][2].to_s) - cookies = HTTP::Cookies.from_client_headers(headers) - - headers.delete("Content-Type") - headers.delete("Google-Accounts-XSRF") - - loop do - if !location || location.path == "/ManageAccount" - break - end - - # Occasionally there will be a second page after login confirming - # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. - - if location.path.starts_with? "/b/0/SmsAuthInterstitial" - traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." - end - - login = client.get(location.request_target, headers) - - headers = login.cookies.add_request_headers(headers) - location = login.headers["Location"]?.try { |u| URI.parse(u) } - end - - cookies = HTTP::Cookies.from_client_headers(headers) - sid = cookies["SID"]?.try &.value - if !sid - raise "Couldn't get SID." - end - - user, sid = get_user(sid, headers) - - # We are now logged in - traceback << "done.
    " - - host = URI.parse(env.request.headers["Host"]).host - - cookies.each do |cookie| - cookie.secure = Invidious::User::Cookies::SECURE - - if cookie.extension - cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) - cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") - end - env.response.cookies << cookie - end - - if env.request.cookies["PREFS"]? - user.preferences = env.get("preferences").as(Preferences) - Invidious::Database::Users.update_preferences(user) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer - rescue ex - traceback.rewind - # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") - error_message = %(#{ex.message}
    Traceback:
    #{traceback.gets_to_end}
    ) - return error_template(500, error_message) - end when "invidious" - if !email + if email.nil? || email.empty? return error_template(401, "User ID is a required field") end - if !password + if password.nil? || password.empty? return error_template(401, "Password is a required field") end user = Invidious::Database::Users.select(email: email) if user - if !user.password - return error_template(400, "Please sign in using 'Log in with Google'") - end - if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) @@ -367,8 +99,6 @@ module Invidious::Routes::Login captcha_type ||= "image" account_type = "invidious" - tfa = false - prompt = "" if captcha_type == "image" captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) @@ -481,11 +211,4 @@ module Invidious::Routes::Login env.redirect referer end - - def self.captcha(env) - headers = HTTP::Headers{":authority" => "accounts.google.com"} - response = YT_POOL.client &.get(env.request.resource, headers) - env.response.headers["Content-Type"] = response.headers["Content-Type"] - response.body - end end diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr index 272a3dc7..8922b740 100644 --- a/src/invidious/routes/notifications.cr +++ b/src/invidious/routes/notifications.cr @@ -24,50 +24,6 @@ module Invidious::Routes::Notifications user = user.as(User) - if !user.password - channel_req = {} of String => String - - channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" - channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" - channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" - - channel_req.reject! { |k, v| v != "true" && v != "false" } - - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_client_headers(headers) - html.cookies.each do |cookie| - if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name - if cookies[cookie.name]? - cookies[cookie.name] = cookie - else - cookies << cookie - end - end - end - headers = cookies.add_request_headers(headers) - - if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) - session_token = match["session_token"] - else - return env.redirect referer - end - - headers["content-type"] = "application/x-www-form-urlencoded" - channel_req["session_token"] = session_token - - subs = XML.parse_html(html.body) - subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| - channel_id = channel.content.lstrip("/channel/").not_nil! - channel_req["channel_id"] = channel_id - - YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) - end - end - if redirect env.redirect referer else diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 0d242ee6..1dd3f32e 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -320,10 +320,6 @@ module Invidious::Routes::Playlists end end - if !user.password - # TODO: Playlist stub, sync with YouTube for Google accounts - # playlist_ajax(playlist_id, action, env.request.headers) - end email = user.email case action @@ -410,8 +406,8 @@ module Invidious::Routes::Playlists return error_template(500, ex) end - page_count = (playlist.video_count / 100).to_i - page_count += 1 if (playlist.video_count % 100) > 0 + page_count = (playlist.video_count / 200).to_i + page_count += 1 if (playlist.video_count % 200) > 0 if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -422,7 +418,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100) + videos = get_playlist_videos(playlist, offset: (page - 1) * 200) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 570cba69..abe0f34e 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -310,6 +310,15 @@ module Invidious::Routes::PreferencesRoute response: error_template(415, "Invalid subscription file uploaded") ) end + when "import_youtube_pl" + filename = part.filename || "" + success = Invidious::User::Import.from_youtube_pl(user, body, filename, type) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Invalid playlist file uploaded") + ) + end when "import_freetube" Invidious::User::Import.from_freetube(user, body) when "import_newpipe_subscriptions" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 2a9705cf..6c3088de 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -65,7 +65,11 @@ module Invidious::Routes::Search redirect_url = Invidious::Frontend::Misc.redirect_url(env) - env.set "search", query.text + if query.type == Invidious::Search::Query::Type::Channel + env.set "search", "channel:#{query.channel} #{query.text}" + else + env.set "search", query.text + end templated "search" end end diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 0704c05e..7f9ec592 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -43,11 +43,6 @@ module Invidious::Routes::Subscriptions channel_id = env.params.query["c"]? channel_id ||= "" - if !user.password - # Sync subscriptions with YouTube - subscribe_ajax(channel_id, action, env.request.headers) - end - case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id @@ -82,14 +77,6 @@ module Invidious::Routes::Subscriptions user = user.as(User) sid = sid.as(String) - if !user.password - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - user, sid = get_user(sid, headers) - end - action_takeout = env.params.query["action_takeout"]?.try &.to_i? action_takeout ||= 0 action_takeout = action_takeout == 1 diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 5d3845c3..e5cf3716 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -76,7 +76,7 @@ module Invidious::Routes::Watch end env.params.query.delete_all("iv_load_policy") - if watched && preferences.watch_history && !watched.includes? id + if watched && preferences.watch_history Invidious::Database::Users.mark_watched(user.as(User), id) end @@ -95,31 +95,31 @@ module Invidious::Routes::Watch if source == "youtube" begin - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" - comments, reddit_thread = fetch_reddit_comments(id) - comment_html = template_reddit_comments(comments, locale) + comments, reddit_thread = Comments.fetch_reddit(id) + comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) end end elsif source == "reddit" begin - comments, reddit_thread = fetch_reddit_comments(id) - comment_html = template_reddit_comments(comments, locale) + comments, reddit_thread = Comments.fetch_reddit(id) + comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end end end else - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end comment_html ||= "" @@ -259,9 +259,7 @@ module Invidious::Routes::Watch case action when "action_mark_watched" - if !user.watched.includes? id - Invidious::Database::Users.mark_watched(user, id) - end + Invidious::Database::Users.mark_watched(user, id) when "action_mark_unwatched" Invidious::Database::Users.mark_unwatched(user, id) else diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9e2ade3d..daaf4d88 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -57,7 +57,6 @@ module Invidious::Routing get "/login", Routes::Login, :login_page post "/login", Routes::Login, :login post "/signout", Routes::Login, :signout - get "/Captcha", Routes::Login, :captcha # User preferences get "/preferences", Routes::PreferencesRoute, :show @@ -243,6 +242,7 @@ module Invidious::Routing # Search get "/api/v1/search", {{namespace}}::Search, :search get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag # Authenticated diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index 7e909590..25edb936 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -10,7 +10,7 @@ module Invidious::Search initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) items, _ = extract_items(initial_data) - return items + return items.reject!(Category) end # Search a youtube channel @@ -32,7 +32,7 @@ module Invidious::Search response_json = YoutubeAPI.browse(continuation) items, _ = extract_items(response_json, "", ucid) - return items + return items.reject!(Category) end # Search inside of user subscriptions diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 24e79609..e38845d9 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -113,7 +113,7 @@ module Invidious::Search case @type when .regular?, .playlist? - items = unnest_items(Processors.regular(self)) + items = Processors.regular(self) # when .channel? items = Processors.channel(self) @@ -136,26 +136,5 @@ module Invidious::Search return params end - - # TODO: clean code - private def unnest_items(all_items) : Array(SearchItem) - items = [] of SearchItem - - # Light processing to flatten search results out of Categories. - # They should ideally be supported in the future. - all_items.each do |i| - if i.is_a? Category - i.contents.each do |nest_i| - if !nest_i.is_a? Video - items << nest_i - end - end - else - items << i - end - end - - return items - end end end diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 134eb437..2d9f8a83 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -17,7 +17,24 @@ def fetch_trending(trending_type, region, locale) client_config = YoutubeAPI::ClientConfig.new(region: region) initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) - trending = extract_videos(initial_data) - return {trending, plid} + items, _ = extract_items(initial_data) + + extracted = [] of SearchItem + + items.each do |itm| + if itm.is_a?(Category) + # Ignore the smaller categories, as they generally contain a sponsored + # channel, which brings a lot of noise on the trending page. + # See: https://github.com/iv-org/invidious/issues/2989 + next if itm.contents.size < 24 + + extracted.concat extract_category(itm) + else + extracted << itm + end + end + + # Deduplicate items before returning results + return extracted.select(SearchVideo).uniq!(&.id), plid end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 20ae0d47..0a2fe1e2 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -6,7 +6,7 @@ struct Invidious::User # Parse a youtube CSV subscription file def parse_subscription_export_csv(csv_content : String) - rows = CSV.new(csv_content, headers: true) + rows = CSV.new(csv_content.strip('\n'), headers: true) subscriptions = Array(String).new # Counter to limit the amount of imports. @@ -30,6 +30,60 @@ struct Invidious::User return subscriptions end + def parse_playlist_export_csv(user : User, raw_input : String) + # Split the input into head and body content + raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true) + + # Create the playlist from the head content + csv_head = CSV.new(raw_head.strip('\n'), headers: true) + csv_head.next + title = csv_head[4] + description = csv_head[5] + visibility = csv_head[6] + + if visibility.compare("Public", case_insensitive: true) == 0 + privacy = PlaylistPrivacy::Public + else + privacy = PlaylistPrivacy::Private + end + + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) + + # Add each video to the playlist from the body content + csv_body = CSV.new(raw_body.strip('\n'), headers: true) + csv_body.each do |row| + video_id = row[0] + if playlist + next if !video_id + next if video_id == "Video Id" + + begin + video = get_video(video_id) + rescue ex + next + end + + playlist_video = PlaylistVideo.new({ + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: playlist.id, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + end + + return playlist + end + # ------------------- # Invidious # ------------------- @@ -48,7 +102,7 @@ struct Invidious::User if data["watch_history"]? user.watched += data["watch_history"].as_a.map(&.as_s) - user.watched.uniq! + user.watched.reverse!.uniq!.reverse! Invidious::Database::Users.update_watch_history(user) end @@ -149,6 +203,21 @@ struct Invidious::User return true end + def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if extension == "csv" || type == "text/csv" + playlist = parse_playlist_export_csv(user, body) + if playlist + return true + else + return false + end + else + return false + end + end + # ------------------- # Freetube # ------------------- diff --git a/src/invidious/users.cr b/src/invidious/users.cr index b763596b..65566d20 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -3,75 +3,6 @@ require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } -def get_user(sid, headers, refresh = true) - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - - if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers) - - Invidious::Database::Users.insert(user, update_on_conflict: true) - Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - else - user, sid = fetch_user(sid, headers) - - Invidious::Database::Users.insert(user, update_on_conflict: true) - Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - - return user, sid -end - -def fetch_user(sid, headers) - feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - feed = XML.parse_html(feed.body) - - channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| - if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] - nil - else - channel["href"].lstrip("/channel/") - end - end - - channels = get_batch_channels(channels) - - email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) - if email - email = email.content.strip - else - email = "" - end - - token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - - user = Invidious::User.new({ - updated: Time.utc, - notifications: [] of String, - subscriptions: channels, - email: email, - preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), - password: nil, - token: token, - watched: [] of String, - feed_needs_update: true, - }) - return user, sid -end - def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) @@ -91,38 +22,6 @@ def create_user(sid, email, password) return user, sid end -def subscribe_ajax(channel_id, action, env_headers) - headers = HTTP::Headers.new - headers["Cookie"] = env_headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_client_headers(headers) - html.cookies.each do |cookie| - if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name - if cookies[cookie.name]? - cookies[cookie.name] = cookie - else - cookies << cookie - end - end - end - headers = cookies.add_request_headers(headers) - - if match = html.body.match(/'XSRF_TOKEN': "(?[^"]+)"/) - session_token = match["session_token"] - - headers["content-type"] = "application/x-www-form-urlencoded" - - post_req = { - session_token: session_token, - } - post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" - - YT_POOL.client &.post(post_url, headers, form: post_req) - end -end - def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0038a97a..f38b33e5 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -394,7 +394,9 @@ def fetch_video(id, region) if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") - else + elsif !reason.as_s.starts_with? "Premieres" + # dont error when it's a premiere. + # we already parsed most of the data and display the premiere date raise InfoException.new(reason.as_s || "") end end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr new file mode 100644 index 00000000..542cb416 --- /dev/null +++ b/src/invidious/videos/description.cr @@ -0,0 +1,64 @@ +require "json" +require "uri" + +private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int + copied = 0 + while copied < count + cp = iter.next + break if cp.is_a?(Iterator::Stop) + + str << cp.chr + + # A codepoint from the SMP counts twice + copied += 1 if cp > 0xFFFF + copied += 1 + end + + return copied +end + +def parse_description(desc, video_id : String) : String? + return "" if desc.nil? + + content = desc["content"].as_s + return "" if content.empty? + + commands = desc["commandRuns"]?.try &.as_a + return content if commands.nil? + + # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints + # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are + # automatically decoded by the JSON parser. It means that we need to count + # copied byte in a special manner, preventing the use of regular string copy. + iter = content.each_codepoint + + index = 0 + + return String.build do |str| + commands.each do |command| + cmd_start = command["startIndex"].as_i + cmd_length = command["length"].as_i + + # Copy the text chunk between this command and the previous if needed. + length = cmd_start - index + index += copy_string(str, iter, length) + + # We need to copy the command's text using the iterator + # and the special function defined above. + cmd_content = String.build(cmd_length) do |str2| + copy_string(str2, iter, cmd_length) + end + + link = cmd_content + if on_tap = command.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, cmd_content, video_id) + end + str << link + index += cmd_length + end + + # Copy the end of the string (past the last command). + remaining_length = content.size - index + copy_string(str, iter, remaining_length) if remaining_length > 0 + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 13ee5f65..9cc0ffdc 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -78,7 +78,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) elsif video_id != player_response.dig("videoDetails", "videoId") # YouTube may return a different video player response than expected. # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") + # Line to be reverted if one day we solve the video not available issue. + return { + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances.
    Click here for more info about the issue."), + } else reason = nil end @@ -284,8 +288,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any description = microformat.dig?("description", "simpleText").try &.as_s || "" short_description = player_response.dig?("videoDetails", "shortDescription") - description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + # description_html = video_secondary_renderer.try &.dig?("description", "runs") + # .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + + description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id) # Video metadata diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 9e11d562..24efc34e 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -27,7 +27,7 @@
  • <% else %>
    - <%= template_youtube_comments(items.not_nil!, locale, thin_mode) %> + <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
    <% end %> diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index f216359f..59888760 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -1,6 +1,6 @@ <% if channel.banner %>
    - "> + " alt="" />
    @@ -11,7 +11,7 @@
    - + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index fa12374f..7cfd38db 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -7,7 +7,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + " alt="" />
    <% end %>

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    @@ -25,7 +25,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + " alt="" />

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -38,7 +38,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -58,7 +58,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if plid_form = env.get?("remove_playlist_items") %>
    " method="post"> @@ -112,7 +112,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if env.get? "show_watched" %> " method="post"> "> diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index 1240e5bd..a03785d1 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -1,6 +1,6 @@
    - autofocus<% end %> name="q" placeholder="<%= translate(locale, "search") %>" title="<%= translate(locale, "search") %>" diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 471d21db..2234b297 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -34,7 +34,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + " method="post"> ">

    diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index e52a7707..2a4b6edd 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -5,12 +5,19 @@ <%= rendered "components/feed_menu" %>

    -
    +

    <%= translate(locale, "user_created_playlists", %(#{items_created.size})) %>

    -
    diff --git a/src/invidious/views/privacy.ecr b/src/invidious/views/privacy.ecr index 643f880b..bc5ff40b 100644 --- a/src/invidious/views/privacy.ecr +++ b/src/invidious/views/privacy.ecr @@ -16,12 +16,11 @@
  • a list of channel UCIDs the user is subscribed to
  • a user ID (for persistent storage of subscriptions and preferences)
  • a json object containing user preferences
  • -
  • a hashed password if applicable (not present on google accounts)
  • +
  • a hashed password
  • a randomly generated token for providing an RSS feed of a user's subscriptions
  • a list of video IDs identifying watched videos
  • Users can clear their watch history using the clear watch history page.

    -

    If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.

    Data you passively provide

    When you request any resource from this website (for example: a page, a font, an image, or an API endpoint) information about the request may be logged.

    diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr index a451159f..27654b40 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -8,7 +8,7 @@ <%= translate(locale, "Import") %>
    - +
    @@ -21,6 +21,11 @@
    +
    + + +
    +
    diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 01d7a210..2b03d280 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -7,42 +7,6 @@
    <% case account_type when %> - <% when "google" %> - -
    - <% if email %> - - <% else %> - - "> - <% end %> - - <% if password %> - - <% else %> - - "> - <% end %> - - <% if prompt %> - - - <% end %> - - <% if tfa %> - - <% end %> - - <% if captcha %> - - - - "> - <% end %> - - -
    - <% else # "invidious" %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index a3ec94e8..5b3190f3 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -208,7 +208,7 @@ we're going to need to do it here in order to allow for translations.
    <% if !video.author_thumbnail.empty? %> - + <% end %> <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -298,7 +298,7 @@ we're going to need to do it here in order to allow for translations.
    &listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %>
    - /mqdefault.jpg"> + /mqdefault.jpg" alt="" />

    <%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %>

    <% end %> diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 46e5bf85..658731cf 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -8,13 +8,15 @@ def add_yt_headers(request) if request.headers["User-Agent"] == "Crystal" - request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" end + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.headers["Accept-Language"] ||= "en-us,en;q=0.5" + # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" if !CONFIG.cookies.empty? request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 9c041361..6686e6e7 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -268,7 +268,7 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - title = item_contents["title"]["simpleText"]?.try &.as_s || "" + title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" video_count = HelperExtractors.get_video_count(item_contents) @@ -381,7 +381,7 @@ private module Parsers # Parses an InnerTube itemSectionRenderer into a SearchVideo. # Returns nil when the given object isn't a ItemSectionRenderer # - # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer, used + # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer or a playlistRenderer, used # by the result page for channel searches. It is located inside a continuationItems # container.It is very similar to RichItemRendererParser # @@ -394,6 +394,8 @@ private module Parsers private def self.parse(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) + return child end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index 0cb3c079..11d95958 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,19 +68,17 @@ rescue ex return false end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo) - extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback) +# This function extracts SearchVideo items from a Category. +# Categories are commonly returned in search results and trending pages. +def extract_category(category : Category) : Array(SearchVideo) + return category.contents.select(SearchVideo) +end - target = [] of (SearchItem | Continuation) - extracted.each do |i| - if i.is_a?(Category) - i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } - else - target << i - end +# :ditto: +def extract_category(category : Category, &) + category.contents.select(SearchVideo).each do |item| + yield item end - - return target.select(SearchVideo) end def extract_selected_tab(tabs) diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 91a9332c..3dd9e9d8 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -7,16 +7,18 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" - private ANDROID_APP_VERSION = "17.33.42" + private ANDROID_APP_VERSION = "18.20.38" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308 - private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip" + private ANDROID_USER_AGENT = "com.google.android.youtube/18.20.38 (Linux; U; Android 12; US) gzip" private ANDROID_SDK_VERSION = 31_i64 private ANDROID_VERSION = "12" - private IOS_APP_VERSION = "17.33.2" + + private IOS_APP_VERSION = "18.21.3" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330 - private IOS_USER_AGENT = "com.google.ios.youtube/17.33.2 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" + private IOS_USER_AGENT = "com.google.ios.youtube/18.21.3 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224 - private IOS_VERSION = "15.6.0.19G71" + private IOS_VERSION = "15.6.0.19G71" + private WINDOWS_VERSION = "10.0" # Enumerate used to select one of the clients supported by the API @@ -43,7 +45,7 @@ module YoutubeAPI ClientType::Web => { name: "WEB", name_proto: "1", - version: "2.20221118.01.00", + version: "2.20230602.01.00", api_key: DEFAULT_API_KEY, screen: "WATCH_FULL_SCREEN", os_name: "Windows", @@ -63,7 +65,7 @@ module YoutubeAPI ClientType::WebMobile => { name: "MWEB", name_proto: "2", - version: "2.20220805.01.00", + version: "2.20230531.05.00", api_key: DEFAULT_API_KEY, os_name: "Android", os_version: ANDROID_VERSION,