diff options
64 files changed, 1160 insertions, 517 deletions
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 @@ -1,4 +1,4 @@ -/doc/ +/docs/ /dev/ /lib/ /bin/ @@ -149,12 +149,13 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab, - [CloudTube](https://sr.ht/~cadence/tube/): A JavaScript-rich alternate YouTube player. - [PeerTubeify](https://gitlab.com/Cha_de_L/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. - [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A material design music player that streams music from YouTube. -- [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favorites. +- [HoloPlay](https://github.com/stephane-r/holoplay-wa): Progressive Web App connecting on Invidious API's with search, playlists and favorites. - [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 ## Liability diff --git a/assets/css/default.css b/assets/css/default.css index 3deaebe1..42f6958f 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -119,13 +119,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 +230,7 @@ div.watched-indicator { border-radius: 0; box-shadow: none; + appearance: none; -webkit-appearance: none; } @@ -365,11 +369,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 +399,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 +433,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 +474,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); } @@ -515,7 +528,10 @@ hr { #descexpansionbutton ~ div { overflow: hidden; - height: 8.3em; +} + +#descexpansionbutton:not(:checked) ~ div { + max-height: 8.3em; } #descexpansionbutton:checked ~ div { @@ -583,3 +599,7 @@ p, /* Wider settings name to less word wrap */ .pure-form-aligned .pure-control-group label { width: 19em; } + +.channel-emoji { + margin: 0 2px; +} 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/themes.js b/assets/js/themes.js index 76767d5f..84a9f6d9 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -22,9 +22,11 @@ function setTheme(theme) { if (theme === THEME_DARK) { toggle_theme.children[0].className = 'icon ion-ios-sunny'; document.body.className = 'dark-theme'; - } else { + } else if (theme === THEME_LIGHT) { toggle_theme.children[0].className = 'icon ion-ios-moon'; document.body.className = 'light-theme'; + } else { + document.body.className = 'no-theme'; } } diff --git a/locales/ar.json b/locales/ar.json index 181ff933..7303915b 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -543,5 +543,9 @@ "channel_tab_channels_label": "القنوات", "Music in this video": "الموسيقى في هذا الفيديو", "Album: ": "الألبوم: ", - "Artist: ": "الفنان: " + "Artist: ": "الفنان: ", + "Song: ": "أغنية: ", + "Channel Sponsor": "راعي القناة", + "Standard YouTube license": "ترخيص YouTube القياسي", + "Download is disabled": "تم تعطيل التحميلات" } diff --git a/locales/ca.json b/locales/ca.json index 2ba6ae39..901249ac 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -66,7 +66,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 +75,7 @@ "Title": "Títol", "Belarusian": "Bielorús", "Enable web notifications": "Activa notificacions web", - "search": "busca", + "search": "Cerca", "Catalan": "Català", "Croatian": "Croat", "preferences_category_admin": "Preferències d'administrador", @@ -99,5 +99,389 @@ "Music": "Música", "search_filters_sort_option_relevance": "Rellevància", "search_filters_date_option_hour": "Última hora", - "search_filters_date_option_today": "Avui" + "search_filters_date_option_today": "Avui", + "preferences_volume_label": "Volum del reproductor: ", + "invidious": "Invidious", + "preferences_quality_dash_option_144p": "144p", + "Turkish (auto-generated)": "Turc (generat automàticament)", + "Urdu": "Urdú", + "Vietnamese (auto-generated)": "Vietnamita (generat automàticament)", + "Welsh": "Gal·lès", + "Yoruba": "Ioruba", + "YouTube comment permalink": "Enllaç permanent de comentari de YouTube", + "Channel Sponsor": "Patrocinador del canal", + "Audio mode": "Mode d'àudio", + "search_filters_date_option_none": "Qualsevol data", + "search_filters_type_option_playlist": "Llista de reproducció", + "search_filters_type_option_movie": "Pel·lícula", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_subtitles": "Subtítols/CC", + "search_filters_features_option_live": "Directe", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_hdr": "HDR", + "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": "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", + "adminprefs_modified_source_code_url_label": "URL al repositori de codi font modificat", + "none": "cap", + "footer_modfied_source_code": "Codi font modificat", + "videoinfo_invidious_embed_link": "Incrusta l'enllaç", + "download_subtitles": "Subtítols - `x` (.vtt)", + "user_created_playlists": "`x`llistes de reproducció creades", + "Video unavailable": "Vídeo no disponible", + "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", + "preferences_automatic_instance_redirect_label": "Redirecció automàtica d'instàncies (retorna a redirect.invidious.io): ", + "Login enabled: ": "Activa inici de sessió: ", + "Registration enabled: ": "Activa registre: ", + "Whitelisted regions: ": "Regions a la llista blanca: ", + "Chinese (Simplified)": "Xinès (Simplificat)", + "Corsican": "Cors", + "Estonian": "Estonià", + "Japanese (auto-generated)": "Japonès (generat automàticament)", + "English (United States)": "Anglès (Estats Units)", + "English (auto-generated)": "Anglès (generat automàticament)", + "Cebuano": "Cebuà", + "Esperanto": "Esperanto", + "Scottish Gaelic": "Gaèlic escocès", + "Playlists": "Llistes de reproducció", + "search_filters_title": "Filtres", + "search_filters_type_option_all": "Qualsevol tipus", + "search_filters_duration_option_none": "Qualsevol duració", + "next_steps_error_message": "Després d'això, hauríeu d'intentar: ", + "next_steps_error_message_refresh": "Recarregar la pàgina", + "crash_page_refresh": "ha intentat <a href=\"`x`\">actualitzar la pàgina</a>", + "crash_page_report_issue": "Si cap de les anteriors no ha ajudat, <a href=\"`x`\">obre un nou issue a GitHub</a> (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ó. <a href=\"`x`\">Feu clic aquí per a la pàgina d'inici de la llista de reproducció.</a>", + "comments_points_count": "{{count}} punt", + "comments_points_count_plural": "{{count}} punts", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Create playlist": "Crear llista de reproducció", + "Text CAPTCHA": "Text CAPTCHA", + "Next page": "Pàgina següent", + "preferences_category_visual": "Preferències visuals", + "preferences_unseen_only_label": "Mostra només no vistos: ", + "preferences_listen_label": "Escolta per defecte: ", + "Import": "Importar", + "Token": "Testimoni", + "Wilson score: ": "Puntuació de Wilson: ", + "search_filters_date_label": "Data de càrrega", + "search_filters_features_option_three_sixty": "360°", + "source": "font", + "preferences_default_home_label": "Pàgina d'inici per defecte: ", + "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 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 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ó", + "search_filters_sort_option_date": "Data de càrrega", + "Unlisted": "No llistat", + "View privacy policy.": "Veure política de privadesa.", + "Public": "Públic", + "View all playlists": "Veure totes les llistes de reproducció", + "reddit": "Reddit", + "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", + "preferences_quality_dash_option_1080p": "1080p", + "Top enabled: ": "Activa top: ", + "Delete playlist `x`?": "Eliminar llista de reproducció `x`?", + "View JavaScript license information.": "Consulta la informació de la llicència de JavaScript.", + "Playlist privacy": "Privacitat de la llista de reproducció", + "search_message_no_results": "No s'han trobat resultats.", + "search_message_use_another_instance": " També es pot <a href=\"`x`\">buscar en una altra instància</a>.", + "Genre: ": "Gènere: ", + "Hidden field \"challenge\" is a required field": "El camp ocult \"repte\" és un camp obligatori", + "Burmese": "Birmà", + "View as playlist": "Mostra com a llista de reproducció", + "preferences_category_subscription": "Preferències de subscripció", + "Music in this video": "Música en aquest vídeo", + "Artist: ": "Artista: ", + "Album: ": "Àlbum: ", + "Shared `x`": "Compartit `x`", + "Premieres `x`": "Estrena `x`", + "View more comments on Reddit": "Veure més comentaris a Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Veure `x` comentari", + "": "Veure `x` comentaris" + }, + "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)", + "Kyrgyz": "Kirguís", + "Latin": "Llatí", + "Malagasy": "Malgaix", + "Maori": "Maori", + "Marathi": "Marathi", + "Norwegian Bokmål": "Bokmål Noruec", + "Nyanja": "Nyanja", + "Portuguese (Brazil)": "Portuguès (Brazil)", + "Punjabi": "Panjabi", + "Russian (auto-generated)": "Rus (generat automàticament)", + "Samoan": "Samoà", + "Somali": "Somali", + "Southern Sotho": "Sesotho", + "Spanish (Mexico)": "Espanyol (Mèxic)", + "Spanish (Spain)": "Espanyol (Espanya)", + "Sundanese": "Sondanès", + "Swahili": "Suahili", + "Tamil": "Tàmil", + "Telugu": "Telugu", + "Zulu": "Zulu", + "generic_count_months": "{{count}} mes", + "generic_count_months_plural": "{{count}} mesos", + "generic_count_weeks": "{{count}} setmana", + "generic_count_weeks_plural": "{{count}} setmanes", + "About": "Sobre", + "`x` marked it with a ❤": "`x`marca'l amb un ❤", + "Video mode": "Mode de vídeo", + "search_filters_features_label": "Característiques", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_purchased": "Comprat", + "Chinese (Hong Kong)": "Xinès (Hong Kong)", + "Chinese (Taiwan)": "Xinès (Taiwan)", + "Hmong": "Hmong", + "Kazakh": "Kazakh", + "Igbo": "Igbo", + "Javanese": "Javanès", + "Indonesian (auto-generated)": "Indonesi (generat automàticament)", + "Interlingue": "Interlingüe", + "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.": "S'ha produït un error en 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.", + "Could not pull trending pages.": "No s'han pogut extreure les pàgines de tendència.", + "comments_view_x_replies": "Veure {{count}} resposta", + "comments_view_x_replies_plural": "Veure {{count}} respostes", + "Subscriptions": "Subscripcions", + "generic_count_seconds": "{{count}} segon", + "generic_count_seconds_plural": "{{count}} segons", + "channel_tab_shorts_label": "Vídeos curts", + "preferences_save_player_pos_label": "Desa la posició de reproducció: ", + "crash_page_before_reporting": "Abans d'informar d'un error, assegureu-vos que teniu:", + "crash_page_switch_instance": "ha intentat <a href=\"`x`\">utilitzar una altra instància</a>", + "crash_page_read_the_faq": "heu llegit les <a href=\"`x`\">Preguntes més freqüents (FAQ)</a>", + "crash_page_search_issue": "ha cercat <a href=\"`x`\">problemes existents a GitHub</a>", + "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": "El testimoni ha caducat, torna-ho a provar", + "English": "Anglès", + "Kannada": "Kanarès", + "Erroneous token": "Testimoni erroni", + "`x` ago": "fa `x`", + "Empty playlist": "Llista de reproducció buida", + "Playlist does not exist.": "La llista de reproducció no existeix.", + "No such user": "No hi ha tal usuari", + "Afrikaans": "Afrikàans", + "Azerbaijani": "Azerbaidjana", + "Cantonese (Hong Kong)": "Cantonès (Hong Kong)", + "Chinese": "Xinès", + "Chinese (China)": "Xinès (Xina)", + "Chinese (Traditional)": "Xinès (Tradicional)", + "Dutch": "Holandès", + "Dutch (auto-generated)": "Holandès (generat automàticament)", + "French (auto-generated)": "Francès (generat automàticament)", + "Georgian": "Georgià", + "German (auto-generated)": "Alemany (generat automàticament)", + "Gujarati": "Gujarati", + "Hawaiian": "Hawaià", + "generic_count_years": "{{count}} any", + "generic_count_years_plural": "{{count}} anys", + "Popular": "Popular", + "Rating: ": "Valoració: ", + "permalink": "enllaç permanent", + "preferences_quality_dash_option_worst": "Pitjor", + "Yiddish": "Ídix", + "preferences_quality_dash_option_auto": "Automàtic", + "Western Frisian": "Frisó occidental", + "Swedish": "Suec", + "Only show latest unwatched video from channel: ": "Mostra només l'últim vídeo no vist del canal: ", + "preferences_continue_label": "Reprodueix el següent per defecte: ", + "Import YouTube subscriptions": "Importar subscripcions de YouTube", + "search_filters_sort_option_rating": "Valoració", + "preferences_thin_mode_label": "Mode prim: ", + "preferences_quality_option_small": "Petit", + "CAPTCHA enabled: ": "activa CAPTCHA: ", + "Import and Export Data": "Importar i exportar dades", + "preferences_quality_dash_option_360p": "360p", + "Popular enabled: ": "Activa popular: ", + "Password": "Contrasenya", + "Blacklisted regions: ": "Regions a la llista negra: ", + "Register": "Registra't", + "Shared `x` ago": "Compartit fa `x`", + "search_filters_sort_option_views": "Recompte de visualitzacions", + "Import Invidious data": "Importa dades JSON d'Invidious", + "preferences_related_videos_label": "Mostra vídeos relacionats: ", + "preferences_show_nick_label": "Mostra l'àlies a la part superior: ", + "Time (h:mm:ss):": "Temps (h:mm:ss):", + "Could not fetch comments": "No s'han pogut obtenir els comentaris", + "New password": "Nova contrasenya", + "preferences_notifications_only_label": "Mostra només notificacions (si n'hi ha): ", + "preferences_annotations_label": "Mostra anotacions per defecte: ", + "Import FreeTube subscriptions (.db)": "Importar subscripcions de FreeTube (.db)", + "Fallback captions: ": "Subtítols alternatius: ", + "Log out": "Tancar sessió", + "preferences_quality_dash_option_2160p": "2160p", + "Unsubscribe": "Cancel·la la subscripció", + "Log in/register": "Inicia sessió/registra't", + "Nepali": "Nepalí", + "Xhosa": "Xosa", + "preferences_captions_label": "Subtítols per defecte: ", + "preferences_autoplay_label": "Reproducció automàtica: ", + "`x` is live": "`x` està en directe", + "Uzbek": "Uzbek", + "Hausa": "Haussa", + "Bosnian": "Bosnià", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hola! Sembla que tens JavaScript desactivat. Feu clic aquí per veure els comentaris, tingueu en compte que poden trigar una mica més a carregar-se.", + "Password cannot be empty": "La contrasenya no pot estar buida", + "preferences_video_loop_label": "Sempre en bucle: ", + "preferences_quality_option_dash": "DASH (qualitat adaptativa)", + "Change password": "Canvia la contrasenya", + "Export data as JSON": "Exporta dades d'Invidious com a JSON", + "Wrong answer": "Resposta incorrecta", + "Clear watch history": "Neteja l'historial de reproduccions", + "Mongolian": "Mongol", + "preferences_quality_dash_option_best": "Millor", + "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", + "Portuguese (auto-generated)": "Portuguès (generat automàticament)", + "footer_source_code": "Codi font", + "videoinfo_youTube_embed_link": "Insereix", + "generic_count_minutes": "{{count}} minut", + "generic_count_minutes_plural": "{{count}} minuts", + "preferences_category_player": "Preferències del reproductor", + "Sign In": "Inicia Sessió", + "preferences_continue_autoplay_label": "Reprodueix automàticament el següent vídeo: ", + "generic_playlists_count": "{{count}} llista de reproducció", + "generic_playlists_count_plural": "{{count}} llistes de reproducció", + "Delete account?": "Esborrar compte?", + "Please log in": "Si us plau inicieu sessió", + "Import NewPipe data (.zip)": "Importar dades de NewPipe (.zip)", + "Image CAPTCHA": "Imatge CAPTCHA", + "channel_tab_streams_label": "Transmissions en directe", + "preferences_category_misc": "Preferències diverses", + "preferences_annotations_subscribed_label": "Mostra les anotacions per defecte dels canals subscrits? ", + "Tajik": "Tadjik", + "preferences_player_style_label": "Estil del reproductor: ", + "Load more": "Carrega més", + "preferences_vr_mode_label": "Vídeos interactius de 360 graus (requereix WebGL): ", + "Manage subscriptions": "Gestionar les subscripcions", + "preferences_quality_option_medium": "Mitjà", + "Editing playlist `x`": "Editant la llista de reproducció `x`", + "search_filters_duration_option_medium": "Mitjà (4 - 20 minuts)", + "E-mail": "Correu electrònic", + "Spanish (auto-generated)": "Castellà (generat automàticament)", + "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 \"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: ", + "Hindi": "Hindi", + "An alternative front-end to YouTube": "Una interfície alternativa a YouTube", + "Export subscriptions as OPML": "Exporta subscripcions com a OPML", + "Watch on YouTube": "Veure a YouTube", + "Lao": "Laosià", + "search_message_change_filters_or_query": "Proveu d'ampliar la vostra consulta de cerca i/o canviar els filtres.", + "View YouTube comments": "Veure comentaris de YouTube", + "New passwords must match": "Les contrasenyes noves han de coincidir", + "Subscription manager": "Gestor de subscripcions", + "Premieres in `x`": "Estrena en `x`", + "youtube": "YouTube", + "Latvian": "Letó", + "LIVE": "EN VIU", + "Could not create mix.": "No s'ha pogut crear la barreja.", + "preferences_speed_label": "Velocitat per defecte: ", + "preferences_extend_desc_label": "Amplieu automàticament la descripció del vídeo: ", + "popular": "popular", + "Erroneous challenge": "Repte erroni", + "last": "darrer", + "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": "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", + "Updated `x` ago": "Actualitzat fa `x`", + "Haitian Creole": "Crioll Haitià", + "preferences_watch_history_label": "Habilita historial de reproduccions: ", + "generic_count_hours": "{{count}} hora", + "generic_count_hours_plural": "{{count}} hores", + "Malayalam": "Maialàiam", + "Clear watch history?": "Neteja historial de reproduccions?", + "Import/export data": "Importa/exporta dades", + "Sinhala": "Singalès", + "Delete playlist": "Eliminar llista de reproducció", + "Bangla": "Bengalí", + "Italian (auto-generated)": "Italià (generat automàticament)", + "License: ": "Llicència: ", + "(edited)": "(editat)", + "Pashto": "Paixtu", + "preferences_dark_mode_label": "Tema: ", + "revoke": "revocar", + "English (United Kingdom)": "Anglès (Regne Unit)", + "preferences_quality_option_hd720": "HD720", + "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", + "generic_subscribers_count_plural": "{{count}} subscriptors", + "Sindhi": "Sindhi", + "Slovenian": "Eslovè", + "preferences_feed_menu_label": "Menú del feed: ", + "Fallback comments: ": "Comentaris alternatius: ", + "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: ", + "Standard YouTube license": "Llicència estàndard de YouTube", + "Download is disabled": "Les baixades s'han inhabilitat" } diff --git a/locales/cs.json b/locales/cs.json index 51db1550..0e8610bf 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -495,5 +495,9 @@ "channel_tab_streams_label": "Živé přenosy", "Music in this video": "Hudba v tomto videu", "Artist: ": "Umělec: ", - "Album: ": "Album: " + "Album: ": "Album: ", + "Channel Sponsor": "Sponzor kanálu", + "Song: ": "Skladba: ", + "Standard YouTube license": "Standardní licence YouTube", + "Download is disabled": "Stahování je zakázáno" } diff --git a/locales/de.json b/locales/de.json index 55c40905..0df86663 100644 --- a/locales/de.json +++ b/locales/de.json @@ -472,5 +472,15 @@ "search_filters_duration_option_none": "Beliebige Länge", "search_filters_date_label": "Upload-Datum", "search_filters_date_option_none": "Beliebiges Datum", - "error_video_not_in_playlist": "Das angeforderte Video existiert nicht in dieser Wiedergabeliste. <a href=\"`x`\">Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen.</a>" + "error_video_not_in_playlist": "Das angeforderte Video existiert nicht in dieser Wiedergabeliste. <a href=\"`x`\">Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen.</a>", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "Music in this video": "Musik in diesem Video", + "Artist: ": "Künstler: ", + "Album: ": "Album: ", + "channel_tab_playlists_label": "Wiedergabelisten", + "channel_tab_channels_label": "Kanäle", + "Channel Sponsor": "Kanalsponsor", + "Standard YouTube license": "Standard YouTube-Lizenz", + "Song: ": "Musik: " } diff --git a/locales/el.json b/locales/el.json index 3448a4dc..8d0c84dd 100644 --- a/locales/el.json +++ b/locales/el.json @@ -366,7 +366,7 @@ "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "Μεσαία", "preferences_quality_option_small": "Μικρό", - "preferences_quality_option_dash": "DASH (προσαρμοστική ποιότητα)", + "preferences_quality_option_dash": "DASH (προσαρμόσιμη ποιότητα)", "preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_720p": "720p", "invidious": "Invidious", @@ -450,5 +450,5 @@ "search_filters_type_option_show": "Μπάρα προόδου διαβάσματος", "preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ", "search_filters_title": "Φίλτρο", - "search_message_no_results": "Δεν" + "search_message_no_results": "Δε βρέθηκαν αποτελέσματα." } diff --git a/locales/en-US.json b/locales/en-US.json index a5c16fd7..05811f27 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -183,6 +183,7 @@ "Show annotations": "Show annotations", "Genre: ": "Genre: ", "License: ": "License: ", + "Standard YouTube license": "Standard YouTube license", "Family friendly? ": "Family friendly? ", "Wilson score: ": "Wilson score: ", "Engagement: ": "Engagement: ", @@ -190,6 +191,7 @@ "Blacklisted regions: ": "Blacklisted regions: ", "Music in this video": "Music in this video", "Artist: ": "Artist: ", + "Song: ": "Song: ", "Album: ": "Album: ", "Shared `x`": "Shared `x`", "Premieres in `x`": "Premieres in `x`", @@ -400,11 +402,13 @@ "Movies": "Movies", "Download": "Download", "Download as: ": "Download as: ", + "Download is disabled": "Download is disabled", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "YouTube comment permalink": "YouTube comment permalink", "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", + "Channel Sponsor": "Channel Sponsor", "Audio mode": "Audio mode", "Video mode": "Video mode", "Playlists": "Playlists", @@ -454,7 +458,7 @@ "footer_documentation": "Documentation", "footer_source_code": "Source code", "footer_original_source_code": "Original source code", - "footer_modfied_source_code": "Modified Source code", + "footer_modfied_source_code": "Modified source code", "adminprefs_modified_source_code_url_label": "URL to modified source code repository", "none": "none", "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", diff --git a/locales/eo.json b/locales/eo.json index 9f37c7cb..464d16ca 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -479,5 +479,9 @@ "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" } diff --git a/locales/es.json b/locales/es.json index 6cf721f3..09f510a7 100644 --- a/locales/es.json +++ b/locales/es.json @@ -52,21 +52,21 @@ "preferences_video_loop_label": "Repetir siempre: ", "preferences_autoplay_label": "Reproducción automática: ", "preferences_continue_label": "Reproducir siguiente por defecto: ", - "preferences_continue_autoplay_label": "Reproducir automáticamente el vídeo siguiente: ", + "preferences_continue_autoplay_label": "Reproducir automáticamente el video siguiente: ", "preferences_listen_label": "Activar el sonido por defecto: ", - "preferences_local_label": "¿Usar un proxy para los vídeos? ", + "preferences_local_label": "¿Usar un proxy para los videos? ", "preferences_speed_label": "Velocidad por defecto: ", - "preferences_quality_label": "Calidad de vídeo preferida: ", + "preferences_quality_label": "Calidad de video preferida: ", "preferences_volume_label": "Volumen del reproductor: ", "preferences_comments_label": "Comentarios por defecto: ", "youtube": "YouTube", "reddit": "Reddit", "preferences_captions_label": "Subtítulos por defecto: ", "Fallback captions: ": "Subtítulos alternativos: ", - "preferences_related_videos_label": "¿Mostrar vídeos relacionados? ", + "preferences_related_videos_label": "¿Mostrar videos relacionados? ", "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ", - "preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ", - "preferences_vr_mode_label": "Vídeos interactivos de 360 grados (necesita WebGL): ", + "preferences_extend_desc_label": "Extender automáticamente la descripción del video: ", + "preferences_vr_mode_label": "Videos interactivos de 360 grados (necesita WebGL): ", "preferences_category_visual": "Preferencias visuales", "preferences_player_style_label": "Estilo de reproductor: ", "Dark mode: ": "Modo oscuro: ", @@ -79,16 +79,16 @@ "preferences_category_subscription": "Preferencias de la suscripción", "preferences_annotations_subscribed_label": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", - "preferences_max_results_label": "Número de vídeos mostrados en la fuente: ", - "preferences_sort_label": "Ordenar los vídeos por: ", + "preferences_max_results_label": "Número de videos mostrados en la fuente: ", + "preferences_sort_label": "Ordenar los videos por: ", "published": "fecha de publicación", "published - reverse": "fecha de publicación: orden inverso", "alphabetically": "alfabéticamente", "alphabetically - reverse": "alfabéticamente: orden inverso", "channel name": "nombre del canal", "channel name - reverse": "nombre del canal: orden inverso", - "Only show latest video from channel: ": "Mostrar solo el último vídeo del canal: ", - "Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ", + "Only show latest video from channel: ": "Mostrar solo el último video del canal: ", + "Only show latest unwatched video from channel: ": "Mostrar solo el último video sin ver del canal: ", "preferences_unseen_only_label": "Mostrar solo los no vistos: ", "preferences_notifications_only_label": "Mostrar solo notificaciones (si hay alguna): ", "Enable web notifications": "Habilitar notificaciones web", @@ -139,7 +139,7 @@ "Editing playlist `x`": "Editando la lista de reproducción 'x'", "Show more": "Mostrar más", "Show less": "Mostrar menos", - "Watch on YouTube": "Ver el vídeo en YouTube", + "Watch on YouTube": "Ver en YouTube", "Switch Invidious Instance": "Cambiar Instancia de Invidious", "Hide annotations": "Ocultar anotaciones", "Show annotations": "Mostrar anotaciones", @@ -153,7 +153,7 @@ "Shared `x`": "Compartido `x`", "Premieres in `x`": "Se estrena en `x`", "Premieres `x`": "Estrenos `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.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tienes JavaScript desactivado. Haz clic aquí para ver los comentarios, pero tengas en cuenta que pueden tardar un poco más en cargarse.", "View YouTube comments": "Ver los comentarios de YouTube", "View more comments on Reddit": "Ver más comentarios en Reddit", "View `x` comments": { @@ -164,7 +164,7 @@ "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, pruebe otra vez en unas horas", + "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.", @@ -176,7 +176,7 @@ "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 puede tener más de 55 caracteres", + "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", "Invidious Private Feed for `x`": "Fuente privada de Invidious para `x`", "channel:`x`": "canal: `x`", @@ -198,7 +198,7 @@ "No such user": "Usuario no existe", "Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo", "English": "Inglés", - "English (auto-generated)": "Inglés (generados automáticamente)", + "English (auto-generated)": "Inglés (generado automáticamente)", "Afrikaans": "Afrikáans", "Albanian": "Albanés", "Amharic": "Amárico", @@ -324,50 +324,51 @@ "permalink": "enlace permanente", "`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "Audio mode": "Modo de audio", - "Video mode": "Modo de vídeo", - "channel_tab_videos_label": "Vídeos", + "Video mode": "Modo de video", + "channel_tab_videos_label": "Videos", "Playlists": "Listas de reproducción", "channel_tab_community_label": "Comunidad", - "search_filters_sort_option_relevance": "relevancia", - "search_filters_sort_option_rating": "valoración", - "search_filters_sort_option_date": "fecha", - "search_filters_sort_option_views": "visualizaciones", - "search_filters_type_label": "content_type", + "search_filters_sort_option_relevance": "Relevancia", + "search_filters_sort_option_rating": "Valoración", + "search_filters_sort_option_date": "Fecha de subida", + "search_filters_sort_option_views": "Visualizaciones", + "search_filters_type_label": "tipo de contenido", "search_filters_duration_label": "duración", "search_filters_features_label": "funcionalidades", "search_filters_sort_label": "ordenar", - "search_filters_date_option_hour": "hora", - "search_filters_date_option_today": "hoy", - "search_filters_date_option_week": "semana", - "search_filters_date_option_month": "mes", - "search_filters_date_option_year": "año", - "search_filters_type_option_video": "vídeo", - "search_filters_type_option_channel": "canal", - "search_filters_type_option_playlist": "lista de reproducción", - "search_filters_type_option_movie": "película", - "search_filters_type_option_show": "programa", - "search_filters_features_option_hd": "hd", - "search_filters_features_option_subtitles": "subtítulos", - "search_filters_features_option_c_commons": "creative_commons", - "search_filters_features_option_three_d": "3d", - "search_filters_features_option_live": "directo", - "search_filters_features_option_four_k": "4k", - "search_filters_features_option_location": "ubicación", - "search_filters_features_option_hdr": "hdr", + "search_filters_date_option_hour": "Última hora", + "search_filters_date_option_today": "Hoy", + "search_filters_date_option_week": "Esta semana", + "search_filters_date_option_month": "Este mes", + "search_filters_date_option_year": "Este año", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Canal", + "search_filters_type_option_playlist": "Lista de reproducción", + "search_filters_type_option_movie": "Película", + "search_filters_type_option_show": "Programa", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtítulos", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "En directo", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Ubicación", + "search_filters_features_option_hdr": "HDR", "Current version: ": "Versión actual: ", "next_steps_error_message": "Después de lo cual debes intentar: ", "next_steps_error_message_refresh": "Recargar la página", "next_steps_error_message_go_to_youtube": "Ir a YouTube", - "search_filters_duration_option_short": "Corto (< 4 minutos)", - "search_filters_duration_option_long": "Largo (> 20 minutos)", + "search_filters_duration_option_short": "Menos de 4 minutos", + "search_filters_duration_option_medium": "De 4 a 20 minutos", + "search_filters_duration_option_long": "Más de 20 minutos", "footer_documentation": "Documentación", "footer_original_source_code": "Código fuente original", - "adminprefs_modified_source_code_url_label": "URL al repositorio de código fuente modificado", + "adminprefs_modified_source_code_url_label": "Enlace al repositorio de código fuente modificado", "footer_source_code": "Código fuente", "footer_modfied_source_code": "Código fuente modificado", "footer_donate_page": "Donar", "preferences_region_label": "País del contenido: ", - "preferences_quality_dash_label": "Calidad de vídeo DASH preferida: ", + "preferences_quality_dash_label": "Calidad de video DASH preferida: ", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "Intermedia", "preferences_quality_dash_option_auto": "Automática", @@ -376,7 +377,7 @@ "download_subtitles": "Subtítulos- `x` (.vtt)", "user_created_playlists": "`x` listas de reproducción creadas", "user_saved_playlists": "`x` listas de reproducción guardadas", - "Video unavailable": "Vídeo no disponible", + "Video unavailable": "Video no disponible", "videoinfo_youTube_embed_link": "Insertar", "preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_4320p": "4320p", @@ -413,8 +414,9 @@ "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": "{{count}} vídeo", - "generic_videos_count_plural": "{{count}} vídeos", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videos", + "generic_videos_count_2": "{{count}} videos", "generic_count_months": "{{count}} mes", "generic_count_months_plural": "{{count}} meses", "comments_points_count": "{{count}} punto", @@ -433,7 +435,7 @@ "crash_page_search_issue": "buscado <a href=\"`x`\">problemas existentes en GitHub</a>", "crash_page_you_found_a_bug": "¡Parece que has encontrado un error en Invidious!", "crash_page_refresh": "probado a <a href=\"`x`\">recargar la página</a>", - "crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye el siguiente texto en tu mensaje (NO traduzcas este texto):", + "crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye verbatim el siguiente texto en tu mensaje:", "English (United States)": "Inglés (Estados Unidos)", "Cantonese (Hong Kong)": "Cantonés (Hong Kong)", "Dutch (auto-generated)": "Neerlandés (generados automáticamente)", @@ -461,23 +463,26 @@ "search_message_no_results": "No se han encontrado resultados.", "search_message_change_filters_or_query": "Pruebe ampliar la consulta de búsqueda y/o a cambiar los filtros.", "search_filters_title": "Filtros", - "search_filters_date_label": "Fecha de subida", + "search_filters_date_label": "fecha de subida", "search_filters_date_option_none": "Cualquier fecha", "search_filters_type_option_all": "Cualquier tipo", "search_filters_duration_option_none": "Cualquier duración", "search_filters_features_option_vr180": "VR180", - "search_filters_apply_button": "Aplicar filtros seleccionados", + "search_filters_apply_button": "Aplicar filtros", "tokens_count": "{{count}} ficha", "tokens_count_plural": "{{count}} fichas", "search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.", - "search_filters_duration_option_medium": "Medio (4 - 20 minutes)", "Popular enabled: ": "¿Habilitar la sección popular? ", - "error_video_not_in_playlist": "El vídeo solicitado no existe en esta lista de reproducción. <a href=\"`x`\">Haga clic aquí para acceder a la página de inicio de la lista de reproducción.</a>", + "error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>", "channel_tab_streams_label": "Directos", "channel_tab_channels_label": "Canales", "channel_tab_shorts_label": "Cortos", "channel_tab_playlists_label": "Listas de reproducción", - "Music in this video": "Música en este vídeo", + "Music in this video": "Música en este video", "Artist: ": "Artista: ", - "Album: ": "Álbum: " + "Album: ": "Álbum: ", + "Song: ": "Canción: ", + "Channel Sponsor": "Patrocinador del canal", + "Standard YouTube license": "Licencia de YouTube estándar", + "Download is disabled": "La descarga está deshabilitada" } diff --git a/locales/fa.json b/locales/fa.json index fe72a1e8..29a0c527 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -26,15 +26,15 @@ "No": "خیر", "Import and Export Data": "درونبرد و برونبرد داده", "Import": "درونبرد", - "Import Invidious data": "درونبرد داده اینویدیوس", - "Import YouTube subscriptions": "درونبرد اشتراکهای یوتیوب", + "Import Invidious data": "وارد کردن داده JSON اینویدیوس", + "Import YouTube subscriptions": "وارد کردن اشتراک OPML/ یوتیوب", "Import FreeTube subscriptions (.db)": "درونبرد اشتراکهای فریتیوب (.db)", "Import NewPipe subscriptions (.json)": "درونبرد اشتراکهای نیوپایپ (.json)", "Import NewPipe data (.zip)": "درونبرد داده نیوپایپ (.zip)", "Export": "برونبرد", "Export subscriptions as OPML": "برونبرد اشتراکها در قالب OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "برونبرد اشتراکها در قالب OPML (برای نیوپایپ و فریتیوب)", - "Export data as JSON": "برونبرد داده در قالب JSON", + "Export data as JSON": "گرفتن(خارج کردن) اطلاعات اینویدیوس با فرمت JSON", "Delete account?": "حذف حساب کاربری؟", "History": "تاریخچه", "An alternative front-end to YouTube": "یک پیشانه جایگزین برای یوتیوب", @@ -71,7 +71,7 @@ "preferences_related_videos_label": "نمایش ویدیو های مرتبط: ", "preferences_annotations_label": "نمایش حاشیه نویسی ها به طور پیشفرض: ", "preferences_extend_desc_label": "گسترش خودکار توضیحات ویدئو: ", - "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی: ", + "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی(نیازمند WebGL): ", "preferences_category_visual": "ترجیحات بصری", "preferences_player_style_label": "حالت پخش کننده: ", "Dark mode: ": "حالت تاریک: ", @@ -80,7 +80,7 @@ "light": "روشن", "preferences_thin_mode_label": "حالت نازک: ", "preferences_category_misc": "ترجیحات متفرقه", - "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (به طور پیشفرض به redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (انتقال به redirect.invidious.io): ", "preferences_category_subscription": "ترجیحات اشتراک", "preferences_annotations_subscribed_label": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", @@ -157,7 +157,7 @@ "Engagement: ": "نامزدی: ", "Whitelisted regions: ": "مناطق لیست سفید: ", "Blacklisted regions: ": "مناطق لیست سیاه: ", - "Shared `x`": "به اشتراک گذاشته شده `x`", + "Shared `x`": "`x` به اشتراک گذاشته شد", "Premieres in `x`": "برای اولین بار در `x`", "Premieres `x`": "برای اولین بار `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "سلام! مثل اینکه تو جاوا اسکریپت رو خاموش کرده ای. اینجا کلیک کن تا نظرات را ببینی، این رو یادت باشه که ممکنه بارگذاری اونها کمی طول بکشه.", @@ -375,7 +375,7 @@ "next_steps_error_message_refresh": "تازهسازی", "next_steps_error_message_go_to_youtube": "رفتن به یوتیوب", "preferences_quality_option_hd720": "HD720", - "preferences_quality_option_dash": "DASH (کیفیت قابل تطبیق)", + "preferences_quality_option_dash": "DASH (کیفیت تطبیفی)", "preferences_quality_option_medium": "میانه", "preferences_quality_option_small": "پایین", "preferences_quality_dash_option_auto": "خودکار", @@ -445,5 +445,13 @@ "Spanish (Spain)": "اسپانیایی (اسپانیا)", "Turkish (auto-generated)": "ترکی (تولید خودکار)", "search_filters_features_option_vr180": "VR180", - "Spanish (Mexico)": "اسپانیایی (مکزیک)" + "Spanish (Mexico)": "اسپانیایی (مکزیک)", + "Popular enabled: ": "محبوب ها فعال شد: ", + "Music in this video": "آهنگ در این ویدیو", + "Artist: ": "هنرمند: ", + "Album: ": "آلبوم: ", + "Song: ": "آهنگ: ", + "Channel Sponsor": "اسپانسر کانال", + "Standard YouTube license": "پروانه استاندارد YouTube", + "search_message_use_another_instance": " شما همچنین میتوانید <a href=\"`x`\">در نمونه دیگر هم جستجو کنید</a>." } diff --git a/locales/fr.json b/locales/fr.json index 9d3e117f..bb40916b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -474,7 +474,14 @@ "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. <a href=\"`x`\">Cliquez ici pour retourner à la liste de lecture.</a>", "channel_tab_shorts_label": "Clips", - "channel_tab_streams_label": "En direct", + "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é" } diff --git a/locales/hi.json b/locales/hi.json index 54e0fe84..41335266 100644 --- a/locales/hi.json +++ b/locales/hi.json @@ -472,5 +472,12 @@ "crash_page_refresh": "<a href=\"`x`\">पृष्ठ को एक बार साफ़ करें</a>", "crash_page_search_issue": "<a href=\"`x`\">GitHub पर मौजूदा मुद्दे</a> ढूँढ़ें", "Popular enabled: ": "लोकप्रिय सक्षम: ", - "Artist: ": "कलाकार: " + "Artist: ": "कलाकार: ", + "Music in this video": "इस वीडियो में संगीत", + "Album: ": "एल्बम: ", + "error_video_not_in_playlist": "अनुरोधित वीडियो इस प्लेलिस्ट में मौजूद नहीं है। <a href=\"`x`\">प्लेलिस्ट के मुखपृष्ठ पर जाने के लिए यहाँ क्लिक करें।</a>", + "channel_tab_shorts_label": "शॉर्ट्स", + "channel_tab_streams_label": "लाइवस्ट्रीम्स", + "channel_tab_playlists_label": "प्लेलिस्ट्स", + "channel_tab_channels_label": "चैनल्स" } diff --git a/locales/hr.json b/locales/hr.json index 72cd6a8e..b87a7729 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -359,13 +359,13 @@ "next_steps_error_message_refresh": "Aktualiziraj stranicu", "next_steps_error_message_go_to_youtube": "Idi na YouTube", "footer_donate_page": "Doniraj", - "adminprefs_modified_source_code_url_label": "URL do repozitorija izmijenjenog izvornog koda", + "adminprefs_modified_source_code_url_label": "URL do repozitorija prilagođenog izvornog koda", "search_filters_duration_option_short": "Kratko (< 4 minute)", "search_filters_duration_option_long": "Dugo (> 20 minute)", "footer_source_code": "Izvorni kod", - "footer_modfied_source_code": "Izmijenjeni izvorni kod", + "footer_modfied_source_code": "Prilagođen izvorni kod", "footer_documentation": "Dokumentacija", - "footer_original_source_code": "Izvoran izvorni kod", + "footer_original_source_code": "Prvobitan izvorni kod", "preferences_region_label": "Zemlja sadržaja: ", "preferences_quality_dash_label": "Preferirana DASH videokvaliteta: ", "preferences_quality_option_dash": "DASH (adaptativna kvaliteta)", @@ -495,5 +495,9 @@ "channel_tab_shorts_label": "Kratka videa", "Music in this video": "Glazba u ovom videu", "Album: ": "Album: ", - "Artist: ": "Izvođač: " + "Artist: ": "Izvođač: ", + "Channel Sponsor": "Sponzor kanala", + "Song: ": "Pjesma: ", + "Standard YouTube license": "Standardna YouTube licenca", + "Download is disabled": "Preuzimanje je deaktivirano" } diff --git a/locales/id.json b/locales/id.json index 51d6d55c..f0adfdb1 100644 --- a/locales/id.json +++ b/locales/id.json @@ -453,5 +453,6 @@ "crash_page_switch_instance": "mencoba untuk <a href=\"`x`\">menggunakan peladen lainnya</a>", "crash_page_read_the_faq": "baca <a href=\"`x`\">Soal Sering Ditanya (SSD/FAQ)</a>", "crash_page_search_issue": "mencari <a href=\"`x`\">isu yang ada di GitHub</a>", - "crash_page_report_issue": "Jika yang di atas tidak membantu, <a href=\"`x`\">buka isu baru di GitHub</a> (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, <a href=\"`x`\">buka isu baru di GitHub</a> (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):", + "Popular enabled: ": "Populer diaktifkan: " } diff --git a/locales/it.json b/locales/it.json index c60f760b..0797b387 100644 --- a/locales/it.json +++ b/locales/it.json @@ -479,5 +479,9 @@ "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" } diff --git a/locales/ja.json b/locales/ja.json index 3ad4b494..d1813bcd 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -5,7 +5,7 @@ "generic_subscribers_count_0": "{{count}} 人の登録者", "generic_subscriptions_count_0": "{{count}} 個の登録チャンネル", "LIVE": "ライブ", - "Shared `x` ago": "`x`前に共有", + "Shared `x` ago": "`x`前に公開", "Unsubscribe": "登録解除", "Subscribe": "登録", "View channel on YouTube": "YouTube でチャンネルを見る", @@ -56,17 +56,17 @@ "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_local_label": "動画視聴にプロキシーを経由: ", + "preferences_speed_label": "標準の再生速度: ", "preferences_quality_label": "優先する画質: ", "preferences_volume_label": "プレイヤーの音量: ", "preferences_comments_label": "デフォルトのコメント: ", "youtube": "YouTube", "reddit": "Reddit", - "preferences_captions_label": "デフォルトの字幕: ", + "preferences_captions_label": "優先する字幕: ", "Fallback captions: ": "フォールバック時の字幕: ", "preferences_related_videos_label": "関連動画を表示: ", "preferences_annotations_label": "デフォルトでアノテーションを表示: ", @@ -108,7 +108,7 @@ "Watch history": "再生履歴", "Delete account": "アカウントを削除", "preferences_category_admin": "管理者設定", - "preferences_default_home_label": "デフォルトのホーム: ", + "preferences_default_home_label": "ホームに表示するページ: ", "preferences_feed_menu_label": "フィードメニュー: ", "preferences_show_nick_label": "ニックネームを一番上に表示する: ", "Top enabled: ": "トップページを有効化: ", @@ -157,7 +157,7 @@ "Engagement: ": "エンゲージメント: ", "Whitelisted regions: ": "ホワイトリストの地域: ", "Blacklisted regions: ": "ブラックリストの地域: ", - "Shared `x`": "`x`に共有", + "Shared `x`": "公開日 `x`", "Premieres in `x`": "`x`後にプレミア公開", "Premieres `x`": "`x`にプレミア公開", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "やあ!君は JavaScript を無効にしているのかな?ここをクリックしてコメントを見れるけど、読み込みには少し時間がかかることがあるのを覚えておいてね。", @@ -191,9 +191,9 @@ "This channel does not exist.": "このチャンネルは存在しません。", "Could not get channel info.": "チャンネル情報を取得できませんでした。", "Could not fetch comments": "コメントを取得できませんでした", - "comments_view_x_replies_0": "{{count}} 件の返信を見る", + "comments_view_x_replies_0": "{{count}}件の返信を表示", "`x` ago": "`x`前", - "Load more": "もっと読み込む", + "Load more": "もっと見る", "comments_points_count_0": "{{count}}点", "Could not create mix.": "ミックスを作成できませんでした。", "Empty playlist": "空の再生リスト", @@ -366,7 +366,7 @@ "search_filters_features_option_subtitles": "字幕", "search_filters_features_option_c_commons": "クリエイティブ・コモンズ", "search_filters_features_option_three_d": "3D", - "search_filters_features_option_live": "生配信", + "search_filters_features_option_live": "ライブ", "search_filters_features_option_four_k": "4K", "search_filters_features_option_location": "場所", "search_filters_features_option_hdr": "HDR", @@ -377,8 +377,8 @@ "search_filters_duration_option_short": "4 分未満", "footer_documentation": "文書", "footer_source_code": "ソースコード", - "footer_original_source_code": "ソースコード (元)", - "footer_modfied_source_code": "ソースコード (改変)", + "footer_original_source_code": "元のソースコード", + "footer_modfied_source_code": "改変して使用", "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL", "search_filters_duration_option_long": "20 分以上", "preferences_region_label": "地域: ", @@ -445,7 +445,7 @@ "search_message_use_another_instance": " <a href=\"`x`\">別のインスタンス上でも検索</a>できます。", "search_filters_apply_button": "選択したフィルターを適用", "user_saved_playlists": "`x` 個の保存した再生リスト", - "crash_page_you_found_a_bug": "Invidious でバグを見つけたようです。", + "crash_page_you_found_a_bug": "Invidious のバグのようです!", "crash_page_refresh": "<a href=\"`x`\">ページを更新</a>を試す", "preferences_watch_history_label": "再生履歴を有効化 ", "search_filters_date_option_none": "すべて", @@ -463,5 +463,8 @@ "channel_tab_channels_label": "チャンネル", "Music in this video": "この動画の音楽", "Artist: ": "アーティスト: ", - "Album: ": "アルバム: " + "Album: ": "アルバム: ", + "Song: ": "曲: ", + "Channel Sponsor": "チャンネルのスポンサー", + "Standard YouTube license": "標準 Youtube ライセンス" } diff --git a/locales/lt.json b/locales/lt.json index 9bfcfdba..91c7febe 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -488,5 +488,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 <a href=\"`x`\">atnaujinti puslapį</a>" + "crash_page_refresh": "pabandėte <a href=\"`x`\">atnaujinti puslapį</a>", + "Album: ": "Albumas " } diff --git a/locales/pl.json b/locales/pl.json index 2dd3ed87..2b6768d9 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -317,6 +317,7 @@ "Movies": "Filmy", "Download": "Pobierz", "Download as: ": "Pobierz jako: ", + "Download is disabled": "Pobieranie jest wyłączone", "%A %B %-d, %Y": "%A, %-d %B %Y", "(edited)": "(edytowany)", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", @@ -495,5 +496,8 @@ "channel_tab_shorts_label": "Shorts", "Music in this video": "Muzyka w tym filmie", "Artist: ": "Wykonawca: ", - "Album: ": "Album: " + "Album: ": "Album: ", + "Song: ": "Piosenka: ", + "Channel Sponsor": "Sponsor kanału", + "Standard YouTube license": "Standardowa licencja YouTube" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index afd31ede..ec00d46e 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -381,7 +381,7 @@ "footer_documentation": "Documentação", "footer_source_code": "Código fonte", "footer_original_source_code": "Código fonte original", - "footer_modfied_source_code": "Código Fonte Modificado", + "footer_modfied_source_code": "Código-fonte modificado", "preferences_quality_dash_label": "Qualidade de vídeo do painel preferida: ", "preferences_region_label": "País do conteúdo: ", "preferences_quality_dash_option_4320p": "4320p", @@ -476,5 +476,8 @@ "channel_tab_channels_label": "Canais", "channel_tab_playlists_label": "Listas de reprodução", "channel_tab_shorts_label": "Curtos", - "channel_tab_streams_label": "Ao Vivo" + "channel_tab_streams_label": "Ao Vivo", + "Music in this video": "Música neste vídeo", + "Artist: ": "Artista: ", + "Album: ": "Álbum: " } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 1788deb1..43834d70 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -472,5 +472,12 @@ "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.", "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>", "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>", - "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>" + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>", + "Artist: ": "Artista: ", + "Album: ": "Álbum: ", + "channel_tab_streams_label": "Diretos", + "channel_tab_playlists_label": "Listas de reprodução", + "channel_tab_channels_label": "Canais", + "Music in this video": "Música neste vídeo", + "channel_tab_shorts_label": "Curtos" } diff --git a/locales/pt.json b/locales/pt.json index b6b6c110..cbce0e5a 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -479,5 +479,9 @@ "channel_tab_streams_label": "Diretos", "Music in this video": "Música neste vídeo", "Artist: ": "Artista: ", - "Album: ": "Álbum: " + "Album: ": "Álbum: ", + "Song: ": "Canção: ", + "Channel Sponsor": "Patrocinador do canal", + "Standard YouTube license": "Licença padrão do YouTube", + "Download is disabled": "A descarga está desativada" } diff --git a/locales/ru.json b/locales/ru.json index 7ca5cf1f..0031f79a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -4,7 +4,7 @@ "Unsubscribe": "Отписаться", "Subscribe": "Подписаться", "View channel on YouTube": "Смотреть канал на YouTube", - "View playlist on YouTube": "Посмотреть плейлист на YouTube", + "View playlist on YouTube": "Просмотреть подборку на ютубе", "newest": "сначала новые", "oldest": "сначала старые", "popular": "популярные", @@ -14,7 +14,7 @@ "Clear watch history?": "Очистить историю просмотров?", "New password": "Новый пароль", "New passwords must match": "Новые пароли не совпадают", - "Cannot change password for Google accounts": "Изменить пароль аккаунта Google невозможно", + "Cannot change password for Google accounts": "Изменить пароль учётной записи Google невозможно", "Authorize token?": "Авторизовать токен?", "Authorize token for `x`?": "Авторизовать токен для `x`?", "Yes": "Да", @@ -30,7 +30,7 @@ "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", @@ -38,14 +38,14 @@ "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": "Электронная почта", + "E-mail": "Эл. почта", "Google verification code": "Код подтверждения Google", "Preferences": "Настройки", "preferences_category_player": "Настройки проигрывателя", @@ -69,11 +69,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": "Настройки подписок", @@ -129,14 +129,14 @@ "Public": "Публичный", "Unlisted": "Нет в списке", "Private": "Приватный", - "View all playlists": "Посмотреть все плейлисты", + "View all playlists": "Просмотреть все подборки", "Updated `x` ago": "Обновлено `x` назад", - "Delete playlist `x`?": "Удалить плейлист `x`?", - "Delete playlist": "Удалить плейлист", - "Create playlist": "Создать плейлист", + "Delete playlist `x`?": "Удалить подборку `x`?", + "Delete playlist": "Удалить подборку", + "Create playlist": "Создать подборку", "Title": "Заголовок", - "Playlist privacy": "Видимость плейлиста", - "Editing playlist `x`": "Редактирование плейлиста `x`", + "Playlist privacy": "Видимость подборки", + "Editing playlist `x`": "Изменение подборки `x`", "Show more": "Развернуть", "Show less": "Свернуть", "Watch on YouTube": "Смотреть на YouTube", @@ -147,13 +147,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": { @@ -171,7 +171,7 @@ "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»", @@ -180,23 +180,23 @@ "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.": "Не удается загрузить страницы «в тренде».", + "Empty playlist": "Подборка пуста", + "Not a playlist.": "Это не подборка.", + "Playlist does not exist.": "Подборка не существует.", + "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 +213,7 @@ "Burmese": "Бирманский", "Catalan": "Каталонский", "Cebuano": "Себуанский", - "Chinese (Simplified)": "Китайский (упрощенный)", + "Chinese (Simplified)": "Китайский (упрощённый)", "Chinese (Traditional)": "Китайский (традиционный)", "Corsican": "Корсиканский", "Croatian": "Хорватский", @@ -310,7 +310,7 @@ "About": "О сайте", "Rating: ": "Рейтинг: ", "preferences_locale_label": "Язык: ", - "View as playlist": "Смотреть как плейлист", + "View as playlist": "Смотреть как подборку", "Default": "По умолчанию", "Music": "Музыка", "Gaming": "Игры", @@ -326,7 +326,7 @@ "Audio mode": "Аудио режим", "Video mode": "Видео режим", "channel_tab_videos_label": "Видео", - "Playlists": "Плейлисты", + "Playlists": "Подборки", "channel_tab_community_label": "Сообщество", "search_filters_sort_option_relevance": "по актуальности", "search_filters_sort_option_rating": "по рейтингу", @@ -343,7 +343,7 @@ "search_filters_date_option_year": "Этот год", "search_filters_type_option_video": "Видео", "search_filters_type_option_channel": "Канал", - "search_filters_type_option_playlist": "Плейлист", + "search_filters_type_option_playlist": "Подборка", "search_filters_type_option_movie": "Фильм", "search_filters_type_option_show": "Сериал", "search_filters_features_option_hd": "HD", @@ -379,13 +379,13 @@ "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": "Версия для встраивания", "videoinfo_invidious_embed_link": "Ссылка для встраивания", "download_subtitles": "Субтитры - `x` (.vtt)", - "user_created_playlists": "`x` созданных плейлистов", + "user_created_playlists": "`x` созданных подборок", "crash_page_you_found_a_bug": "Похоже, вы нашли ошибку в Invidious!", "crash_page_before_reporting": "Прежде чем сообщать об ошибке, убедитесь, что вы:", "crash_page_refresh": "пробовали <a href=\"`x`\"> перезагрузить страницу</a>", @@ -393,9 +393,9 @@ "generic_videos_count_0": "{{count}} видео", "generic_videos_count_1": "{{count}} видео", "generic_videos_count_2": "{{count}} видео", - "generic_playlists_count_0": "{{count}} плейлист", - "generic_playlists_count_1": "{{count}} плейлиста", - "generic_playlists_count_2": "{{count}} плейлистов", + "generic_playlists_count_0": "{{count}} подборка", + "generic_playlists_count_1": "{{count}} подборки", + "generic_playlists_count_2": "{{count}} подборок", "tokens_count_0": "{{count}} токен", "tokens_count_1": "{{count}} токена", "tokens_count_2": "{{count}} токенов", @@ -453,8 +453,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": "поискали <a href=\"`x`\">похожую проблему на GitHub</a>", "comments_points_count_0": "{{count}} плюс", "comments_points_count_1": "{{count}} плюса", @@ -488,12 +488,16 @@ "search_filters_duration_option_medium": "Средние (4 - 20 минут)", "search_filters_apply_button": "Применить фильтры", "Popular enabled: ": "Популярное включено: ", - "error_video_not_in_playlist": "Запрошенного видео нет в этом плейлисте. <a href=\"`x`\">Нажмите тут, чтобы вернуться к странице плейлиста.</a>", - "channel_tab_playlists_label": "Плейлисты", + "error_video_not_in_playlist": "Запрошенного видео нет в этой подборке. <a href=\"`x`\">Нажмите тут, чтобы вернуться к странице подборки.</a>", + "channel_tab_playlists_label": "Подборки", "channel_tab_channels_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": "Загрузка отключена" } diff --git a/locales/sl.json b/locales/sl.json index 47f295e0..410b432c 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -511,5 +511,9 @@ "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" } diff --git a/locales/sq.json b/locales/sq.json index 15025750..7f29a035 100644 --- a/locales/sq.json +++ b/locales/sq.json @@ -286,7 +286,7 @@ "search_filters_type_option_show": "Shfaqe", "search_filters_duration_option_short": "E shkurtër (< 4 minuta)", "search_filters_features_option_purchased": "Të blera", - "footer_modfied_source_code": "Kod Burim i ndryshuar", + "footer_modfied_source_code": "Kod burim i ndryshuar", "adminprefs_modified_source_code_url_label": "URL e depos së ndryshuar të kodit burim", "none": "asnjë", "videoinfo_started_streaming_x_ago": "Filloi transmetimin `x` më parë", @@ -468,5 +468,7 @@ "Artist: ": "Artist: ", "Album: ": "Album: ", "channel_tab_channels_label": "Kanale", - "Music in this video": "Muzikë në këtë video" + "Music in this video": "Muzikë në këtë video", + "channel_tab_shorts_label": "Të shkurtra", + "channel_tab_streams_label": "Transmetime të drejtpërdrejta" } diff --git a/locales/tr.json b/locales/tr.json index d98e2038..a2fdd573 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -363,7 +363,7 @@ "footer_documentation": "Belgelendirme", "footer_source_code": "Kaynak Kodları", "footer_original_source_code": "Orijinal Kaynak Kodları", - "footer_modfied_source_code": "Değiştirilmiş Kaynak Kodları", + "footer_modfied_source_code": "Değiştirilmiş kaynak kodları", "adminprefs_modified_source_code_url_label": "Değiştirilmiş Kaynak Kodları Deposunun URL'si", "footer_donate_page": "Bağış Yap", "preferences_region_label": "İçerik Ülkesi: ", @@ -479,5 +479,9 @@ "channel_tab_playlists_label": "Oynatma Listeleri", "Album: ": "Albüm: ", "Music in this video": "Bu videodaki müzik", - "Artist: ": "Sanatçı: " + "Artist: ": "Sanatçı: ", + "Channel Sponsor": "Kanal Sponsoru", + "Song: ": "Şarkı: ", + "Standard YouTube license": "Standart YouTube lisansı", + "Download is disabled": "İndirme devre dışı" } diff --git a/locales/uk.json b/locales/uk.json index b44d237f..61bf3d31 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -495,5 +495,9 @@ "channel_tab_channels_label": "Канали", "Music in this video": "Музика в цьому відео", "Artist: ": "Виконавець: ", - "Album: ": "Альбом: " + "Album: ": "Альбом: ", + "Song: ": "Пісня: ", + "Channel Sponsor": "Спонсор каналу", + "Standard YouTube license": "Стандартна ліцензія YouTube", + "Download is disabled": "Завантаження вимкнено" } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index aff6dd3e..df31812a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -463,5 +463,9 @@ "channel_tab_streams_label": "直播", "Album: ": "专辑: ", "channel_tab_shorts_label": "短视频", - "channel_tab_channels_label": "频道" + "channel_tab_channels_label": "频道", + "Song: ": "歌曲: ", + "Channel Sponsor": "频道赞助者", + "Standard YouTube license": "标准 YouTube 许可证", + "Download is disabled": "已禁用下载" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 8aa9869a..daa22493 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -463,5 +463,9 @@ "channel_tab_streams_label": "直播", "Artist: ": "藝術家: ", "Album: ": "專輯: ", - "Music in this video": "此影片中的音樂" + "Music in this video": "此影片中的音樂", + "Channel Sponsor": "頻道贊助者", + "Song: ": "歌曲: ", + "Standard YouTube license": "標準 YouTube 授權條款", + "Download is disabled": "已停用下載" } diff --git a/mocks b/mocks -Subproject dfd53ea6ceb3cbcbbce6004f6ce60b330ad0f9b +Subproject 11ec372f72747c09d48ffef04843f72be67d5b5 diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index 132b37a3..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(32_846_329) - expect(info["likes"].as_i).to eq(2_611_650) + 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... @@ -46,14 +46,14 @@ Spectator.describe "parse_video_info" do # Related videos - expect(info["relatedVideos"].as_a.size).to eq(19) + expect(info["relatedVideos"].as_a.size).to eq(20) - expect(info["relatedVideos"][0]["id"]).to eq("tVWWp1PqDus") - expect(info["relatedVideos"][0]["title"]).to eq("100 Girls Vs 100 Boys For $500,000") + expect(info["relatedVideos"][0]["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("49702799") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("49M") + 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/AMLnZu84dsnlYtuUFBMC8imQs0IUcTKA9khWAmUOgQZltw=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("101M") + 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_356_197) + 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") @@ -134,21 +134,19 @@ Spectator.describe "parse_video_info" do expect(info["relatedVideos"].as_a.size).to eq(19) - expect(info["relatedVideos"][0]["id"]).to eq("0bkrY_V0yZg") - expect(info["relatedVideos"][0]["title"]).to eq( - "Chris Rea Best Songs Collection - Chris Rea Greatest Hits Full Album 2022" - ) - expect(info["relatedVideos"][0]["author"]).to eq("Rock Ultimate") - expect(info["relatedVideos"][0]["ucid"]).to eq("UCekSc2A19di9koUIpj8gxlQ") - expect(info["relatedVideos"][0]["view_count"]).to eq("1992412") - expect(info["relatedVideos"][0]["short_view_count"]).to eq("1.9M") + expect(info["relatedVideos"][0]["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("<p></p>") + 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 ff5aacd5..25e08c51 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -1,114 +1,13 @@ require "../../parsers_helper.cr" Spectator.describe "parse_video_info" do - it "parses scheduled livestreams data (test 1)" do - # Enable mock - _player = load_mock("video/scheduled_live_nintendo.player") - _next = load_mock("video/scheduled_live_nintendo.next") - - raw_data = _player.merge!(_next) - info = parse_video_info("QMGibBzTu0g", raw_data) - - # Some basic verifications - expect(typeof(info)).to eq(Hash(String, JSON::Any)) - - expect(info["videoType"].as_s).to eq("Scheduled") - - # Basic video infos - - expect(info["title"].as_s).to eq("Xenoblade Chronicles 3 Nintendo Direct") - expect(info["views"].as_i).to eq(160) - expect(info["likes"].as_i).to eq(2_283) - expect(info["lengthSeconds"].as_i).to eq(0_i64) - expect(info["published"].as_s).to eq("2022-06-22T14:00:00Z") # Unix 1655906400 - - # Extra video infos - - expect(info["allowedRegions"].as_a).to_not be_empty - expect(info["allowedRegions"].as_a.size).to eq(249) - - expect(info["allowedRegions"].as_a).to contain( - "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", - "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", - "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" - ) - - expect(info["keywords"].as_a).to_not be_empty - expect(info["keywords"].as_a.size).to eq(11) - - expect(info["keywords"].as_a).to contain_exactly( - "nintendo", - "game", - "gameplay", - "fun", - "video game", - "action", - "adventure", - "rpg", - "play", - "switch", - "nintendo switch" - ).in_any_order - - expect(info["allowRatings"].as_bool).to be_true - expect(info["isFamilyFriendly"].as_bool).to be_true - expect(info["isListed"].as_bool).to be_true - expect(info["isUpcoming"].as_bool).to be_true - - # Related videos - - expect(info["relatedVideos"].as_a.size).to eq(20) - - # related video #1 - expect(info["relatedVideos"][3]["id"].as_s).to eq("a-SN3lLIUEo") - expect(info["relatedVideos"][3]["author"].as_s).to eq("Nintendo") - expect(info["relatedVideos"][3]["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg") - expect(info["relatedVideos"][3]["view_count"].as_s).to eq("147796") - expect(info["relatedVideos"][3]["short_view_count"].as_s).to eq("147K") - expect(info["relatedVideos"][3]["author_verified"].as_s).to eq("true") - - # Related video #2 - expect(info["relatedVideos"][16]["id"].as_s).to eq("l_uC1jFK0lo") - expect(info["relatedVideos"][16]["author"].as_s).to eq("Nintendo") - expect(info["relatedVideos"][16]["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg") - expect(info["relatedVideos"][16]["view_count"].as_s).to eq("53510") - expect(info["relatedVideos"][16]["short_view_count"].as_s).to eq("53K") - expect(info["relatedVideos"][16]["author_verified"].as_s).to eq("true") - - # Description - - description = "Tune in on 6/22 at 7 a.m. PT for a livestreamed Xenoblade Chronicles 3 Direct presentation featuring roughly 20 minutes of information about the upcoming RPG adventure for Nintendo Switch." - - expect(info["description"].as_s).to eq(description) - expect(info["shortDescription"].as_s).to eq(description) - expect(info["descriptionHtml"].as_s).to eq(description) - - # Video metadata - - expect(info["genre"].as_s).to eq("Gaming") - expect(info["genreUcid"].as_s).to be_empty - expect(info["license"].as_s).to be_empty - - # Author infos - - expect(info["author"].as_s).to eq("Nintendo") - expect(info["ucid"].as_s).to eq("UCGIY_O-8vW4rfX98KlMkvRg") - - expect(info["authorThumbnail"].as_s).to eq( - "https://yt3.ggpht.com/ytc/AKedOLTt4vtjREUUNdHlyu9c4gtJjG90M9jQheRlLKy44A=s48-c-k-c0x00ffffff-no-rj" - ) - - expect(info["authorVerified"].as_bool).to be_true - expect(info["subCountText"].as_s).to eq("8.5M") - end - - it "parses scheduled livestreams data (test 2)" do + it "parses scheduled livestreams data" do # Enable mock _player = load_mock("video/scheduled_live_PBD-Podcast.player") _next = load_mock("video/scheduled_live_PBD-Podcast.next") raw_data = _player.merge!(_next) - info = parse_video_info("RG0cjYbXxME", raw_data) + info = parse_video_info("N-yVic7BbY0", raw_data) # Some basic verifications expect(typeof(info)).to eq(Hash(String, JSON::Any)) @@ -117,11 +16,11 @@ Spectator.describe "parse_video_info" do # Basic video infos - expect(info["title"].as_s).to eq("The Truth About Greenpeace w/ Dr. Patrick Moore | PBD Podcast | Ep. 171") - expect(info["views"].as_i).to eq(24) - expect(info["likes"].as_i).to eq(22) + expect(info["title"].as_s).to eq("Home Team | PBD Podcast | Ep. 241") + expect(info["views"].as_i).to eq(6) + expect(info["likes"].as_i).to eq(7) expect(info["lengthSeconds"].as_i).to eq(0_i64) - expect(info["published"].as_s).to eq("2022-07-14T13:00:00Z") # Unix 1657803600 + expect(info["published"].as_s).to eq("2023-02-28T14:00:00Z") # Unix 1677592800 # Extra video infos @@ -173,40 +72,24 @@ Spectator.describe "parse_video_info" do expect(info["relatedVideos"].as_a.size).to eq(20) - # related video #1 - expect(info["relatedVideos"][2]["id"]).to eq("La9oLLoI5Rc") - expect(info["relatedVideos"][2]["author"]).to eq("Tom Bilyeu") - expect(info["relatedVideos"][2]["ucid"]).to eq("UCnYMOamNKLGVlJgRUbamveA") - expect(info["relatedVideos"][2]["view_count"]).to eq("13329149") - expect(info["relatedVideos"][2]["short_view_count"]).to eq("13M") - expect(info["relatedVideos"][2]["author_verified"]).to eq("true") - - # Related video #2 - expect(info["relatedVideos"][9]["id"]).to eq("IQ_4fvpzYuA") - expect(info["relatedVideos"][9]["author"]).to eq("Business Today") - expect(info["relatedVideos"][9]["ucid"]).to eq("UCaPHWiExfUWaKsUtENLCv5w") - expect(info["relatedVideos"][9]["view_count"]).to eq("26432") - expect(info["relatedVideos"][9]["short_view_count"]).to eq("26K") - expect(info["relatedVideos"][9]["author_verified"]).to eq("true") + expect(info["relatedVideos"][0]["id"]).to eq("j7jPzzjbVuk") + expect(info["relatedVideos"][0]["author"]).to eq("Democracy Now!") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCzuqE7-t13O4NIDYJfakrhw") + expect(info["relatedVideos"][0]["view_count"]).to eq("7576") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("7.5K") + expect(info["relatedVideos"][0]["author_verified"]).to eq("true") # Description - description_start_text = <<-TXT - PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. - - Join the channel to get exclusive access to perks: https://bit.ly/3Q9rSQL - TXT + description_start_text = "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 - https://aura.com/pbd" expect(info["description"].as_s).to start_with(description_start_text) expect(info["shortDescription"].as_s).to start_with(description_start_text) - expect(info["descriptionHtml"].as_s).to start_with( - <<-TXT - PBD Podcast Episode 171. In this episode, Patrick Bet-David is joined by Dr. Patrick Moore and Adam Sosnick. - - Join the channel to get exclusive access to perks: <a href="https://bit.ly/3Q9rSQL">bit.ly/3Q9rSQL</a> - TXT - ) + # 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 - <a href=\"https://aura.com/pbd\">aura.com/pbd</a>" + # ) # Video metadata @@ -223,6 +106,6 @@ Spectator.describe "parse_video_info" do "https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" ) expect(info["authorVerified"].as_bool).to be_false - expect(info["subCountText"].as_s).to eq("227K") + expect(info["subCountText"].as_s).to eq("594K") end end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 13af2d8b..ad786f3a 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -1,3 +1,5 @@ +private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} + # TODO: Add "sort_by" def fetch_channel_community(ucid, continuation, locale, format, thin_mode) response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") @@ -29,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"]? @@ -108,6 +108,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] .try &.as_s.gsub(/\D/, "").to_i? || 0 + reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0") + json.field "content", html_to_content(content_html) json.field "contentHtml", content_html @@ -115,6 +117,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "likeCount", like_count + json.field "replyCount", reply_count json.field "commentId", post["postId"]? || post["commentId"]? || "" json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid @@ -174,9 +177,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality @@ -185,10 +186,39 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - # TODO - # when .has_key?("pollRenderer") - # attachment = attachment["pollRenderer"] - # json.field "type", "poll" + when .has_key?("pollRenderer") + attachment = attachment["pollRenderer"] + json.field "type", "poll" + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice.dig("text", "runs", 0, "text").as_s + # A choice can have an image associated with it. + # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re + if choice["image"]? + thumbnail = choice["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + json.field "image" do + json.array do + IMAGE_QUALITIES.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end + end + end + end when .has_key?("postMultiImageRenderer") attachment = attachment["postMultiImageRenderer"] json.field "type", "multiImage" @@ -202,9 +232,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality @@ -240,10 +268,8 @@ 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 diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index befec03d..12ed4a7d 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -30,7 +30,9 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so "15:embedded" => { "1:embedded" => { "1:string" => object_inner_2_encoded, - "2:string" => "00000000-0000-0000-0000-000000000000", + }, + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", }, "3:varint" => sort_by_numerical, }, @@ -127,38 +129,15 @@ module Invidious::Channel::Tabs # Shorts # ------------------- - private def fetch_shorts_data(ucid : String, continuation : String? = nil) + def get_shorts(channel : AboutChannel, continuation : String? = nil) if continuation.nil? # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts" # TODO: try to extract the continuation tokens that allows other sorting options - return YoutubeAPI.browse(ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") + initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") else - return YoutubeAPI.browse(continuation: continuation) - end - end - - def get_shorts(channel : AboutChannel, continuation : String? = nil) - initial_data = self.fetch_shorts_data(channel.ucid, continuation) - - begin - # Try to parse the initial data fetched above - return extract_items(initial_data, channel.author, channel.ucid) - rescue ex : RetryOnceException - # Sometimes, for a completely unknown reason, the "reelItemRenderer" - # object is missing some critical information (it happens once in about - # 20 subsequent requests). Refreshing the page is required to properly - # show the "shorts" tab. - # - # In order to make the experience smoother for the user, we simulate - # said page refresh by fetching again the JSON. If that still doesn't - # work, we raise a BrokenTubeException, as something is really broken. - begin - initial_data = self.fetch_shorts_data(channel.ucid, continuation) - return extract_items(initial_data, channel.author, channel.ucid) - rescue ex : RetryOnceException - raise BrokenTubeException.new "reelPlayerHeaderSupportedRenderers" - end + initial_data = YoutubeAPI.browse(continuation: continuation) end + return extract_items(initial_data, channel.author, channel.ucid) end # ------------------- diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 357a461c..ec4449f0 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -182,7 +182,11 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b 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)) @@ -324,21 +328,32 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) + sponsor_icon = "" if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool author_name += " <i class=\"icon ion ion-md-checkmark-circle\"></i>" elsif child["verified"]?.try &.as_bool author_name += " <i class=\"icon ion ion-md-checkmark\"></i>" end + + if child["isSponsor"]?.try &.as_bool + sponsor_icon = String.build do |str| + str << %(<img alt="" ) + str << %(src="/ggpht) << URI.parse(child["sponsorIconUrl"].as_s).request_target << "\" " + str << %(title=") << translate(locale, "Channel Sponsor") << "\" " + str << %(width="16" height="16" />) + end + end html << <<-END_HTML <div class="pure-g" style="width:100%"> <div class="channel-profile pure-u-4-24 pure-u-md-2-24"> - <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}"> + <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}" alt="" /> </div> <div class="pure-u-20-24 pure-u-md-22-24"> <p> <b> <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a> </b> + #{sponsor_icon} <p style="white-space:pre-wrap">#{child["contentHtml"]}</p> END_HTML @@ -352,7 +367,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML <div class="pure-g"> <div class="pure-u-1 pure-u-md-1-2"> - <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}"> + <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}" alt="" /> </div> </div> END_HTML @@ -413,7 +428,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}"> <div class="creator-heart"> - <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> + <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" /> <div class="creator-heart-small-hearted"> <div class="icon ion-ios-heart creator-heart-small-container"></div> </div> @@ -589,7 +604,7 @@ def text_to_parsed_content(text : String) : JSON::Any 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] : ""} + afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} currentNodes << (JSON.parse(afterNode.to_json)) end @@ -620,61 +635,35 @@ def content_to_comment_html(content, video_id : String? = "") 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 = %(<a href="#{url}">#{reduce_uri(displayed_url)}</a>) - 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 = %(<a href="#{url}" data-onclick="jump_to_time" data-jump-time="#{start_time}">#{reduce_uri(text)}</a>) - else - text = %(<a href="#{url}">#{text}</a>) - 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 = %(<a href="#{url}">#{text}</a>) - else - text = %(<a href="#{url}">#{reduce_uri(url)}</a>) - end - end + if navigationEndpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigationEndpoint, text, video_id) end text = "<b>#{text}</b>" if run["bold"]? text = "<s>#{text}</s>" if run["strikethrough"]? text = "<i>#{text}</i>" 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 << %(<img alt=") << emojiAlt << "\" " + str << %(src="/ggpht) << URI.parse(emojiThumb["url"].as_s).request_target << "\" " + str << %(title=") << emojiAlt << "\" " + str << %(width=") << emojiThumb["width"] << "\" " + str << %(height=") << emojiThumb["height"] << "\" " + str << %(class="channel-emoji" />) + end + else + # Hide deleted channel emoji + text = "" + end + end + end + text end diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index a9b00860..e3214469 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -20,7 +20,7 @@ module Invidious::Frontend::WatchPage def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String if CONFIG.disabled?("downloads") - return "<p id=\"download\">#{translate(locale, "Download is disabled.")}</p>" + return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>" end return String.build(4000) do |str| diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 500a2582..bcf7c963 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -389,3 +389,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 = %(<a href="#{url}">#{reduce_uri(displayed_url)}</a>) + 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 = %(<a href="#{url}" data-onclick="jump_to_time" data-jump-time="#{start_time}">#{reduce_uri(text)}</a>) + else + text = %(<a href="#{url}">#{text}</a>) + 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 = %(<a href="#{url}">#{text}</a>) + else + text = %(<a href="#{url}">#{reduce_uri(url)}</a>) + end + end + return text +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index a2b1a35c..fe4b5223 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -197,6 +197,21 @@ module Invidious::JSONify::APIv1 end end + if !video.music.empty? + json.field "musicTracks" do + json.array do + video.music.each do |music| + json.object do + json.field "song", music.song + json.field "artist", music.artist + json.field "album", music.album + json.field "license", music.license + end + end + end + end + end + json.field "recommendedVideos" do json.array do video.related_videos.each do |rv| 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) <li class="pure-menu-item"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}"> <div class="thumbnail"> - <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> 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) <li class="pure-menu-item" id="#{video["videoId"]}"> <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}"> <div class="thumbnail"> - <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 8f69df94..5aa4452c 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -262,7 +262,7 @@ module Invidious::Routes::Account end query["token"] = access_token - query["username"] = user.email + query["username"] = URI.encode_path_segment(user.email) url.query = query.to_s env.redirect url.to_s diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 6b935312..ce2ee812 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -54,6 +54,65 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end + def self.get_history(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + page = env.params.query["page"]?.try &.to_i?.try &.clamp(0, Int32::MAX) + page ||= 1 + + 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 + + start_index = (page - 1) * max_results + if user.watched[start_index]? + watched = user.watched.reverse[start_index, max_results] + end + watched ||= [] of String + + return watched.to_json + end + + def self.mark_watched(env) + user = env.get("user").as(User) + + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + + id = env.params.url["id"] + if !id.match(/^[a-zA-Z0-9_-]{11}$/) + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_watched(user, id) + env.response.status_code = 204 + end + + def self.mark_unwatched(env) + user = env.get("user").as(User) + + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + + id = env.params.url["id"] + if !id.match(/^[a-zA-Z0-9_-]{11}$/) + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_unwatched(user, id) + env.response.status_code = 204 + end + + def self.clear_history(env) + user = env.get("user").as(User) + + Invidious::Database::Users.clear_watch_history(user) + env.response.status_code = 204 + end + def self.feed(env) env.response.content_type = "application/json" diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 1e932d11..9641e01a 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -256,7 +256,7 @@ module Invidious::Routes::VideoPlayback return error_template(400, "Invalid video ID") end - if itag.nil? || itag <= 0 || itag >= 1000 + if !itag.nil? && (itag <= 0 || itag >= 1000) return error_template(400, "Invalid itag") end @@ -277,7 +277,11 @@ module Invidious::Routes::VideoPlayback return error_template(500, ex) end - fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + if itag.nil? + fmt = video.fmt_stream[-1]? + else + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + end url = fmt.try &.["url"]?.try &.as_s if !url diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index dca2f117..9e2ade3d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -257,6 +257,11 @@ module Invidious::Routing get "/api/v1/auth/export/invidious", {{namespace}}::Authenticated, :export_invidious post "/api/v1/auth/import/invidious", {{namespace}}::Authenticated, :import_invidious + get "/api/v1/auth/history", {{namespace}}::Authenticated, :get_history + post "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_watched + delete "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_unwatched + delete "/api/v1/auth/history", {{namespace}}::Authenticated, :clear_history + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions 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/videos.cr b/src/invidious/videos.cr index 436ac82d..0038a97a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -249,7 +249,12 @@ struct Video def music : Array(VideoMusic) info["music"].as_a.map { |music_json| - VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + VideoMusic.new( + music_json["song"].as_s, + music_json["album"].as_s, + music_json["artist"].as_s, + music_json["license"].as_s + ) } 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/music.cr b/src/invidious/videos/music.cr index 402ae46f..08d88a3e 100644 --- a/src/invidious/videos/music.cr +++ b/src/invidious/videos/music.cr @@ -3,10 +3,11 @@ require "json" struct VideoMusic include JSON::Serializable + property song : String property album : String property artist : String property license : String - def initialize(@album : String, @artist : String, @license : String) + def initialize(@song : String, @album : String, @artist : String, @license : String) end end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index cf43f1be..2e8eecc3 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -185,10 +185,12 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # We have to try to extract viewCount from videoPrimaryInfoRenderer first, # then from videoDetails, as the latter is "0" for livestreams (we want # to get the amount of viewers watching). - views_txt = video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") - views_txt ||= video_details["viewCount"]? - views = views_txt.try &.as_s.gsub(/\D/, "").to_i64? + views_txt = extract_text( + video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") + ) + views_txt ||= video_details["viewCount"]?.try &.as_s || "" + views = views_txt.gsub(/\D/, "").to_i64? length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) .try &.as_s.to_i64 @@ -282,8 +284,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 @@ -325,17 +329,28 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any album = nil music_license = nil + # Used when the video has multiple songs + if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") + # "simpleText" for plain text / "runs" when song has a link + song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") + + # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU + next if !song + end + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) if desc_title == "ARTIST" artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "SONG" + song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) elsif desc_title == "ALBUM" album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) elsif desc_title == "LICENSES" music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) end end - music_list << VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) + music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) end # Author infos 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 %> <div class="h-box"> - <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>"> + <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" /> </div> <div class="h-box"> @@ -11,7 +11,7 @@ <div class="pure-g h-box"> <div class="pure-u-2-3"> <div class="channel-profile"> - <img src="/ggpht<%= channel_profile_pic %>"> + <img src="/ggpht<%= channel_profile_pic %>" alt="" /> <span><%= author %></span><% if !channel.verified.nil? && channel.verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %> </div> </div> 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 @@ <a href="/channel/<%= item.ucid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <center> - <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/> + <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" /> </center> <% end %> <p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></p> @@ -25,7 +25,7 @@ <a style="width:100%" href="<%= url %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/> + <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" /> <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p> </div> <% end %> @@ -38,7 +38,7 @@ <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" /> <% if item.length_seconds != 0 %> <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> <% end %> @@ -58,7 +58,7 @@ <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" /> <% if plid_form = env.get?("remove_playlist_items") %> <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post"> @@ -112,7 +112,7 @@ <a style="width:100%" href="/watch?v=<%= item.id %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> + <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" /> <% if env.get? "show_watched" %> <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> 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 @@ <a style="width:100%" href="/watch?v=<%= item %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> + <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" /> <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <p class="watched"> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 666eb3b0..5b3190f3 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -181,7 +181,11 @@ we're going to need to do it here in order to allow for translations. <% end %> </p> <% if video.license %> - <p id="license"><%= translate(locale, "License: ") %><%= video.license %></p> + <% if video.license.empty? %> + <p id="license"><%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %></p> + <% else %> + <p id="license"><%= translate(locale, "License: ") %><%= video.license %></p> + <% end %> <% end %> <p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p> <p id="wilson" style="display: none; visibility: hidden;"></p> @@ -204,7 +208,7 @@ we're going to need to do it here in order to allow for translations. <a href="/channel/<%= video.ucid %>" style="display:block;width:fit-content;width:-moz-fit-content"> <div class="channel-profile"> <% if !video.author_thumbnail.empty? %> - <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>"> + <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" /> <% end %> <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></span> </div> @@ -248,9 +252,9 @@ we're going to need to do it here in order to allow for translations. <div id="music-description-box"> <% video.music.each do |music| %> <div class="music-item"> - <p id="music-artist"><%= translate(locale, "Artist: ") %><%= music.artist %></p> - <p id="music-album"><%= translate(locale, "Album: ") %><%= music.album %></p> - <p id="music-license"><%= translate(locale, "License: ") %><%= music.license %></p> + <p class="music-song"><%= translate(locale, "Song: ") %><%= music.song %></p> + <p class="music-artist"><%= translate(locale, "Artist: ") %><%= music.artist %></p> + <p class="music-album"><%= translate(locale, "Album: ") %><%= music.album %></p> </div> <% end %> </div> @@ -294,7 +298,7 @@ we're going to need to do it here in order to allow for translations. <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %> <div class="thumbnail"> - <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" /> <p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p> </div> <% end %> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b14ad7b9..9c041361 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -18,6 +18,7 @@ private ITEM_PARSERS = { Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, + Parsers::ItemSectionRendererParser, Parsers::ContinuationItemRendererParser, } @@ -377,6 +378,30 @@ private module Parsers end end + # 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 + # by the result page for channel searches. It is located inside a continuationItems + # container.It is very similar to RichItemRendererParser + # + module ItemSectionRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item.dig?("itemSectionRenderer", "contents", 0) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + return child + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses an InnerTube richItemRenderer into a SearchVideo. # Returns nil when the given object isn't a RichItemRenderer # @@ -423,44 +448,43 @@ private module Parsers "overlay", "reelPlayerOverlayRenderer" ) - # Sometimes, the "reelPlayerOverlayRenderer" object is missing the - # important part of the response. We use this exception to tell - # the calling function to fetch the content again. - if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers") - raise RetryOnceException.new - end - - video_details_container = reel_player_overlay.dig( - "reelPlayerHeaderSupportedRenderers", - "reelPlayerHeaderRenderer" - ) - - # Author infos + if video_details_container = reel_player_overlay.dig?( + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + # Author infos - author = video_details_container - .dig?("channelTitleText", "runs", 0, "text") - .try &.as_s || author_fallback.name + author = video_details_container + .dig?("channelTitleText", "runs", 0, "text") + .try &.as_s || author_fallback.name - ucid = video_details_container - .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") - .try &.as_s || author_fallback.id + ucid = video_details_container + .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id - # Title & publication date + # Title & publication date - title = video_details_container.dig?("reelTitleText") - .try { |t| extract_text(t) } || "" + title = video_details_container.dig?("reelTitleText") + .try { |t| extract_text(t) } || "" - published = video_details_container - .dig?("timestampText", "simpleText") - .try { |t| decode_date(t.as_s) } || Time.utc + published = video_details_container + .dig?("timestampText", "simpleText") + .try { |t| decode_date(t.as_s) } || Time.utc + # View count + view_count_text = video_details_container.dig?("viewCountText", "simpleText") + else + author = author_fallback.name + ucid = author_fallback.id + published = Time.utc + title = item_contents.dig?("headline", "simpleText").try &.as_s || "" + end # View count # View count used to be in the reelWatchEndpoint, but that changed? - view_count_text = item_contents.dig?("viewCountText", "simpleText") - view_count_text ||= video_details_container.dig?("viewCountText", "simpleText") + view_count_text ||= item_contents.dig?("viewCountText", "simpleText") - view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + view_count = short_text_to_number(view_count_text.try &.as_s || "0") # Duration @@ -773,6 +797,7 @@ end def extract_items(initial_data : InitialData, &block) if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h else unpackaged_data = initial_data 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) |
