Compare commits

..

25 Commits

Author SHA1 Message Date
syeopite
6ff616d9a4 Ensure http-proxy is not used for companion 2025-05-13 05:18:52 -07:00
syeopite
bd2f00f6df Formatting 2025-05-13 05:18:52 -07:00
syeopite
d70b05e5c3 Remove idle_pool_size config
Clients created when idle_capacity is reached but not max_capacity
are discarded as soon as the client is checked back into the pool,
not when the connection is closed.

This means that allowing idle_capacity to be lower than max_capacity
essentially just makes the remaining clients a checkout timeout
deterrent that gets thrown away as soon as it is used. Not useful
for reusing connections whatsoever during peak load times
2025-05-13 05:18:52 -07:00
syeopite
924ae11721 Remove extraneous space 2025-05-13 05:18:52 -07:00
syeopite
43f9af2015 Fix ameba complaints 2025-05-13 05:18:52 -07:00
syeopite
4628f98eeb Merge companion and standard pool into one 2025-05-13 05:18:52 -07:00
syeopite
60b45290bd Pool: raise custom error when DB::PoolTimeout 2025-05-13 05:18:52 -07:00
syeopite
e5795cab3d Pool: Make Pool#client method public 2025-05-13 05:18:52 -07:00
syeopite
b79177f07e Add tests for connection pool 2025-05-13 05:18:52 -07:00
syeopite
7fe8f6f24e Use non-streaming api when not invoked with block
Defaulting to the streaming api of `HTTP::Client` causes some issues
since the streaming respone content needs to be accessed
through #body_io rather than #body
2025-05-13 05:18:52 -07:00
syeopite
936c0ff61b Update comment on reiniting proxy of pooled client 2025-05-13 05:18:52 -07:00
syeopite
802790e468 Simplify namespace calls to ConnectionPool 2025-05-13 05:18:52 -07:00
syeopite
3dd68872f9 Pool: remove redundant properties 2025-05-13 05:18:52 -07:00
syeopite
be9749aefd Pool: Refactor logic for request methods
Make non-block request method internally call
the block based request method.
2025-05-13 05:18:51 -07:00
syeopite
7cd974dc24 Release client only when it still exists
@pool.release should not be called when the client has already been
deleted from the pool.
2025-05-13 05:18:51 -07:00
syeopite
bf2eccbcaf Connection pool: ensure response is fully read
The streaming API of HTTP::Client has an internal buffer
that will continue to persist onto the next request unless
the response is fully read.

This commit privatizes the #client method of Pool and instead
expose various HTTP request methods that will call and yield
the underlying request and response.

This way, we can ensure that the resposne is fully read before
the client is passed back into the pool for another request.
2025-05-13 05:18:51 -07:00
syeopite
586dfcc1e3 Improve documentation of idle pool size 2025-05-13 05:18:51 -07:00
syeopite
4e4a084f22 Delete broken clients from the pool explicitly 2025-05-13 05:18:51 -07:00
syeopite
65e6929037 Remove redundant pool.release
pool.checkout(&block) already ensures that the checked out item
will be released back into the pool
2025-05-13 05:18:51 -07:00
syeopite
2f52ebc7e3 Typo 2025-05-13 05:18:51 -07:00
syeopite
97b13c042e Add config to set connection pool checkout timeout 2025-05-13 05:18:51 -07:00
syeopite
c86846e7b7 Move ytimg pool logic to Invidious::ConnectionPool 2025-05-13 05:18:51 -07:00
syeopite
95a1f632d8 Move client logic file to connection subfolder 2025-05-13 05:18:51 -07:00
syeopite
220adf53f4 Refactor connection pooling logic
- Remove duplication between standard and companion pool
- Raises a wrapped exception on any DB:Error
- Don't use a non-pool client when client fails
- Ensure that client is always released
- Add documentation to various pool methods
2025-05-13 05:18:51 -07:00
syeopite
c2ede1d2a5 Add support for setting max idle http pool size 2025-05-13 05:18:51 -07:00
79 changed files with 904 additions and 1005 deletions

View File

@@ -25,7 +25,7 @@ Lint/NotNil:
Lint/SpecFilename:
Excluded:
- spec/parsers_helper.cr
- spec/*_helper.cr
#

3
.github/CODEOWNERS vendored
View File

@@ -1,3 +1,6 @@
# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review.
* @iv-org/developers
docker-compose.yml @unixfox
docker/ @unixfox
kubernetes/ @unixfox

View File

@@ -38,11 +38,10 @@ jobs:
matrix:
stable: [true]
crystal:
- 1.12.2
- 1.13.3
- 1.14.1
- 1.15.1
- 1.16.3
- 1.12.1
- 1.13.2
- 1.14.0
- 1.15.0
include:
- crystal: nightly
stable: false

View File

@@ -2,15 +2,11 @@
## vX.Y.0 (future)
## v2.20250517.0
Inverse fallback for the YouTube client from TVHTML then MWEB. Fixes https://github.com/iv-org/invidious/issues/5273
## v2.20250504.0
Small release with quick workaround fix for issue #4251 (Nil assertion failed).
PR: https://github.com/iv-org/invidious/issues/5262
PR: https://github.com/iv-org/invidious/issues/5263
## v2.20250314.0

View File

@@ -550,10 +550,6 @@ span > select {
color: #565d64;
}
.light-theme .error-card {
border: 1px solid black;
}
@media (prefers-color-scheme: light) {
.no-theme a:hover,
.no-theme a:active,
@@ -600,10 +596,6 @@ span > select {
.light-theme .pure-menu-heading {
color: #565d64;
}
.no-theme .error-card {
border: 1px solid black;
}
}
@@ -666,10 +658,6 @@ body.dark-theme {
color: inherit;
}
.dark-theme .error-card {
border: 1px solid #5e5e5e;
}
@media (prefers-color-scheme: dark) {
.no-theme a:hover,
.no-theme a:active,
@@ -731,10 +719,6 @@ body.dark-theme {
.no-theme footer a {
color: #adadad !important;
}
.no-theme .error-card {
border: 1px solid #5e5e5e;
}
}
@@ -832,57 +816,3 @@ h1, h2, h3, h4, h5, p,
#download_widget {
width: 100%;
}
.error-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 25px;
margin-bottom: 1em;
border-radius: 10px;
box-sizing: border-box;
height: 100%;
}
.error-card > .explanation {
display: grid;
grid-template-columns: max-content 1fr;
grid-template-rows: 1fr max-content;
align-items: center;
column-gap: 10px;
row-gap: 4px;
}
.error-card > .explanation > i {
color: #f44;
font-size: 24px;
grid-area: 1 / 1 / 2 / 2;
}
.error-card > .explanation > h4 {
grid-area: 1 / 2 / 2 / 3;
margin: 0;
}
.error-card > .explanation > p {
grid-area: 2 / 2 / 3 / 3;
margin: 0;
}
.error-card details {
margin-top: 10px;
width: 100%;
}
.error-card summary {
width: 100%;
}
.error-card pre {
height: 300px;
}
.error-issue-template {
padding: 20px;
background: rgba(0, 0, 0, 0.12345);
}

View File

@@ -1,4 +1,4 @@
#filters-collapse summary {
summary {
/* This should hide the marker */
display: block;
@@ -8,10 +8,10 @@
cursor: pointer;
}
#filters-collapse summary::-webkit-details-marker,
#filters-collapse summary::marker { display: none; }
summary::-webkit-details-marker,
summary::marker { display: none; }
#filters-collapse summary:before {
summary:before {
border-radius: 5px;
content: "[ + ]";
margin: -2px 10px 0 10px;
@@ -20,7 +20,7 @@
width: 40px;
}
#filters-collapse details[open] > summary:before { content: "[ ]"; }
details[open] > summary:before { content: "[ ]"; }
#filters-box {

View File

@@ -206,7 +206,7 @@ https_only: false
#disable_proxy: false
##
## Size of the HTTP pool used to connect to youtube. Each
## Max size of the HTTP pool used to connect to youtube. Each
## domain ('youtube.com', 'ytimg.com', ...) has its own pool.
##
## Accepted values: a positive integer
@@ -214,6 +214,16 @@ https_only: false
##
#pool_size: 100
##
## Amount of seconds to wait for a client to be free from the pool
## before raising an error
##
##
## Accepted values: a positive integer
## Default: 5
##
#pool_checkout_timeout: 5
##
## Additional cookies to be sent when requesting the youtube API.

View File

@@ -1,4 +1,4 @@
FROM crystallang/crystal:1.16.3-alpine AS builder
FROM crystallang/crystal:1.16.2-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static

View File

@@ -154,8 +154,8 @@
"View YouTube comments": "عرض تعليقات اليوتيوب",
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليق",
"": "عرض `x` تعليقات"
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات",
"": "عرض `x` تعليقات."
},
"View Reddit comments": "عرض تعليقات ريديت",
"Hide replies": "إخفاء الردود",
@@ -566,8 +566,5 @@
"carousel_skip": "تخطي الكاروسيل",
"carousel_go_to": "انتقل إلى الشريحة `x`",
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)",
"channel_tab_courses_label": "الدورات",
"channel_tab_posts_label": "المنشورات",
"First page": "الصفحة الأولى"
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
}

View File

@@ -403,7 +403,7 @@
"comments_view_x_replies": "Виж {{count}} отговор",
"comments_view_x_replies_plural": "Виж {{count}} отговора",
"footer_original_source_code": "Оригинален изходен код",
"Import YouTube subscriptions": "Импортиране на YouTube-CSV/OPML абонаменти",
"Import YouTube subscriptions": "Импортиране на YouTube/OPML абонаменти",
"Lithuanian": "Литовски",
"Nyanja": "Нянджа",
"Updated `x` ago": "Актуализирано преди `x`",
@@ -493,8 +493,5 @@
"Add to playlist: ": "Добави към плейлист: ",
"Answer": "Отговор",
"Search for videos": "Търсене на видеа",
"The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора.",
"Filipino (auto-generated)": "Филипински (автоматично генериран)",
"preferences_preload_label": "Предварително заредете видео данни: ",
"First page": "Първа страница"
"The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора."
}

View File

@@ -204,7 +204,7 @@
"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`\">cercar en una altra instància</a>.",
"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à",
@@ -489,16 +489,5 @@
"generic_button_delete": "Suprimeix",
"Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)",
"Answer": "Resposta",
"toggle_theme": "Commuta el tema",
"Add to playlist": "Afegeix a la llista de reproducció",
"Add to playlist: ": "Afegeix a la llista de reproducció: ",
"Search for videos": "Cercar vídeos",
"carousel_slide": "Diapositiva {{current}} de {{total}}",
"preferences_preload_label": "Precarregar dades del vídeo: ",
"carousel_go_to": "Anar a la diapositiva `x`",
"First page": "Primera pàgina",
"Filipino (auto-generated)": "Filipí (generat automàticament)",
"channel_tab_courses_label": "Cursos",
"channel_tab_posts_label": "Missatges",
"carousel_skip": "Saltar l'exhibició"
"toggle_theme": "Commuta el tema"
}

View File

@@ -515,8 +515,5 @@
"carousel_skip": "Přeskočit galerii",
"carousel_go_to": "Přejít na snímek `x`",
"preferences_preload_label": "Předem načíst data videa: ",
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)",
"First page": "První stránka",
"channel_tab_courses_label": "Kurzy",
"channel_tab_posts_label": "Příspěvky"
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
}

View File

@@ -141,7 +141,7 @@
"An alternative front-end to YouTube": "Pen blaen amgen i YouTube",
"source": "ffynhonnell",
"Log in": "Mewngofnodi",
"Log in/register": "Mewngofnodi/cofrestru",
"Log in/register": "Mewngofnodi/Cofrestru",
"User ID": "Enw defnyddiwr",
"preferences_quality_option_dash": "DASH (ansawdd addasol)",
"Sign In": "Mewngofnodi",
@@ -381,32 +381,5 @@
"channel_tab_channels_label": "Sianeli",
"channel_tab_community_label": "Cymuned",
"channel_tab_shorts_label": "Fideos byrion",
"channel_tab_videos_label": "Fideos",
"generic_playlists_count_0": "{{count}} rhestr chwarae",
"generic_playlists_count_1": "{{count}} rhestr chwarae",
"generic_playlists_count_2": "{{count}} rhestri chwarae",
"generic_playlists_count_3": "{{count}} rhestri chwarae",
"generic_playlists_count_4": "{{count}} rhestri chwarae",
"generic_playlists_count_5": "{{count}} rhestri chwarae",
"New passwords must match": "Rhaid i'r cyfrineiriau newydd cyfateb â'i gilydd",
"last": "diwethaf",
"First page": "Tudalen gyntaf",
"preferences_preload_label": "Cynlwytho data fideo: ",
"preferences_extend_desc_label": "Ymestyn disgrifiad fideo'n awtomatig: ",
"preferences_vr_mode_label": "Fideos rhyngweithiol 360 gradd (angen WebGL): ",
"preferences_video_loop_label": "Doleniwch bob amser: ",
"Top enabled: ": "Tudalen fideos brig wedi'i alluogi: ",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Allforio tanysgrifiadau ar fformat OPML (i NewPipe a FreeTube)",
"Export subscriptions as OPML": "Allforio tanysgrifiadau ar fformat OPML",
"preferences_annotations_subscribed_label": "Ddangos nodiadau sianeli tanysgrifiwyd fel rhagosodiad? ",
"Redirect homepage to feed: ": "Ailgyfeirio tudalen gartref i'r borthiant: ",
"preferences_feed_menu_label": "Dewislen porthiant: ",
"Login enabled: ": "Mewngofnodi wedi'i alluogi: ",
"tokens_count_0": "",
"tokens_count_1": "tocyn",
"tokens_count_2": "",
"tokens_count_3": "",
"tokens_count_4": "tocynnau",
"tokens_count_5": "",
"Source available here.": "Tarddle ar gael yma."
"channel_tab_videos_label": "Fideos"
}

View File

@@ -499,7 +499,5 @@
"carousel_go_to": "Zu Element `x` springen",
"carousel_slide": "Seite {{current}} von {{total}}",
"carousel_skip": "Galerie überspringen",
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)",
"channel_tab_courses_label": "Kurse",
"channel_tab_posts_label": "Beiträge"
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
}

View File

@@ -490,7 +490,7 @@
"Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση",
"Add to playlist": "Προσθήκη στην λίστα αναπαραγωγής",
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής",
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ",
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
@@ -498,8 +498,5 @@
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
"carousel_skip": "Αποφυγή εμφάνισης εικόνων",
"First page": "Πρώτη σελίδα",
"channel_tab_courses_label": "Μαθήματα",
"channel_tab_posts_label": "Δημοσιεύσεις"
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
}

View File

@@ -64,6 +64,8 @@
"User ID": "User ID",
"Password": "Password",
"Time (h:mm:ss):": "Time (h:mm:ss):",
"Text CAPTCHA": "Text CAPTCHA",
"Image CAPTCHA": "Image CAPTCHA",
"Sign In": "Sign In",
"Register": "Register",
"E-mail": "E-mail",
@@ -499,8 +501,5 @@
"toggle_theme": "Toggle Theme",
"carousel_slide": "Slide {{current}} of {{total}}",
"carousel_skip": "Skip the Carousel",
"carousel_go_to": "Go to slide `x`",
"timeline_parse_error_placeholder_heading": "Unable to parse item",
"timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:",
"timeline_parse_error_show_technical_details": "Show technical details"
"carousel_go_to": "Go to slide `x`"
}

View File

@@ -187,10 +187,10 @@
"Hidden field \"token\" is a required field": "El campo oculto «símbolo» es un campo obligatorio",
"Erroneous challenge": "Desafío no válido",
"Erroneous token": "Símbolo no válido",
"No such user": "El usuario no existe",
"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",
@@ -276,7 +276,7 @@
"Somali": "Somalí",
"Southern Sotho": "Sesoto",
"Spanish": "Español",
"Spanish (Latin America)": "Español (Latinoamérica)",
"Spanish (Latin America)": "Español (Hispanoamérica)",
"Sundanese": "Sondanés",
"Swahili": "Suajili",
"Swedish": "Sueco",
@@ -412,8 +412,8 @@
"generic_count_weeks_1": "{{count}} semanas",
"generic_count_weeks_2": "{{count}} semanas",
"generic_playlists_count_0": "{{count}} lista de reproducción",
"generic_playlists_count_1": "{{count}} listas de reproducción",
"generic_playlists_count_2": "{{count}} listas de reproducción",
"generic_playlists_count_1": "{{count}} listas de reproducciones",
"generic_playlists_count_2": "{{count}} listas de reproducciones",
"generic_videos_count_0": "{{count}} video",
"generic_videos_count_1": "{{count}} videos",
"generic_videos_count_2": "{{count}} videos",
@@ -463,7 +463,7 @@
"Chinese (Hong Kong)": "Chino (Hong Kong)",
"Chinese (China)": "Chino (China)",
"Korean (auto-generated)": "Coreano (generados automáticamente)",
"Spanish (Mexico)": "Español (México)",
"Spanish (Mexico)": "Español (Méjico)",
"Spanish (auto-generated)": "Español (generados automáticamente)",
"preferences_watch_history_label": "Habilitar historial de reproducciones: ",
"search_message_no_results": "No se han encontrado resultados.",
@@ -500,7 +500,7 @@
"generic_button_cancel": "Cancelar",
"generic_button_rss": "RSS",
"channel_tab_podcasts_label": "Podcasts",
"channel_tab_releases_label": "Lanzamientos",
"channel_tab_releases_label": "Publicaciones",
"generic_channels_count_0": "{{count}} canal",
"generic_channels_count_1": "{{count}} canales",
"generic_channels_count_2": "{{count}} canales",
@@ -515,8 +515,5 @@
"carousel_skip": "Saltar el carrusel",
"carousel_go_to": "Ir a la diapositiva `x`",
"preferences_preload_label": "Precargar datos del vídeo: ",
"Filipino (auto-generated)": "Filipino (generados automáticamente)",
"channel_tab_posts_label": "Publicaciones",
"First page": "Primera página",
"channel_tab_courses_label": "Cursos"
"Filipino (auto-generated)": "Filipino (generado automáticamente)"
}

View File

@@ -515,8 +515,5 @@
"carousel_go_to": "Aller à la diapositive `x`",
"toggle_theme": "Changer le Thème",
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
"preferences_preload_label": "Précharger les données de la vidéo : ",
"First page": "Première page",
"channel_tab_courses_label": "Cours",
"channel_tab_posts_label": "Messages"
"preferences_preload_label": "Précharger les données de la vidéo : "
}

View File

@@ -2,7 +2,7 @@
"LIVE": "BEINT",
"Shared `x` ago": "Deilt fyrir `x` síðan",
"Unsubscribe": "Afskrá",
"Subscribe": "Setja í áskrift",
"Subscribe": "Áskrifa",
"View channel on YouTube": "Skoða rás á YouTube",
"View playlist on YouTube": "Skoða spilunarlista á YouTube",
"newest": "nýjasta",
@@ -14,8 +14,8 @@
"Clear watch history?": "Hreinsa áhorfsferil?",
"New password": "Nýtt lykilorð",
"New passwords must match": "Nýtt lykilorð verður að passa",
"Authorize token?": "Auðkenna teikn?",
"Authorize token for `x`?": "Auðkenna teikn fyrir `x`?",
"Authorize token?": "Leyfa teikn?",
"Authorize token for `x`?": "Leyfa teikn fyrir `x`?",
"Yes": "Já",
"No": "Nei",
"Import and Export Data": "Inn- og útflutningur gagna",
@@ -36,17 +36,17 @@
"source": "uppruni",
"Log in": "Skrá inn",
"Log in/register": "Innskráning/nýskráning",
"User ID": "Auðkenni notanda",
"User ID": "Notandakenni",
"Password": "Lykilorð",
"Time (h:mm:ss):": "Tími (h:mm: ss):",
"Text CAPTCHA": "CAPTCHA-texti",
"Image CAPTCHA": "CAPTCHA-mynd",
"Text CAPTCHA": "Texta CAPTCHA",
"Image CAPTCHA": "Mynd CAPTCHA",
"Sign In": "Skrá inn",
"Register": "Nýskrá",
"E-mail": "Tölvupóstur",
"Preferences": "Kjörstillingar",
"preferences_category_player": "Kjörstillingar spilara",
"preferences_video_loop_label": "Alltaf endurtaka: ",
"preferences_video_loop_label": "Alltaf lykkja: ",
"preferences_autoplay_label": "Sjálfvirk spilun: ",
"preferences_continue_label": "Spila næst sjálfgefið: ",
"preferences_continue_autoplay_label": "Spila næsta myndskeið sjálfkrafa: ",
@@ -85,7 +85,7 @@
"preferences_unseen_only_label": "Sýna aðeins óséð: ",
"preferences_notifications_only_label": "Sýna aðeins tilkynningar (ef einhverjar eru): ",
"Enable web notifications": "Virkja veftilkynningar",
"`x` uploaded a video": "`x` sendi inn myndskeið",
"`x` uploaded a video": "`x` hlóð upp myndband",
"`x` is live": "`x` er í beinni",
"preferences_category_data": "Gagnastillingar",
"Clear watch history": "Hreinsa áhorfsferil",
@@ -104,8 +104,8 @@
"Registration enabled: ": "Nýskráning virkjuð? ",
"Report statistics: ": "Skrá tölfræði? ",
"Save preferences": "Vista stillingar",
"Subscription manager": "Áskriftastýring",
"Token manager": "Teiknastýring",
"Subscription manager": "Áskriftarstjóri",
"Token manager": "Teiknastjórnun",
"Token": "Teikn",
"Import/export": "Flytja inn/út",
"unsubscribe": "afskrá",
@@ -233,7 +233,7 @@
"Korean": "Kóreska",
"Kurdish": "Kúrdíska",
"Kyrgyz": "Kirgisíska",
"Lao": "Laóska",
"Lao": "Laó",
"Latin": "Latína",
"Latvian": "Lettneska",
"Lithuanian": "Litháíska",
@@ -295,18 +295,18 @@
"View as playlist": "Skoða sem spilunarlista",
"Default": "Sjálfgefið",
"Music": "Tónlist",
"Gaming": "Spilun leikja",
"Gaming": "Tólvuleikja",
"News": "Fréttir",
"Movies": "Kvikmyndir",
"Download": "Niðurhal",
"Download as: ": "Sækja sem: ",
"Download as: ": "Niðurhala sem: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(breytt)",
"YouTube comment permalink": "Varanlegur tengill á YouTube-ummæli",
"YouTube comment permalink": "YouTube ummæli varanlegur tengill",
"permalink": "Varanlegur tengill",
"`x` marked it with a ❤": "`x` merkti það með ❤",
"Audio mode": "Hljóðhamur",
"Video mode": "Myndhamur",
"Audio mode": "Hljóð ham",
"Video mode": "Myndband ham",
"channel_tab_videos_label": "Myndskeið",
"Playlists": "Spilunarlistar",
"channel_tab_community_label": "Samfélag",
@@ -388,7 +388,7 @@
"crash_page_before_reporting": "Áður en þú tilkynnir villu, gakktu úr skugga um að þú hafir:",
"crash_page_switch_instance": "reynt að <a href=\"`x`\">nota annað tilvik</a>",
"crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að <a href=\"`x`\">opna nýja verkbeiðni (issue) á GitHub</a> (helst á ensku) og láta fylgja eftirfarandi texta í skilaboðunum þínum (alls EKKI þýða þennan texta):",
"channel_tab_shorts_label": "Símamyndir",
"channel_tab_shorts_label": "Stuttmyndir",
"carousel_slide": "Skyggna {{current}} af {{total}}",
"carousel_go_to": "Fara á skyggnu `x`",
"channel_tab_streams_label": "Bein streymi",
@@ -401,8 +401,8 @@
"English (United Kingdom)": "Enska (Bretland)",
"English (United States)": "Enska (Bandarísk)",
"Vietnamese (auto-generated)": "Víetnamska (sjálfvirkt útbúið)",
"generic_count_months": "{{count}} mánuði",
"generic_count_months_plural": "{{count}} mánuðum",
"generic_count_months": "{{count}} mánuður",
"generic_count_months_plural": "{{count}} mánuðir",
"search_filters_sort_option_rating": "Einkunn",
"videoinfo_youTube_embed_link": "Ívefja",
"error_video_not_in_playlist": "Umbeðið myndskeið fyrirfinnst ekki í þessum spilunarlista. <a href=\"`x`\">Smelltu hér til að fara á heimasíðu spilunarlistans.</a>",
@@ -429,11 +429,11 @@
"Spanish (auto-generated)": "Spænska (sjálfvirkt útbúið)",
"Spanish (Mexico)": "Spænska (Mexíkó)",
"generic_count_hours": "{{count}} klukkustund",
"generic_count_hours_plural": "{{count}} klukkustundum",
"generic_count_years": "{{count}} ári",
"generic_count_years_plural": "{{count}} árum",
"generic_count_weeks": "{{count}} viku",
"generic_count_weeks_plural": "{{count}} vikum",
"generic_count_hours_plural": "{{count}} klukkustundir",
"generic_count_years": "{{count}} ár",
"generic_count_years_plural": "{{count}} ár",
"generic_count_weeks": "{{count}} vika",
"generic_count_weeks_plural": "{{count}} vikur",
"search_filters_date_option_none": "Hvaða dagsetning sem er",
"Channel Sponsor": "Styrktaraðili rásar",
"search_filters_date_option_week": "Í þessari viku",
@@ -476,8 +476,8 @@
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"Korean (auto-generated)": "Kóreska (sjálfvirkt útbúið)",
"generic_count_days": "{{count}} degi",
"generic_count_days_plural": "{{count}} dögum",
"generic_count_days": "{{count}} dagur",
"generic_count_days_plural": "{{count}} dagar",
"search_filters_date_option_today": "Í dag",
"search_filters_type_label": "Tegund",
"search_filters_type_option_all": "Hvaða tegund sem er",
@@ -498,8 +498,5 @@
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)",
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)",
"channel_tab_posts_label": "Færslur",
"First page": "Fyrsta síða",
"channel_tab_courses_label": "Kennsluefni"
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
}

View File

@@ -515,8 +515,5 @@
"carousel_skip": "Salta la galleria",
"carousel_go_to": "Vai al fotogramma `x`",
"preferences_preload_label": "Precarica dati video: ",
"Filipino (auto-generated)": "Filippino (generati automaticamente)",
"First page": "Prima pagina",
"channel_tab_courses_label": "Corsi",
"channel_tab_posts_label": "Post"
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
}

View File

@@ -25,7 +25,7 @@
"No": "いいえ",
"Import and Export Data": "データのインポートとエクスポート",
"Import": "インポート",
"Import Invidious data": "Invidious JSON データをインポート",
"Import Invidious data": "Invidious JSONデータをインポート",
"Import YouTube subscriptions": "YouTube/OPML 登録チャンネルをインポート",
"Import FreeTube subscriptions (.db)": "FreeTube 登録チャンネルをインポート (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe 登録チャンネルをインポート (.json)",
@@ -68,7 +68,7 @@
"preferences_related_videos_label": "関連動画を表示: ",
"preferences_annotations_label": "最初からアノテーションを表示: ",
"preferences_extend_desc_label": "動画の説明文を自動的に拡張: ",
"preferences_vr_mode_label": "対話的な 360° 動画 (WebGL が必要): ",
"preferences_vr_mode_label": "対話的な360°動画 (WebGLが必要): ",
"preferences_category_visual": "外観設定",
"preferences_player_style_label": "プレイヤーのスタイル: ",
"Dark mode: ": "ダークモード: ",
@@ -77,7 +77,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: ": "ホームからフィードにリダイレクト: ",
@@ -125,7 +125,7 @@
"subscriptions_unseen_notifs_count_0": "{{count}}件の未読通知",
"search": "検索",
"Log out": "ログアウト",
"Released under the AGPLv3 on Github.": "GitHub 上で AGPLv3 の元で公開",
"Released under the AGPLv3 on Github.": "GitHub上でAGPLv3の元で公開",
"Source available here.": "ソースはここで閲覧可能です。",
"View JavaScript license information.": "JavaScriptライセンス情報",
"View privacy policy.": "個人情報保護方針",
@@ -143,8 +143,8 @@
"Editing playlist `x`": "再生リスト `x` を編集中",
"Show more": "もっと見る",
"Show less": "表示を少なく",
"Watch on YouTube": "YouTube で視聴",
"Switch Invidious Instance": "Invidious インスタンスの変更",
"Watch on YouTube": "YouTubeで視聴",
"Switch Invidious Instance": "Invidiousインスタンスの変更",
"Hide annotations": "アノテーションを隠す",
"Show annotations": "アノテーションを表示",
"Genre: ": "ジャンル: ",
@@ -330,7 +330,7 @@
"(edited)": "(編集済み)",
"YouTube comment permalink": "YouTube コメントのパーマリンク",
"permalink": "パーマリンク",
"`x` marked it with a ❤": "`x` がを送りました",
"`x` marked it with a ❤": "`x` がを送りました",
"Audio mode": "音声モード",
"Video mode": "動画モード",
"channel_tab_videos_label": "動画",
@@ -343,7 +343,7 @@
"search_filters_type_label": "種類",
"search_filters_duration_label": "再生時間",
"search_filters_features_label": "特徴",
"search_filters_sort_label": "並べ替え",
"search_filters_sort_label": "順番",
"search_filters_date_option_hour": "1時間以内",
"search_filters_date_option_today": "今日",
"search_filters_date_option_week": "今週",
@@ -365,13 +365,13 @@
"Current version: ": "現在のバージョン: ",
"next_steps_error_message": "以下をお試しください: ",
"next_steps_error_message_refresh": "再読み込み",
"next_steps_error_message_go_to_youtube": "YouTube を開く",
"next_steps_error_message_go_to_youtube": "YouTubeを開く",
"search_filters_duration_option_short": "4分未満",
"footer_documentation": "説明書",
"footer_source_code": "ソースコード",
"footer_original_source_code": "元のソースコード",
"footer_modfied_source_code": "改変し使用",
"adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリの URL",
"footer_modfied_source_code": "改変し使用",
"adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL",
"search_filters_duration_option_long": "20分以上",
"preferences_region_label": "地域: ",
"footer_donate_page": "寄付する",
@@ -399,7 +399,7 @@
"preferences_quality_dash_option_worst": "最低",
"preferences_quality_dash_option_best": "最高",
"videoinfo_started_streaming_x_ago": "`x`前に配信を開始",
"videoinfo_watch_on_youTube": "YouTube で視聴",
"videoinfo_watch_on_youTube": "YouTubeで視聴",
"user_created_playlists": "`x`個の作成した再生リスト",
"Video unavailable": "動画は利用できません",
"Chinese": "中国語",
@@ -446,7 +446,7 @@
"search_filters_duration_option_medium": "4 20分",
"preferences_save_player_pos_label": "再生位置を保存: ",
"crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。",
"crash_page_report_issue": "上記が助けにならない場合、<a href=\"`x`\">GitHub</a> に新しい issue を作成し (できれば英語で) 、メッセージに次のテキストを含めてください (テキストは翻訳しない) 。",
"crash_page_report_issue": "上記が助けにならないなら、<a href=\"`x`\">GitHub</a> に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてくださいテキストは翻訳しない。",
"crash_page_search_issue": "<a href=\"`x`\">GitHub の既存の問題 (issue)</a> を検索",
"channel_tab_streams_label": "ライブ",
"channel_tab_playlists_label": "再生リスト",
@@ -481,8 +481,5 @@
"carousel_skip": "画像のスライド表示をスキップ",
"toggle_theme": "テーマの切り替え",
"preferences_preload_label": "動画データを事前に読み込む: ",
"Filipino (auto-generated)": "フィリピノ語 (自動生成)",
"First page": "最初のページ",
"channel_tab_posts_label": "投稿",
"channel_tab_courses_label": "コース"
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
}

View File

@@ -419,7 +419,7 @@
"Portuguese (Brazil)": "포르투갈어 (브라질)",
"search_message_no_results": "결과가 없습니다.",
"search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.",
"search_message_use_another_instance": "<a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
"search_message_use_another_instance": " <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
"English (United States)": "영어 (미국)",
"Chinese": "중국어",
"Chinese (China)": "중국어 (중국)",
@@ -480,9 +480,5 @@
"Search for videos": "비디오 검색",
"toggle_theme": "테마 전환",
"carousel_slide": "{{total}}의 슬라이드 {{current}}",
"preferences_preload_label": "비디오 데이터 사전 로드: ",
"First page": "첫 페이지",
"Filipino (auto-generated)": "Filipino (auto-generated)",
"channel_tab_posts_label": "게시글",
"channel_tab_courses_label": "코스"
"preferences_preload_label": "비디오 데이터 사전 로드: "
}

View File

@@ -1,69 +0,0 @@
{
"generic_channels_count_0": "{{count}} kanāli",
"generic_channels_count_1": "{{count}} kanāls",
"generic_channels_count_2": "{{count}} kanāli",
"Add to playlist": "Pievienot atskaņošanas sarakstam",
"Answer": "Atbildēt",
"generic_subscribers_count_0": "{{count}} abonenti",
"generic_subscribers_count_1": "{{count}} abonents",
"generic_subscribers_count_2": "{{count}} abonenti",
"generic_button_delete": "Dzēst",
"generic_button_edit": "Rediģēt",
"generic_button_save": "Saglabāt",
"generic_button_cancel": "Atcelt",
"generic_button_rss": "RSS",
"Unsubscribe": "Pārtraukt abonementu",
"View playlist on YouTube": "Skatīt atskaņošanas sarakstu YouTube vietnē",
"New password": "Jaunā parole",
"Yes": "Jā",
"No": "Nē",
"Import and Export Data": "Ievietot un izgūt datus",
"Import": "Ievietot",
"Import Invidious data": "Ievietot Invidious JSON datus",
"Delete account?": "Vai dzēst kontu?",
"History": "Vēsture",
"User ID": "Lietotāja ID",
"Password": "Parole",
"Import YouTube subscriptions": "Ievietot YouTube CSV vai OPML abonementus",
"E-mail": "E-pasts",
"Preferences": "Iestatījumi",
"preferences_category_player": "Atskaņotāja iestatījumi",
"preferences_quality_option_hd720": "HD - 720p",
"preferences_quality_option_medium": "Vidēja",
"preferences_quality_dash_option_worst": "Vissliktākā",
"preferences_quality_dash_option_2160p": "2160p (4K)",
"preferences_quality_dash_option_1080p": "1080p (Full HD)",
"preferences_quality_dash_option_720p": "720p (HD)",
"preferences_quality_dash_option_1440p": "1440p (2.5K, QHD)",
"preferences_quality_dash_option_480p": "480p (SD)",
"preferences_quality_dash_option_360p": "360p",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
"preferences_volume_label": "Atskaņošanas skaļums: ",
"reddit": "Reddit",
"invidious": "Invidious",
"Bangla": "Bengāļu",
"Basque": "Basku",
"Cebuano": "Sebuāņu",
"Chinese (Traditional)": "Ķīniešu (tradicionālā)",
"Corsican": "Korsikāņu",
"Croatian": "Horvātu",
"Galician": "Galisiešu",
"Georgian": "Gruzīnu",
"Gujarati": "Gudžaratu",
"German": "Vācu",
"Greek": "Grieķu",
"Haitian Creole": "Haitiešu",
"Hausa": "Hausu",
"Hawaiian": "Havajiešu",
"Export data as JSON": "Izgūt Invidious datus JSON formātā",
"preferences_quality_dash_option_4320p": "4320p (8K)",
"Time (h:mm:ss):": "Laiks (h:mm:ss):",
"Chinese (Simplified)": "Ķīniešu (vienkāršotā)",
"preferences_quality_dash_option_best": "Vislabākā",
"preferences_quality_option_small": "Zema",
"youtube": "YouTube",
"Add to playlist: ": "Pievienot atskaņošanas sarakstam: ",
"Subscribe": "Abonēt",
"View channel on YouTube": "Skatīt kanālu YouTube vietnē"
}

View File

@@ -498,8 +498,5 @@
"carousel_skip": "Carousel overslaan",
"toggle_theme": "Thema omschakelen",
"preferences_preload_label": "Videogegevens vooraf laden: ",
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)",
"channel_tab_courses_label": "Cursussen",
"First page": "Eerste pagina",
"channel_tab_posts_label": "Gepost"
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
}

View File

@@ -515,8 +515,5 @@
"carousel_skip": "Pomiń karuzelę",
"carousel_go_to": "Przejdź do slajdu `x`",
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)",
"First page": "Pierwsza strona",
"channel_tab_posts_label": "Posty",
"channel_tab_courses_label": "Kursy"
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
}

View File

@@ -515,8 +515,5 @@
"carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir ao slide `x`",
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
"channel_tab_posts_label": "Postagens",
"First page": "Primeira página",
"channel_tab_courses_label": "Cursos"
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
}

View File

@@ -1,27 +1,27 @@
{
"LIVE": "Direto",
"LIVE": "Em direto",
"Shared `x` ago": "Partilhado `x` atrás",
"Unsubscribe": "Anular subscrição",
"Subscribe": "Subscrever",
"View channel on YouTube": "Ver canal no YouTube",
"View playlist on YouTube": "Ver lista de reprodução no YouTube",
"newest": "recentes",
"oldest": "antigos",
"popular": "populares",
"newest": "mais recentes",
"oldest": "mais antigos",
"popular": "popular",
"last": "últimos",
"Next page": "Página seguinte",
"Next page": "Próxima página",
"Previous page": "Página anterior",
"Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova palavra-passe",
"New passwords must match": "As novas palavras-passe devem ser iguais",
"Authorize token?": "Autorizar 'token'?",
"Authorize token for `x`?": "Autorizar 'token' para `x`?",
"New password": "Nova palavra-chave",
"New passwords must match": "As novas palavra-chaves devem corresponder",
"Authorize token?": "Autorizar token?",
"Authorize token for `x`?": "Autorizar token para `x`?",
"Yes": "Sim",
"No": "Não",
"Import and Export Data": "Importar e exportar dados",
"Import": "Importar",
"Import Invidious data": "Importar dados JSON do Invidious",
"Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML",
"Import YouTube subscriptions": "Importar subscrições do YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
@@ -32,38 +32,38 @@
"Delete account?": "Eliminar conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
"JavaScript license information": "Informação da licença JavaScript",
"source": "fonte",
"JavaScript license information": "Informação de licença do JavaScript",
"source": "código-fonte",
"Log in": "Iniciar sessão",
"Log in/register": "Iniciar sessão/registar",
"User ID": "Utilizador",
"Password": "Palavra-passe",
"Password": "Palavra-chave",
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
"Text CAPTCHA": "Texto CAPTCHA",
"Image CAPTCHA": "Imagem CAPTCHA",
"Sign In": "Entrar",
"Sign In": "Iniciar sessão",
"Register": "Registar",
"E-mail": "E-mail",
"Preferences": "Preferências",
"preferences_category_player": "Preferências do reprodutor",
"preferences_video_loop_label": "Repetir sempre: ",
"preferences_autoplay_label": "Reprodução automática: ",
"preferences_continue_label": "Reproduzir sempre o seguinte: ",
"preferences_continue_label": "Reproduzir sempre o próximo: ",
"preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ",
"preferences_listen_label": "Apenas áudio: ",
"preferences_local_label": "Usar proxy nos vídeos: ",
"preferences_speed_label": "Velocidade preferida: ",
"preferences_quality_label": "Qualidade de vídeo preferida: ",
"preferences_volume_label": "Volume de reprodução: ",
"preferences_comments_label": "Comentários padrão: ",
"preferences_volume_label": "Volume da reprodução: ",
"preferences_comments_label": "Preferência dos comentários: ",
"youtube": "YouTube",
"reddit": "Reddit",
"preferences_captions_label": "Legendas padrão: ",
"preferences_captions_label": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ",
"preferences_related_videos_label": "Mostrar vídeos relacionados: ",
"preferences_annotations_label": "Mostrar anotações sempre: ",
"preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ",
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ",
"preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ",
"preferences_category_visual": "Preferências visuais",
"preferences_player_style_label": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ",
@@ -74,9 +74,9 @@
"preferences_category_misc": "Preferências diversas",
"preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ",
"preferences_category_subscription": "Preferências de subscrições",
"preferences_annotations_subscribed_label": "Mostrar sempre anotações nos canais subscritos: ",
"preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
"preferences_max_results_label": "Número de vídeos nas subscrições: ",
"preferences_max_results_label": "Quantidade de vídeos nas subscrições: ",
"preferences_sort_label": "Ordenar vídeos por: ",
"published": "publicado",
"published - reverse": "publicado - inverso",
@@ -88,19 +88,19 @@
"Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
"preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ",
"preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ",
"Enable web notifications": "Ativar notificações web",
"`x` uploaded a video": "`x` publicou um vídeo",
"Enable web notifications": "Ativar notificações pela web",
"`x` uploaded a video": "`x` publicou um novo vídeo",
"`x` is live": "`x` está em direto",
"preferences_category_data": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução",
"Import/export data": "Importar/exportar dados",
"Change password": "Alterar palavra-passe",
"Manage subscriptions": "Gerir subscrições",
"Import/export data": "Importar / exportar dados",
"Change password": "Alterar palavra-chave",
"Manage subscriptions": "Gerir as subscrições",
"Manage tokens": "Gerir tokens",
"Watch history": "Histórico de reprodução",
"Delete account": "Eliminar conta",
"preferences_category_admin": "Preferências de administrador",
"preferences_default_home_label": "Página inicial padrão: ",
"preferences_default_home_label": "Página inicial predefinida: ",
"preferences_feed_menu_label": "Menu de subscrições: ",
"preferences_show_nick_label": "Mostrar nome de utilizador em cima: ",
"Top enabled: ": "Destaques ativados: ",
@@ -109,29 +109,28 @@
"Registration enabled: ": "Registar ativado: ",
"Report statistics: ": "Relatório de estatísticas: ",
"Save preferences": "Guardar preferências",
"Subscription manager": "Gestor de subscrições",
"Token manager": "Gestor de tokens",
"Subscription manager": "Gerir subscrições",
"Token manager": "Gerir tokens",
"Token": "Token",
"tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokens",
"tokens_count_2": "{{count}} tokens",
"Import/export": "Importar/exportar",
"tokens_count": "{{count}} token",
"tokens_count_plural": "{{count}} tokens",
"Import/export": "Importar / exportar",
"unsubscribe": "anular subscrição",
"revoke": "revogar",
"Subscriptions": "Subscrições",
"search": "pesquisar",
"Log out": "Terminar sessão",
"Released under the AGPLv3 on Github.": "Disponibilizada sob a AGPLv3 no GitHub.",
"Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.",
"Source available here.": "Código-fonte disponível aqui.",
"View JavaScript license information.": "Ver informações da licença JavaScript.",
"View privacy policy.": "Ver política de privacidade.",
"View JavaScript license information.": "Ver informações da licença do JavaScript.",
"View privacy policy.": "Ver a política de privacidade.",
"Trending": "Tendências",
"Public": "Público",
"Unlisted": "Não listado",
"Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x`",
"Delete playlist `x`?": "Eliminar lista de reprodução `x`?",
"Updated `x` ago": "Atualizado `x` atrás",
"Delete playlist `x`?": "Eliminar a lista de reprodução `x`?",
"Delete playlist": "Eliminar lista de reprodução",
"Create playlist": "Criar lista de reprodução",
"Title": "Título",
@@ -140,7 +139,7 @@
"Show more": "Mostrar mais",
"Show less": "Mostrar menos",
"Watch on YouTube": "Ver no YouTube",
"Switch Invidious Instance": "Alterar instância Invidious",
"Switch Invidious Instance": "Mudar a instância do Invidious",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Género: ",
@@ -151,27 +150,27 @@
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Partilhado `x`",
"Premieres in `x`": "Estreia a `x`",
"Premieres `x`": "Estreia `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.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, mas tenha e conta que podem levar mais tempo para carregar.",
"Premieres in `x`": "Estreias em `x`",
"Premieres `x`": "Estreias `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.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário",
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários",
"": "Ver `x` comentários"
},
"View Reddit comments": "Ver comentários do Reddit",
"Hide replies": "Ocultar respostas",
"Show replies": "Mostrar respostas",
"Incorrect password": "Palavra-passe incorreta",
"Incorrect password": "Palavra-chave incorreta",
"Wrong answer": "Resposta errada",
"Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
"User ID is a required field": "O nome de utilizador é um campo obrigatório",
"Password is a required field": "Palavra-passe é um campo obrigatório",
"Wrong username or password": "Nome de utilizador ou palavra-passe incorreta",
"Password cannot be empty": "A palavra-passe não pode estar vazia",
"Password cannot be longer than 55 characters": "A palavra-passe não pode ter mais do que 55 caracteres",
"Password is a required field": "Palavra-chave é um campo obrigatório",
"Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
"Password cannot be empty": "A palavra-chave não pode estar vazia",
"Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
"Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal:`x`",
@@ -181,20 +180,20 @@
"Could not fetch comments": "Não foi possível obter os comentários",
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
"Could not create mix.": "Não foi possível criar o mix.",
"Could not create mix.": "Não foi possível criar a mistura.",
"Empty playlist": "Lista de reprodução vazia",
"Not a playlist.": "Não é uma lista de reprodução.",
"Playlist does not exist.": "A lista de reprodução não existe.",
"Could not pull trending pages.": "Não foi possível obter a página de tendências.",
"Could not pull trending pages.": "Não foi possível obter as páginas de tendências.",
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
"Erroneous challenge": "Desafio inválido",
"Erroneous token": "Token inválido",
"No such user": "Utilizador inválido",
"Token is expired, please try again": "Token caducado, tente novamente",
"Token is expired, please try again": "Token expirou, tente novamente",
"English": "Inglês",
"English (auto-generated)": "Inglês (auto-gerado)",
"Afrikaans": "Africânder",
"Afrikaans": "Africano",
"Albanian": "Albanês",
"Amharic": "Amárico",
"Arabic": "Árabe",
@@ -210,7 +209,7 @@
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Chinês (simplificado)",
"Chinese (Traditional)": "Chinês (tradicional)",
"Corsican": "Córsego",
"Corsican": "Corso",
"Croatian": "Croata",
"Czech": "Checo",
"Danish": "Dinamarquês",
@@ -253,7 +252,7 @@
"Macedonian": "Macedónio",
"Malagasy": "Malgaxe",
"Malay": "Malaio",
"Malayalam": "Malaialaio",
"Malayalam": "Malaiala",
"Maltese": "Maltês",
"Maori": "Maori",
"Marathi": "Marathi",
@@ -298,37 +297,30 @@
"Yiddish": "Iídiche",
"Yoruba": "Ioruba",
"Zulu": "Zulu",
"generic_count_years_0": "{{count}} ano",
"generic_count_years_1": "{{count}} anos",
"generic_count_years_2": "{{count}} anos",
"generic_count_months_0": "{{count}} mês",
"generic_count_months_1": "{{count}} meses",
"generic_count_months_2": "{{count}} meses",
"generic_count_weeks_0": "{{count}} semana",
"generic_count_weeks_1": "{{count}} semanas",
"generic_count_weeks_2": "{{count}} semanas",
"generic_count_days_0": "{{count}} dia",
"generic_count_days_1": "{{count}} dias",
"generic_count_days_2": "{{count}} dias",
"generic_count_hours_0": "{{count}} hora",
"generic_count_hours_1": "{{count}} horas",
"generic_count_hours_2": "{{count}} horas",
"generic_count_minutes_0": "{{count}} minuto",
"generic_count_minutes_1": "{{count}} minutos",
"generic_count_minutes_2": "{{count}} minutos",
"generic_count_seconds_0": "{{count}} segundo",
"generic_count_seconds_1": "{{count}} segundos",
"generic_count_seconds_2": "{{count}} segundos",
"Fallback comments: ": "Alternativa para comentários: ",
"generic_count_years": "{{count}} ano",
"generic_count_years_plural": "{{count}} anos",
"generic_count_months": "{{count}} s",
"generic_count_months_plural": "{{count}} meses",
"generic_count_weeks": "{{count}} seman",
"generic_count_weeks_plural": "{{count}} semanas",
"generic_count_days": "{{count}} dia",
"generic_count_days_plural": "{{count}} dias",
"generic_count_hours": "{{count}} hora",
"generic_count_hours_plural": "{{count}} horas",
"generic_count_minutes": "{{count}} minuto",
"generic_count_minutes_plural": "{{count}} minutos",
"generic_count_seconds": "{{count}} segundo",
"generic_count_seconds_plural": "{{count}} segundos",
"Fallback comments: ": "Comentários alternativos: ",
"Popular": "Popular",
"Search": "Pesquisar",
"Top": "Destaques",
"About": "Acerca",
"About": "Sobre",
"Rating: ": "Avaliação: ",
"preferences_locale_label": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
"Default": "Padrão",
"Music": "Músicas",
"Default": "Predefinido",
"Music": "Música",
"Gaming": "Jogos",
"News": "Notícias",
"Movies": "Filmes",
@@ -336,9 +328,9 @@
"Download as: ": "Descarregar como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Ligação permanente do comentário no YouTube",
"permalink": "ligação permanente",
"`x` marked it with a ❤": "`x` foi marcado com um ❤",
"YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
"permalink": "hiperligação permanente",
"`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo",
"channel_tab_videos_label": "Vídeos",
@@ -346,7 +338,7 @@
"channel_tab_community_label": "Comunidade",
"search_filters_sort_option_relevance": "Relevância",
"search_filters_sort_option_rating": "Avaliação",
"search_filters_sort_option_date": "Data de carregamento",
"search_filters_sort_option_date": "Data de envio",
"search_filters_sort_option_views": "Visualizações",
"search_filters_type_label": "Tipo",
"search_filters_duration_label": "Duração",
@@ -361,44 +353,38 @@
"search_filters_type_option_channel": "Canal",
"search_filters_type_option_playlist": "Lista de reprodução",
"search_filters_type_option_movie": "Filme",
"search_filters_type_option_show": "Séries",
"search_filters_type_option_show": "Espetáculo",
"search_filters_features_option_hd": "HD",
"search_filters_features_option_subtitles": "Legendas",
"search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_three_d": "3D",
"search_filters_features_option_live": "Direto",
"search_filters_features_option_live": "Em direto",
"search_filters_features_option_four_k": "4K",
"search_filters_features_option_location": "Localização",
"search_filters_features_option_hdr": "HDR",
"Current version: ": "Versão atual: ",
"next_steps_error_message": "Pode tentar as seguintes opções: ",
"next_steps_error_message_refresh": "Recarregar",
"next_steps_error_message_go_to_youtube": "Ir para o YouTube",
"next_steps_error_message_refresh": "Atualizar",
"next_steps_error_message_go_to_youtube": "Ir ao YouTube",
"search_filters_title": "Filtro",
"generic_videos_count_0": "{{count}} vídeo",
"generic_videos_count_1": "{{count}} vídeos",
"generic_videos_count_2": "{{count}} vídeos",
"generic_playlists_count_0": "{{count}} lista de reprodução",
"generic_playlists_count_1": "{{count}} listas de reprodução",
"generic_playlists_count_2": "{{count}} listas de reprodução",
"generic_subscriptions_count_0": "{{count}} subscrição",
"generic_subscriptions_count_1": "{{count}} subscrições",
"generic_subscriptions_count_2": "{{count}} subscrições",
"generic_views_count_0": "{{count}} visualização",
"generic_views_count_1": "{{count}} visualizações",
"generic_views_count_2": "{{count}} visualizações",
"generic_subscribers_count_0": "{{count}} subscritor",
"generic_subscribers_count_1": "{{count}} subscritores",
"generic_subscribers_count_2": "{{count}} subscritores",
"generic_videos_count": "{{count}} vídeo",
"generic_videos_count_plural": "{{count}} vídeos",
"generic_playlists_count": "{{count}} lista de reprodução",
"generic_playlists_count_plural": "{{count}} listas de reprodução",
"generic_subscriptions_count": "{{count}} inscrição",
"generic_subscriptions_count_plural": "{{count}} inscrições",
"generic_views_count": "{{count}} visualização",
"generic_views_count_plural": "{{count}} visualizações",
"generic_subscribers_count": "{{count}} inscrito",
"generic_subscribers_count_plural": "{{count}} inscritos",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
"preferences_quality_dash_option_2160p": "2160p",
"subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista",
"subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas",
"subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas",
"subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
"subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
"Popular enabled: ": "Página \"popular\" ativada: ",
"search_message_no_results": "Nenhum resultado encontrado.",
"preferences_quality_dash_option_auto": "Automática",
"preferences_quality_dash_option_auto": "Automático",
"preferences_region_label": "País do conteúdo: ",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_720p": "720p",
@@ -417,12 +403,10 @@
"preferences_quality_dash_option_240p": "240p",
"Video unavailable": "Vídeo não disponível",
"Russian (auto-generated)": "Russo (gerado automaticamente)",
"comments_view_x_replies_0": "Ver {{count}} resposta",
"comments_view_x_replies_1": "Ver {{count}} respostas",
"comments_view_x_replies_2": "Ver {{count}} respostas",
"comments_points_count_0": "{{count}} ponto",
"comments_points_count_1": "{{count}} pontos",
"comments_points_count_2": "{{count}} pontos",
"comments_view_x_replies": "Ver {{count}} resposta",
"comments_view_x_replies_plural": "Ver {{count}} respostas",
"comments_points_count": "{{count}} ponto",
"comments_points_count_plural": "{{count}} pontos",
"English (United Kingdom)": "Inglês (Reino Unido)",
"Chinese (Hong Kong)": "Chinês (Hong Kong)",
"Chinese (Taiwan)": "Chinês (Taiwan)",
@@ -448,13 +432,13 @@
"videoinfo_watch_on_youTube": "Ver no YouTube",
"videoinfo_youTube_embed_link": "Incorporar",
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
"videoinfo_invidious_embed_link": "Incorporar ligação",
"videoinfo_invidious_embed_link": "Incorporar hiperligação",
"none": "nenhum",
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
"download_subtitles": "Legendas - `x` (.vtt)",
"user_created_playlists": "`x` listas de reprodução criadas",
"user_saved_playlists": "`x` listas de reprodução guardadas",
"preferences_save_player_pos_label": "Guardar posição de reprodução: ",
"preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ",
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
"Chinese (China)": "Chinês (China)",
@@ -471,52 +455,21 @@
"search_filters_date_option_none": "Qualquer data",
"search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180",
"search_message_use_another_instance": "Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
"search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
"crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
"crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
"crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
"crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>",
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO o traduza):",
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
"crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
"crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
"error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para voltar à 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": "Emissões em direto",
"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",
"generic_button_delete": "Eliminar",
"generic_button_edit": "Editar",
"generic_button_save": "Guardar",
"generic_button_cancel": "Cancelar",
"Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)",
"Song: ": "Canção: ",
"Answer": "Responder",
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
"Channel Sponsor": "Patrocinador do canal",
"Download is disabled": "A descarga está desativada",
"Add to playlist": "Adicionar à lista de reprodução",
"Add to playlist: ": "Adicionar à lista de reprodução: ",
"Search for videos": "Procurar vídeos",
"generic_channels_count_0": "{{count}} canal",
"generic_channels_count_1": "{{count}} canais",
"generic_channels_count_2": "{{count}} canais",
"generic_button_rss": "RSS",
"Import YouTube watch history (.json)": "Importar histórico de reprodução do YouTube (.json)",
"preferences_preload_label": "Pré-carregamento dos dados: ",
"playlist_button_add_items": "Adicionar vídeos",
"channel_tab_podcasts_label": "Podcasts",
"channel_tab_releases_label": "Lançamentos",
"carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir para o diapositivo`x`",
"First page": "Primeira página",
"Standard YouTube license": "Licença padrão do YouTube",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
"channel_tab_courses_label": "Cursos",
"channel_tab_posts_label": "Publicações",
"toggle_theme": "Trocar tema"
"channel_tab_shorts_label": "Curtos"
}

View File

@@ -515,8 +515,5 @@
"carousel_go_to": "Ir para o diapositivo`x`",
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
"preferences_preload_label": "Pré-carregamento dos dados: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
"First page": "Primeira página",
"channel_tab_courses_label": "Cursos",
"channel_tab_posts_label": "Publicações"
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
}

View File

@@ -475,7 +475,7 @@
"search_filters_date_option_none": "Любая дата",
"search_filters_date_label": "Дата загрузки",
"search_message_no_results": "Ничего не найдено.",
"search_message_use_another_instance": "Дополнительно вы можете <a href=\"`x`\">поискать на других зеркалах</a>.",
"search_message_use_another_instance": " Дополнительно вы можете <a href=\"`x`\">поискать на других зеркалах</a>.",
"search_filters_features_option_vr180": "VR180",
"search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос и/или изменить фильтры.",
"search_filters_duration_option_medium": "Средние (4 - 20 минут)",
@@ -515,7 +515,5 @@
"carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё",
"carousel_go_to": "Перейти к странице `x`",
"preferences_preload_label": "Предзагрузка видеоданных: ",
"channel_tab_courses_label": "Курсы",
"channel_tab_posts_label": "Записи"
"preferences_preload_label": "Предзагрузка видеоданных: "
}

View File

@@ -494,9 +494,5 @@
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
"carousel_go_to": "Kalo te diapozitivi `x`",
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
"preferences_preload_label": "Parangarko të dhëna videoje: ",
"toggle_theme": "Ndërroni Temë",
"channel_tab_courses_label": "Kurse",
"channel_tab_posts_label": "Postime",
"First page": "Faqja e parë"
"preferences_preload_label": "Parangarko të dhëna videoje: "
}

View File

@@ -513,10 +513,7 @@
"Answer": "Odgovor",
"Search for videos": "Pretražite video snimke",
"carousel_skip": "Preskoči karusel",
"toggle_theme": "Podesi temu",
"toggle_theme": "Подеси тему",
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
"Filipino (auto-generated)": "Filipinski (automatski generisano)",
"channel_tab_posts_label": "Objave",
"First page": "Prva stranica",
"channel_tab_courses_label": "Kursevi"
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
}

View File

@@ -515,8 +515,5 @@
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
"carousel_slide": "Слајд {{current}} од {{total}}",
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)",
"channel_tab_courses_label": "Курсеви",
"First page": "Прва страница",
"channel_tab_posts_label": "Објаве"
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
}

View File

@@ -498,8 +498,5 @@
"carousel_skip": "Hoppa över karusellen",
"carousel_go_to": "Gå till bildspel `x`",
"preferences_preload_label": "Förladda video data: ",
"Filipino (auto-generated)": "Filippinska (auto-genererad)",
"First page": "Första sidan",
"channel_tab_courses_label": "Kurser",
"channel_tab_posts_label": "Inlägg"
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
}

View File

@@ -497,9 +497,5 @@
"carousel_skip": "Kayar menüyü atla",
"carousel_go_to": "`x` sunumuna git",
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.",
"preferences_preload_label": "Video verilerini önceden yükle: ",
"First page": "İlk sayfa",
"Filipino (auto-generated)": "Filipince (oto-oluşturuldu)",
"channel_tab_courses_label": "Kurslar",
"channel_tab_posts_label": "Yazılar"
"preferences_preload_label": "Video verilerini önceden yükle: "
}

View File

@@ -515,8 +515,5 @@
"carousel_skip": "Пропустити карусель",
"carousel_go_to": "Перейти до слайда `x`",
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)",
"First page": "Перша сторінка",
"channel_tab_courses_label": "Курси",
"channel_tab_posts_label": "Дописи"
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
}

View File

@@ -314,11 +314,11 @@
"search_filters_duration_label": "Thời lượng",
"search_filters_features_label": "Đặc điểm",
"search_filters_sort_label": "Sắp xếp theo",
"search_filters_date_option_hour": "Một giờ trước",
"search_filters_date_option_hour": "Một giờ qua",
"search_filters_date_option_today": "Hôm nay",
"search_filters_date_option_week": "Tuần này",
"search_filters_date_option_month": "Tháng này",
"search_filters_date_option_year": "Năm nay",
"search_filters_date_option_year": "Năm này",
"search_filters_type_option_video": "video",
"search_filters_type_option_channel": "Kênh",
"search_filters_type_option_playlist": "Danh sách phát",
@@ -479,8 +479,5 @@
"carousel_skip": "Bỏ qua Carousel",
"carousel_go_to": "Đi tới trang `x`",
"Search for videos": "Tìm kiếm video",
"The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý.",
"preferences_preload_label": "Tải trước dữ liệu video: ",
"Filipino (auto-generated)": "Tiếng Philippines (tự động tạo)",
"First page": "Trang đầu"
"The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý."
}

View File

@@ -420,7 +420,7 @@
"Chinese": "中文",
"Chinese (China)": "中文 (中国)",
"Chinese (Hong Kong)": "中文 (中国香港)",
"Chinese (Taiwan)": "中文 (台湾)",
"Chinese (Taiwan)": "中文 (中国台湾)",
"German (auto-generated)": "德语 (自动生成)",
"Indonesian (auto-generated)": "印尼语 (自动生成)",
"Interlingue": "国际语",
@@ -481,8 +481,5 @@
"carousel_skip": "跳过图集",
"carousel_go_to": "转到图 `x`",
"preferences_preload_label": "预加载视频数据: ",
"Filipino (auto-generated)": "菲律宾语 (自动生成)",
"channel_tab_posts_label": "帖子",
"First page": "第一页",
"channel_tab_courses_label": "课程"
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
}

View File

@@ -481,8 +481,5 @@
"carousel_go_to": "跳到投影片 `x`",
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。",
"preferences_preload_label": "預先載入影片資訊 ",
"Filipino (auto-generated)": "菲律賓語(自動產生)",
"channel_tab_courses_label": "課程",
"First page": "第一頁",
"channel_tab_posts_label": "貼文"
"Filipino (auto-generated)": "菲律賓語(自動產生)"
}

View File

@@ -1,56 +0,0 @@
# This file automatically generates Crystal strings of rows within an HTML Javascript licenses table
#
# These strings will then be placed within a `<%= %>` statement in licenses.ecr at compile time which
# will be interpolated at run-time. This interpolation is only for the translation of the "source" string
# so maybe we can just switch to a non-translated string to simplify the logic here.
#
# The Javascript Web Labels table defined at https://www.gnu.org/software/librejs/free-your-javascript.html#step3
# for example just reiterates the name of the source file rather than use a "source" string.
all_javascript_files = Dir.glob("assets/**/*.js")
videojs_js = [] of String
invidious_js = [] of String
all_javascript_files.each do |js_path|
if js_path.starts_with?("assets/videojs/")
videojs_js << js_path[7..]
else
invidious_js << js_path[7..]
end
end
def create_licence_tr(path, file_name, licence_name, licence_link, source_location)
tr = <<-HTML
"<tr>
<td><a href=\\"/#{path}\\">#{file_name}</a></td>
<td><a href=\\"#{licence_link}\\">#{licence_name}</a></td>
<td><a href=\\"#{source_location}\\">\#{translate(locale, "source")}</a></td>
</tr>"
HTML
# New lines are removed as to allow for using String.join and StringLiteral.split
# to get a clean list of each table row.
tr.gsub('\n', "")
end
# TODO Use videojs-dependencies.yml to generate license info for videojs javascript
jslicence_table_rows = [] of String
invidious_js.each do |path|
file_name = path.split('/')[-1]
# A couple non Invidious JS files are also shipped alongside Invidious due to various reasons
next if {
"sse.js", "silvermine-videojs-quality-selector.min.js", "videojs-youtube-annotations.min.js",
}.includes?(file_name)
jslicence_table_rows << create_licence_tr(
path: path,
file_name: file_name,
licence_name: "AGPL-3.0",
licence_link: "https://www.gnu.org/licenses/agpl-3.0.html",
source_location: path
)
end
puts jslicence_table_rows.join("\n")

View File

@@ -18,7 +18,7 @@ shards:
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.4.1
version: 0.2.2
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
@@ -26,7 +26,11 @@ shards:
kemal:
git: https://github.com/kemalcr/kemal.git
version: 1.6.0
version: 1.1.2
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.6.1
pg:
git: https://github.com/will/crystal-pg.git

View File

@@ -1,5 +1,5 @@
name: invidious
version: 2.20250517.0-dev
version: 2.20250314.0-dev
authors:
- Invidious team <contact@invidious.io>
@@ -17,7 +17,10 @@ dependencies:
version: ~> 0.21.0
kemal:
github: kemalcr/kemal
version: ~> 1.6.0
version: ~> 1.1.2
kilt:
github: jeromegn/kilt
version: ~> 0.6.1
protodec:
github: iv-org/protodec
version: ~> 0.1.5

View File

@@ -0,0 +1,112 @@
# Due to the way that specs are handled this file cannot be run
# together with everything else without causing a compile time error
#
# TODO: Allow running different isolated spec through make
#
# For now run this with `crystal spec -p spec/helpers/networking/connection_pool_spec.cr -Drunning_by_self`
{% skip_file unless flag?(:running_by_self) %}
# Based on https://github.com/jgaskins/http_client/blob/958cf56064c0d31264a117467022b90397eb65d7/spec/http_client_spec.cr
require "wait_group"
require "uri"
require "http"
require "http/server"
require "http_proxy"
require "db"
require "pg"
require "spectator"
require "../../load_config_helper"
require "../../../src/invidious/helpers/crystal_class_overrides"
require "../../../src/invidious/connection/*"
TEST_SERVER_URL = URI.parse("http://localhost:12345")
server = HTTP::Server.new do |context|
request = context.request
response = context.response
case {request.method, request.path}
when {"GET", "/get"}
response << "get"
when {"POST", "/post"}
response.status = :created
response << "post"
when {"GET", "/sleep"}
duration = request.query_params["duration_sec"].to_i.seconds
sleep duration
end
end
spawn server.listen 12345
Fiber.yield
Spectator.describe Invidious::ConnectionPool do
describe "Pool" do
it "Can make a requests through standard HTTP methods" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect(pool.get("/get").body).to eq("get")
expect(pool.post("/post").body).to eq("post")
end
it "Can make streaming requests" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect(pool.get("/get", &.body_io.gets_to_end)).to eq("get")
expect(pool.get("/post", &.body)).to eq("")
expect(pool.post("/post", &.body_io.gets_to_end)).to eq("post")
end
it "Allows more than one clients to be checked out (if applicable)" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
pool.checkout do |_|
expect(pool.post("/post").body).to eq("post")
end
end
it "Can make multiple requests with the same client" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
pool.checkout do |client|
expect(client.get("/get").body).to eq("get")
expect(client.post("/post").body).to eq("post")
expect(client.get("/get").body).to eq("get")
end
end
it "Allows concurrent requests" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
responses = [] of HTTP::Client::Response
WaitGroup.wait do |wg|
100.times do
wg.spawn { responses << pool.get("/get") }
end
end
expect(responses.map(&.body)).to eq(["get"] * 100)
end
it "Raises on checkout timeout" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 2, timeout: 0.01) { next make_client(TEST_SERVER_URL) }
# Long running requests
2.times do
spawn { pool.get("/sleep?duration_sec=2") }
end
Fiber.yield
expect { pool.get("/get") }.to raise_error(Invidious::ConnectionPool::Error)
end
it "Raises when an error is encountered" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect { pool.get("/get") { raise IO::Error.new } }.to raise_error(Invidious::ConnectionPool::Error)
end
end
end

View File

@@ -0,0 +1,15 @@
require "yaml"
require "log"
abstract class Kemal::BaseLogHandler
end
require "../src/invidious/config"
require "../src/invidious/jobs/base_job"
require "../src/invidious/jobs.cr"
require "../src/invidious/user/preferences.cr"
require "../src/invidious/helpers/logger"
require "../src/invidious/helpers/utils"
CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
HMAC_KEY = CONFIG.hmac_key

View File

@@ -0,0 +1,16 @@
# Overrides for Kemal's `content_for` macro in order to keep using
# kilt as it was before Kemal v1.1.1 (Kemal PR #618).
require "kemal"
require "kilt"
macro content_for(key, file = __FILE__)
%proc = ->() {
__kilt_io__ = IO::Memory.new
{{ yield }}
__kilt_io__.to_s
}
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc
nil
end

View File

@@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt
filesize = data.bytesize
attachment(env, filename, disposition)
Kemal.config.static_headers.try(&.call(env, file_path, filestat))
Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
file = IO::Memory.new(data)
if env.request.method == "GET" && env.request.headers.has_key?("Range")

View File

@@ -17,8 +17,10 @@
require "digest/md5"
require "file_utils"
# Require kemal, then our own overrides
# Require kemal, kilt, then our own overrides
require "kemal"
require "kilt"
require "./ext/kemal_content_for.cr"
require "./ext/kemal_static_file_handler.cr"
require "http_proxy"
@@ -33,6 +35,7 @@ require "protodec/utils"
require "./invidious/database/*"
require "./invidious/database/migrations/*"
require "./invidious/connection/*"
require "./invidious/http_server/*"
require "./invidious/helpers/*"
require "./invidious/yt_backend/*"
@@ -47,8 +50,7 @@ require "./invidious/channels/*"
require "./invidious/user/*"
require "./invidious/search/*"
require "./invidious/routes/**"
require "./invidious/jobs/base_job"
require "./invidious/jobs/*"
require "./invidious/jobs/**"
# Declare the base namespace for invidious
module Invidious
@@ -90,15 +92,31 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
YT_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(YT_URL, force_resolve: true)
end
# Image request pool
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
GGPHT_URL = URI.parse("https://yt3.ggpht.com")
COMPANION_POOL = CompanionConnectionPool.new(
capacity: CONFIG.pool_size
)
GGPHT_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(GGPHT_URL, force_resolve: true)
end
COMPANION_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
reinitialize_proxy: false
) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
end
# CLI
Kemal.config.extra_options do |parser|
@@ -225,8 +243,8 @@ error 500 do |env, ex|
error_template(500, ex)
end
static_headers do |env|
env.response.headers.add("Cache-Control", "max-age=2629800")
static_headers do |response|
response.headers.add("Cache-Control", "max-age=2629800")
end
# Init Kemal

View File

@@ -166,7 +166,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
}
LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
rss = YT_POOL.get("/feeds/videos.xml?channel_id=#{ucid}").body
LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
rss = XML.parse(rss)

View File

@@ -157,8 +157,13 @@ class Config
property host_binding : String = "0.0.0.0"
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
property socket_binding : SocketBindingConfig? = nil
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
# Max pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool)
property pool_size : Int32 = 100
# Amount of seconds to wait for a client to be free from the pool before rasing an error
property pool_checkout_timeout : Float64 = 5
# HTTP Proxy configuration
property http_proxy : HTTPProxyConfig? = nil

View File

@@ -0,0 +1,53 @@
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
# Force the usage of a specific configured IP Family
if force_resolve
client.family = CONFIG.force_resolve
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
return client
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
begin
yield client
ensure
client.close
end
end
def make_configured_http_proxy_client
# This method is only called when configuration for an HTTP proxy are set
config_proxy = CONFIG.http_proxy.not_nil!
return HTTP::Proxy::Client.new(
config_proxy.host,
config_proxy.port,
username: config_proxy.user,
password: config_proxy.password,
)
end

View File

@@ -0,0 +1,116 @@
module Invidious::ConnectionPool
# A connection pool to reuse `HTTP::Client` connections
struct Pool
getter pool : DB::Pool(HTTP::Client)
# Creates a connection pool with the provided options, and client factory block.
def initialize(
*,
max_capacity : Int32 = 5,
timeout : Float64 = 5.0,
@reinitialize_proxy : Bool = true, # Whether or not http-proxy should be reinitialized on checkout
&client_factory : -> HTTP::Client
)
pool_options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: max_capacity,
max_idle_pool_size: max_capacity,
checkout_timeout: timeout
)
@pool = DB::Pool(HTTP::Client).new(pool_options, &client_factory)
end
{% for method in %w[get post put patch delete head options] %}
# Streaming API for {{method.id.upcase}} request.
# The response will have its body as an `IO` accessed via `HTTP::Client::Response#body_io`.
def {{method.id}}(*args, **kwargs, &)
self.checkout do | client |
client.{{method.id}}(*args, **kwargs) do | response |
result = yield response
return result
ensure
response.body_io?.try &.skip_to_end
end
end
end
# Executes a {{method.id.upcase}} request.
# The response will have its body as a `String`, accessed via `HTTP::Client::Response#body`.
def {{method.id}}(*args, **kwargs)
self.checkout do | client |
return client.{{method.id}}(*args, **kwargs)
end
end
{% end %}
# Checks out a client in the pool
def checkout(&)
# If a client has been deleted from the pool
# we won't try to release it
client_exists_in_pool = true
http_client = pool.checkout
# When the HTTP::Client connection is closed, the automatic reconnection
# feature will create a new IO to connect to the server with
#
# This new TCP IO will be a direct connection to the server and will not go
# through the proxy. As such we'll need to reinitialize the proxy connection
http_client.proxy = make_configured_http_proxy_client() if @reinitialize_proxy && CONFIG.http_proxy
response = yield http_client
rescue ex : DB::PoolTimeout
# Failed to checkout a client
raise ConnectionPool::PoolCheckoutError.new(ex.message)
rescue ex
# An error occurred with the client itself.
# Delete the client from the pool and close the connection
if http_client
client_exists_in_pool = false
@pool.delete(http_client)
http_client.close
end
# Raise exception for outer methods to handle
raise ConnectionPool::Error.new(ex.message, cause: ex)
ensure
pool.release(http_client) if http_client && client_exists_in_pool
end
end
class Error < Exception
end
# Raised when the pool failed to get a client in time
class PoolCheckoutError < Error
end
# Mapping of subdomain => Invidious::ConnectionPool::Pool
# This is needed as we may need to access arbitrary subdomains of ytimg
private YTIMG_POOLS = {} of String => ConnectionPool::Pool
# Fetches a HTTP pool for the specified subdomain of ytimg.com
#
# Creates a new one when the specified pool for the subdomain does not exist
def self.get_ytimg_pool(subdomain)
if pool = YTIMG_POOLS[subdomain]?
return pool
else
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
url = URI.parse("https://#{subdomain}.ytimg.com")
pool = ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(url, force_resolve: true)
end
YTIMG_POOLS[subdomain] = pool
return pool
end
end
end

View File

@@ -91,7 +91,7 @@ module Invidious::Database::Playlists
end
# -------------------
# Select
# Salect
# -------------------
def select(*, id : String) : InvidiousPlaylist?
@@ -113,7 +113,7 @@ module Invidious::Database::Playlists
end
# -------------------
# Select (filtered)
# Salect (filtered)
# -------------------
def select_like_iv(email : String) : Array(InvidiousPlaylist)
@@ -213,7 +213,7 @@ module Invidious::Database::PlaylistVideos
end
# -------------------
# Select
# Salect
# -------------------
def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo)

View File

@@ -32,7 +32,7 @@ module Invidious::Frontend::WatchPage
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " action='" << URI.encode_www_form(url) << "'"
str << " action='#{url}'"
str << " method='post'"
str << " rel='noopener'"
str << " target='_blank'>"

View File

@@ -18,7 +18,16 @@ def github_details(summary : String, content : String)
return HTML.escape(details)
end
def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String)
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
if exception.is_a?(InfoException)
return error_template_helper(env, status_code, exception.message || "")
end
locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "text/html"
env.response.status_code = status_code
issue_title = "#{exception.message} (#{exception.class})"
issue_template = <<-TEXT
@@ -31,24 +40,6 @@ def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tup
issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
return issue_title, issue_template
end
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
if exception.is_a?(InfoException)
return error_template_helper(env, status_code, exception.message || "")
end
locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "text/html"
env.response.status_code = status_code
# Unpacking into issue_title, issue_template directly causes a compiler error
# I have no idea why.
issue_template_components = get_issue_template(env, exception)
issue_title, issue_template = issue_template_components
# URLs for the error message below
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
url_search_issues = "https://github.com/iv-org/invidious/issues"
@@ -78,7 +69,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
<p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
<!-- TODO: Add a "copy to clipboard" button -->
<pre class="error-issue-template">#{issue_template}</pre>
<pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre>
</div>
END_HTML

View File

@@ -55,11 +55,12 @@ macro templated(_filename, template = "template", navbar_search = true)
{{ layout = "src/invidious/views/" + template + ".ecr" }}
__content_filename__ = {{filename}}
render {{filename}}, {{layout}}
content = Kilt.render({{filename}})
Kilt.render({{layout}})
end
macro rendered(filename)
render("src/invidious/views/#{{{filename}}}.ecr")
Kilt.render("src/invidious/views/#{{{filename}}}.ecr")
end
# Similar to Kemals halt method but works in a

View File

@@ -291,55 +291,6 @@ struct SearchHashtag
end
end
# A `ProblematicTimelineItem` is a `SearchItem` created by Invidious that
# represents an item that caused an exception during parsing.
#
# This is not a parsed object from YouTube but rather an Invidious-only type
# created to gracefully communicate parse errors without throwing away
# the rest of the (hopefully) successfully parsed item on a page.
struct ProblematicTimelineItem
property parse_exception : Exception
property id : String
def initialize(@parse_exception)
@id = Random.new.hex(8)
end
def to_json(locale : String?, json : JSON::Builder)
json.object do
json.field "type", "parse-error"
json.field "errorMessage", @parse_exception.message
json.field "errorBacktrace", @parse_exception.inspect_with_backtrace
end
end
# Provides compatibility with PlaylistVideo
def to_json(json : JSON::Builder, *args, **kwargs)
return to_json("", json)
end
def to_xml(env, locale, xml : XML::Builder)
xml.element("entry") do
xml.element("id") { xml.text "iv-err-#{@id}" }
xml.element("title") { xml.text "Parse Error: This item has failed to parse" }
xml.element("updated") { xml.text Time.utc.to_rfc3339 }
xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
xml.element("div") do
xml.element("h4") { translate(locale, "timeline_parse_error_placeholder_heading") }
xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") }
end
xml.element("pre") do
get_issue_template(env, @parse_exception)
end
end
end
end
end
end
class Category
include DB::Serializable
@@ -382,4 +333,4 @@ struct Continuation
end
end
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category | ProblematicTimelineItem
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category

View File

@@ -262,7 +262,7 @@ def get_referer(env, fallback = "/", unroll = true)
end
referer = referer.request_target
referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z+]/, "").lstrip("/\\")
referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z]/, "").lstrip("/\\")
if referer == env.request.path
referer = fallback

View File

@@ -26,7 +26,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
end
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
response = YT_POOL.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
initial_data = extract_initial_data(response.body)
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?

View File

@@ -432,7 +432,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
end
videos = [] of PlaylistVideo | ProblematicTimelineItem
videos = [] of PlaylistVideo
until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
# 100 videos per request
@@ -448,7 +448,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
end
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
videos = [] of PlaylistVideo | ProblematicTimelineItem
videos = [] of PlaylistVideo
if initial_data["contents"]?
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
@@ -500,8 +500,6 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
index: index,
})
end
rescue ex
videos << ProblematicTimelineItem.new(parse_exception: ex)
end
return videos

View File

@@ -26,7 +26,7 @@ module Invidious::Routes::API::Manifest
end
if dashmpd = video.dash_manifest_url
response = YT_POOL.client &.get(URI.parse(dashmpd).request_target)
response = YT_POOL.get(URI.parse(dashmpd).request_target)
if response.status_code != 200
haltf env, status_code: response.status_code
@@ -167,7 +167,7 @@ module Invidious::Routes::API::Manifest
# /api/manifest/hls_playlist/*
def self.get_hls_playlist(env)
response = YT_POOL.client &.get(env.request.path)
response = YT_POOL.get(env.request.path)
if response.status_code != 200
haltf env, status_code: response.status_code
@@ -223,7 +223,7 @@ module Invidious::Routes::API::Manifest
# /api/manifest/hls_variant/*
def self.get_hls_variant(env)
response = YT_POOL.client &.get(env.request.path)
response = YT_POOL.get(env.request.path)
if response.status_code != 200
haltf env, status_code: response.status_code

View File

@@ -106,7 +106,7 @@ module Invidious::Routes::API::V1::Videos
# Auto-generated captions often have cues that aren't aligned properly with the video,
# as well as some other markup that makes it cumbersome, so we try to fix that here
if caption.name.includes? "auto-generated"
caption_xml = YT_POOL.client &.get(url).body
caption_xml = YT_POOL.get(url).body
settings_field = {
"Kind" => "captions",
@@ -147,7 +147,7 @@ module Invidious::Routes::API::V1::Videos
query_params = uri.query_params
query_params["fmt"] = "vtt"
uri.query_params = query_params
webvtt = YT_POOL.client &.get(uri.request_target).body
webvtt = YT_POOL.get(uri.request_target).body
if webvtt.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(webvtt)
@@ -300,7 +300,7 @@ module Invidious::Routes::API::V1::Videos
cache_annotation(id, annotations)
end
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
response = YT_POOL.get("/annotations_invideo?video_id=#{id}")
if response.status_code != 200
haltf env, response.status_code

View File

@@ -20,6 +20,14 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff"
# Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed
if CONFIG.disabled?("local") || !preferences.local
extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
else
extra_media_csp = ""
end
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' file: http: https:"
@@ -37,7 +45,7 @@ module Invidious::Routes::BeforeAll
"font-src 'self' data:",
"connect-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob:",
"media-src 'self' blob:" + extra_media_csp,
"child-src 'self' blob:",
"frame-src 'self'",
"frame-ancestors " + frame_ancestors,
@@ -102,21 +110,6 @@ module Invidious::Routes::BeforeAll
preferences.locale = locale
env.set "preferences", preferences
# Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed
#
# `!preferences.local` has to be checked after setting and
# reading `preferences` from the "PREFS" cookie and
# saved user preferences from the database, otherwise
# `https://*.googlevideo.com:443 https://*.youtube.com:443`
# will not be set in the CSP header if
# `default_user_preferences.local` is set to true on the
# configuration file, causing preference “Proxy Videos”
# not to work while having it disabled and using medium quality.
if CONFIG.disabled?("local") || !preferences.local
env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443")
end
current_page = env.request.path
if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!)

View File

@@ -392,7 +392,7 @@ module Invidious::Routes::Channels
value = env.request.resource.split("/")[2]
body = ""
{"channel", "user", "c"}.each do |type|
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
response = YT_POOL.get("/#{type}/#{value}/live?disable_polymer=1")
if response.status_code == 200
body = response.body
end

View File

@@ -12,15 +12,13 @@ module Invidious::Routes::Embed
url = "/playlist?list=#{plid}"
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
url = "/embed/#{first_playlist_video}?#{env.params.query}"
url = "/embed/#{videos[0].id}?#{env.params.query}"
if env.params.query.size > 0
url += "?#{env.params.query}"
@@ -74,15 +72,13 @@ module Invidious::Routes::Embed
url = "/playlist?list=#{plid}"
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException
return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
url = "/embed/#{first_playlist_video.id}"
url = "/embed/#{videos[0].id}"
elsif video_series
url = "/embed/#{video_series.shift}"
env.params.query["playlist"] = video_series.join(",")
@@ -96,7 +92,7 @@ module Invidious::Routes::Embed
return env.redirect url
when "live_stream"
response = YT_POOL.client &.get("/embed/live_stream?channel=#{env.params.query["channel"]? || ""}")
response = YT_POOL.get("/embed/live_stream?channel=#{env.params.query["channel"]? || ""}")
video_id = response.body.match(/"video_id":"(?<video_id>[a-zA-Z0-9_-]{11})"/).try &.["video_id"]
env.params.query.delete_all("channel")

View File

@@ -9,10 +9,10 @@ module Invidious::Routes::ErrorRoutes
item = md["id"]
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
response = YT_POOL.get("/#{item}")
if response.status_code == 301
response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
response = YT_POOL.get(URI.parse(response.headers["Location"]).request_target)
end
if response.body.empty?
@@ -40,7 +40,7 @@ module Invidious::Routes::ErrorRoutes
end
# Check if item is video ID
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.head("/watch?v=#{item}").status_code != 404
env.response.headers["Location"] = url
haltf env, status_code: 302
end

View File

@@ -160,8 +160,9 @@ module Invidious::Routes::Feeds
"default" => "http://www.w3.org/2005/Atom",
}
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
response = YT_POOL.get("/feeds/videos.xml?channel_id=#{ucid}")
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@@ -202,7 +203,7 @@ module Invidious::Routes::Feeds
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
xml.element("id") { xml.text "yt:channel:#{ucid}" }
xml.element("yt:channelId") { xml.text ucid }
xml.element("title") { xml.text author }
xml.element("title") { author }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}")
xml.element("author") do
@@ -296,13 +297,7 @@ module Invidious::Routes::Feeds
xml.element("name") { xml.text playlist.author }
end
videos.each do |video|
if video.is_a? PlaylistVideo
video.to_xml(xml)
else
video.to_xml(env, locale, xml)
end
end
videos.each &.to_xml(xml)
end
end
else
@@ -310,7 +305,7 @@ module Invidious::Routes::Feeds
end
end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
response = YT_POOL.get("/feeds/videos.xml?playlist_id=#{plid}")
return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
document = XML.parse(response.body)

View File

@@ -12,7 +12,7 @@ module Invidious::Routes::Images
end
begin
GGPHT_POOL.client &.get(url, headers) do |resp|
GGPHT_POOL.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
@@ -42,7 +42,7 @@ module Invidious::Routes::Images
end
begin
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
ConnectionPool.get_ytimg_pool(authority).get(url, headers) do |resp|
env.response.headers["Connection"] = "close"
return self.proxy_image(env, resp)
end
@@ -65,7 +65,7 @@ module Invidious::Routes::Images
end
begin
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
ConnectionPool.get_ytimg_pool("i9").get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
@@ -81,7 +81,7 @@ module Invidious::Routes::Images
end
begin
YT_POOL.client &.get(env.request.resource, headers) do |response|
YT_POOL.get(env.request.resource, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
@@ -111,7 +111,7 @@ module Invidious::Routes::Images
if name == "maxres.jpg"
build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200
if ConnectionPool.get_ytimg_pool("i").head(thumbnail_resource_path, headers).status_code == 200
name = thumb[:url] + ".jpg"
break
end
@@ -127,7 +127,7 @@ module Invidious::Routes::Images
end
begin
get_ytimg_pool("i").client &.get(url, headers) do |resp|
ConnectionPool.get_ytimg_pool("i").get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex

View File

@@ -21,6 +21,9 @@ module Invidious::Routes::Login
account_type = env.params.query["type"]?
account_type ||= "invidious"
captcha_type = env.params.query["captcha"]?
captcha_type ||= "image"
templated "user/login"
end
@@ -85,14 +88,34 @@ module Invidious::Routes::Login
password = password.byte_slice(0, 55)
if CONFIG.captcha_enabled
captcha_type = env.params.body["captcha_type"]?
answer = env.params.body["answer"]?
change_type = env.params.body["change_type"]?
account_type = "invidious"
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
if !captcha_type || change_type
if change_type
captcha_type = change_type
end
captcha_type ||= "image"
account_type = "invidious"
if captcha_type == "image"
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
else
captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
end
return templated "user/login"
end
tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
if answer
answer ||= ""
captcha_type ||= "image"
case captcha_type
when "image"
answer = answer.lstrip('0')
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
@@ -101,8 +124,27 @@ module Invidious::Routes::Login
rescue ex
return error_template(400, ex)
end
else
return templated "user/login"
else # "text"
answer = Digest::MD5.hexdigest(answer.downcase.strip)
if tokens.empty?
return error_template(500, "Erroneous CAPTCHA")
end
found_valid_captcha = false
error_exception = Exception.new
tokens.each do |tok|
begin
validate_request(tok, answer, env.request, HMAC_KEY, locale)
found_valid_captcha = true
rescue ex
error_exception = ex
end
end
if !found_valid_captcha
return error_template(500, error_exception)
end
end
end

View File

@@ -464,7 +464,7 @@ module Invidious::Routes::Playlists
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
def self.watch_videos(env)
response = YT_POOL.client &.get(env.request.resource)
response = YT_POOL.get(env.request.resource)
if url = response.headers["Location"]?
url = URI.parse(url).request_target
return env.redirect url

View File

@@ -58,11 +58,7 @@ module Invidious::Routes::Search
end
begin
if user
items = query.process(user.as(User))
else
items = query.process
end
items = query.process
rescue ex : ChannelSearchException
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
rescue ex

View File

@@ -16,11 +16,11 @@ module Invidious::Search
# Search a youtube channel
# TODO: clean code, and rely more on YoutubeAPI
def channel(query : Query) : Array(SearchItem)
response = YT_POOL.client &.get("/channel/#{query.channel}")
response = YT_POOL.get("/channel/#{query.channel}")
if response.status_code == 404
response = YT_POOL.client &.get("/user/#{query.channel}")
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
response = YT_POOL.get("/user/#{query.channel}")
response = YT_POOL.get("/c/#{query.channel}") if response.status_code == 404
initial_data = extract_initial_data(response.body)
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
raise ChannelSearchException.new(query.channel) if !ucid

View File

@@ -31,12 +31,12 @@ def fetch_trending(trending_type, region, locale)
# See: https://github.com/iv-org/invidious/issues/2989
next if (itm.contents.size < 24 && deduplicate)
extracted.concat itm.contents.select(SearchItem)
extracted.concat extract_category(itm)
else
extracted << itm
end
end
# Deduplicate items before returning results
return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
return extracted.select(SearchVideo).uniq!(&.id), plid
end

View File

@@ -4,6 +4,8 @@ struct Invidious::User
module Captcha
extend self
private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
def generate_image(key)
second = Random::Secure.rand(12)
second_angle = second * 30
@@ -58,5 +60,19 @@ struct Invidious::User
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
def generate_text(key)
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end
end
end

View File

@@ -82,7 +82,7 @@ def extract_video_info(video_id : String)
"reason" => JSON::Any.new(reason),
}
end
elsif video_id != player_response.dig?("videoDetails", "videoId")
elsif video_id != player_response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue.
@@ -109,33 +109,21 @@ def extract_video_info(video_id : String)
params["reason"] = JSON::Any.new(reason) if reason
if !CONFIG.invidious_companion.present?
if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
if player_response["streamingData"]? && player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.")
players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile}
players_fallback = [YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile]
players_fallback.each do |player_fallback|
client_config.client_type = player_fallback
next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config))
if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url")
player_fallback_response = try_fetch_streaming_data(video_id, client_config)
if player_fallback_response && player_fallback_response["streamingData"]? &&
player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url")
streaming_data = player_response["streamingData"].as_h
streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"]
player_response["streamingData"] = JSON::Any.new(streaming_data)
break
end
rescue InfoException
next LOGGER.warn("Failed to fetch streams with #{player_fallback}")
end
end
# Seems like video page can still render even without playable streams.
# its better than nothing.
#
# # Were we able to find playable video streams?
# if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
# # No :(
# end
end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
@@ -166,7 +154,7 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
if id != response.dig?("videoDetails", "videoId")
if id != response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise InfoException.new(

View File

@@ -1,6 +1,6 @@
<%-
thin_mode = env.get("preferences").as(Preferences).thin_mode
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
author_verified = item.responds_to?(:author_verified) && item.author_verified
-%>
@@ -97,18 +97,6 @@
</div>
</div>
<% when Category %>
<% when ProblematicTimelineItem %>
<div class="error-card">
<div class="explanation">
<i class="icon ion-ios-alert"></i>
<h4><%=translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
<p><%=translate(locale, "timeline_parse_error_placeholder_message")%></p>
</div>
<details>
<summary class="pure-button pure-button-secondary"><%=translate(locale, "timeline_parse_error_show_technical_details")%></summary>
<pre class="error-issue-template"><%=get_issue_template(env, item.parse_exception)[1]%></pre>
</details>
</div>
<% else %>
<%-
# `endpoint_params` is used for the "video-context-buttons" component

View File

@@ -9,6 +9,90 @@
<body>
<h1><%= translate(locale, "JavaScript license information") %></h1>
<table id="jslicense-labels1">
<tr>
<td>
<a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>">_helpers.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/handlers.js?v=<%= ASSET_COMMIT %>">handlers.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/handlers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/embed.js?v=<%= ASSET_COMMIT %>">embed.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/embed.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>">notifications.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/player.js?v=<%= ASSET_COMMIT %>">player.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/player.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>">silvermine-videojs-quality-selector.min.js</a>
@@ -37,6 +121,34 @@
</td>
</tr>
<tr>
<td>
<a href="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>">subscribe_widget.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/themes.js?v=<%= ASSET_COMMIT %>">themes.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/themes.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>">videojs-contrib-quality-levels.js</a>
@@ -177,9 +289,19 @@
</td>
</tr>
<%- {% for row in run("../../../scripts/generate_js_licenses.cr").stringify.split('\n') %} %>
<%-= {{row.id}} -%>
<% {% end %} -%>
<tr>
<td>
<a href="/js/watch.js?v=<%= ASSET_COMMIT %>">watch.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/watch.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -25,17 +25,44 @@
<% end %>
<% if captcha %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% case captcha_type when %>
<% when "image" %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="image">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<% else # "text" %>
<% captcha = captcha.not_nil! %>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="text">
<label for="answer"><%= captcha[:question] %></label>
<input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
<% end %>
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Register") %>
</button>
<% case captcha_type when %>
<% when "image" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="text">
<%= translate(locale, "Text CAPTCHA") %>
</button>
</label>
<% else # "text" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="image">
<%= translate(locale, "Image CAPTCHA") %>
</button>
</label>
<% end %>
<% else %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>

View File

@@ -1,153 +0,0 @@
# Mapping of subdomain => YoutubeConnectionPool
# This is needed as we may need to access arbitrary subdomains of ytimg
private YTIMG_POOLS = {} of String => YoutubeConnectionPool
struct YoutubeConnectionPool
property! url : URI
property! capacity : Int32
property! timeout : Float64
property pool : DB::Pool(HTTP::Client)
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
@url = url
@pool = build_pool()
end
def client(&)
conn = pool.checkout
# Proxy needs to be reinstated every time we get a client from the pool
conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
begin
response = yield conn
rescue ex
conn.close
conn = make_client(url, force_resolve: true)
response = yield conn
ensure
pool.release(conn)
end
response
end
private def build_pool
options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: capacity,
max_idle_pool_size: capacity,
checkout_timeout: timeout
)
DB::Pool(HTTP::Client).new(options) do
next make_client(url, force_resolve: true)
end
end
end
struct CompanionConnectionPool
property pool : DB::Pool(HTTP::Client)
def initialize(capacity = 5, timeout = 5.0)
options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: capacity,
max_idle_pool_size: capacity,
checkout_timeout: timeout
)
@pool = DB::Pool(HTTP::Client).new(options) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
end
end
def client(&)
conn = pool.checkout
begin
response = yield conn
rescue ex
conn.close
companion = CONFIG.invidious_companion.sample
conn = make_client(companion.private_url, use_http_proxy: false)
response = yield conn
ensure
pool.release(conn)
end
response
end
end
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
# Force the usage of a specific configured IP Family
if force_resolve
client.family = CONFIG.force_resolve
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
return client
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
begin
yield client
ensure
client.close
end
end
def make_configured_http_proxy_client
# This method is only called when configuration for an HTTP proxy are set
config_proxy = CONFIG.http_proxy.not_nil!
return HTTP::Proxy::Client.new(
config_proxy.host,
config_proxy.port,
username: config_proxy.user,
password: config_proxy.password,
)
end
# Fetches a HTTP pool for the specified subdomain of ytimg.com
#
# Creates a new one when the specified pool for the subdomain does not exist
def get_ytimg_pool(subdomain)
if pool = YTIMG_POOLS[subdomain]?
return pool
else
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size)
YTIMG_POOLS[subdomain] = pool
return pool
end
end

View File

@@ -35,20 +35,6 @@ record AuthorFallback, name : String, id : String
# data is passed to the private `#parse()` method which returns a datastruct of the given
# type. Otherwise, nil is returned.
private module Parsers
module BaseParser
def parse(*args)
begin
return parse_internal(*args)
rescue ex
LOGGER.debug("#{{{@type.name}}}: Failed to render item.")
LOGGER.debug("#{{{@type.name}}}: Got exception: #{ex.message}")
ProblematicTimelineItem.new(
parse_exception: ex
)
end
end
end
# Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer
#
# A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not**
@@ -59,16 +45,13 @@ private module Parsers
# `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
#
module VideoRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
video_id = item_contents["videoId"].as_s
title = extract_text(item_contents["title"]?) || ""
@@ -132,7 +115,7 @@ private module Parsers
badges = VideoBadges::None
item_contents["badges"]?.try &.as_a.each do |badge|
b = badge["metadataBadgeRenderer"]
case b["label"]?.try &.as_s
case b["label"].as_s
when "LIVE"
badges |= VideoBadges::LiveNow
when "New"
@@ -187,16 +170,13 @@ private module Parsers
# `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
#
module ChannelRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?)
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
author = extract_text(item_contents["title"]) || author_fallback.name
author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
@@ -250,16 +230,13 @@ private module Parsers
# A `hashtagTileRenderer` is a kind of search result.
# It can be found when searching for any hashtag (e.g "#hi" or "#shorts")
module HashtagRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["hashtagTileRenderer"]?
return self.parse(item_contents)
end
end
private def parse_internal(item_contents)
private def self.parse(item_contents)
title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi"
# E.g "/hashtag/hi"
@@ -286,6 +263,10 @@ private module Parsers
video_count: short_text_to_number(video_count_txt || ""),
channel_count: short_text_to_number(channel_count_txt || ""),
})
rescue ex
LOGGER.debug("HashtagRendererParser: Failed to extract renderer.")
LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}")
return nil
end
def self.parser_name
@@ -303,16 +284,13 @@ private module Parsers
# `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories.
#
module GridPlaylistRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["gridPlaylistRenderer"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
title = extract_text(item_contents["title"]) || ""
plid = item_contents["playlistId"]?.try &.as_s || ""
@@ -347,16 +325,13 @@ private module Parsers
# `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc.
#
module PlaylistRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["playlistRenderer"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
title = extract_text(item_contents["title"]) || ""
plid = item_contents["playlistId"]?.try &.as_s || ""
@@ -410,16 +385,13 @@ private module Parsers
# `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
#
module CategoryRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["shelfRenderer"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
title = extract_text(item_contents["title"]?) || ""
url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url")
.try &.as_s
@@ -478,16 +450,13 @@ private module Parsers
# container.It is very similar to RichItemRendererParser
#
module ItemSectionRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
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 parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
child = VideoRendererParser.process(item_contents, author_fallback)
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
@@ -507,16 +476,13 @@ private module Parsers
# itself inside a richGridRenderer container.
#
module RichItemRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item.dig?("richItemRenderer", "content")
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
child = VideoRendererParser.process(item_contents, author_fallback)
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
@@ -540,16 +506,13 @@ private module Parsers
# TODO: Confirm that hypothesis
#
module ReelItemRendererParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["reelItemRenderer"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
video_id = item_contents["videoId"].as_s
reel_player_overlay = item_contents.dig(
@@ -637,16 +600,13 @@ private module Parsers
# a richItemRenderer or a richGridRenderer.
#
module LockupViewModelParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["lockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s
thumbnail_view_model = item_contents.dig(
@@ -715,16 +675,13 @@ private module Parsers
# usually (always?) encapsulated in a richItemRenderer.
#
module ShortsLockupViewModelParser
extend self
include BaseParser
def process(item : JSON::Any, author_fallback : AuthorFallback)
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["shortsLockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def parse_internal(item_contents, author_fallback)
private def self.parse(item_contents, author_fallback)
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...

View File

@@ -639,15 +639,13 @@ module YoutubeAPI
LOGGER.trace("YoutubeAPI: POST data: #{data}")
# Send the POST request
body = YT_POOL.client() do |client|
client.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
body = YT_POOL.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end
# Convert result to Hash
@@ -695,7 +693,7 @@ module YoutubeAPI
# Send the POST request
begin
response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json)
response = COMPANION_POOL.post(endpoint, headers: headers, body: data.to_json)
body = response.body
if (response.status_code != 200)
raise Exception.new(