Compare commits

..

2 Commits

Author SHA1 Message Date
Émilien (perso)
aa7de1ed4c fix: fallback other yt clients no url found for adaptive formats (#5262) 2025-05-04 12:03:31 +02:00
Emilien
5f1f8ff4b1 Release v2.20250504.0 2025-05-04 12:02:58 +02:00
76 changed files with 585 additions and 1099 deletions

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-compose.yml @unixfox
docker/ @unixfox docker/ @unixfox
kubernetes/ @unixfox kubernetes/ @unixfox

View File

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "weekly"
- package-ecosystem: github-actions
directory: /
schedule:
interval: "weekly"

View File

@ -50,7 +50,7 @@ jobs:
quay.expires-after=12w quay.expires-after=12w
- name: Build and push Docker AMD64 image for Push Event - name: Build and push Docker AMD64 image for Push Event
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
@ -75,7 +75,7 @@ jobs:
quay.expires-after=12w quay.expires-after=12w
- name: Build and push Docker ARM64 image for Push Event - name: Build and push Docker ARM64 image for Push Event
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile.arm64 file: docker/Dockerfile.arm64

View File

@ -43,7 +43,7 @@ jobs:
quay.expires-after=12w quay.expires-after=12w
- name: Build and push Docker AMD64 image for Push Event - name: Build and push Docker AMD64 image for Push Event
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
@ -69,7 +69,7 @@ jobs:
quay.expires-after=12w quay.expires-after=12w
- name: Build and push Docker ARM64 image for Push Event - name: Build and push Docker ARM64 image for Push Event
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile.arm64 file: docker/Dockerfile.arm64

View File

@ -38,11 +38,10 @@ jobs:
matrix: matrix:
stable: [true] stable: [true]
crystal: crystal:
- 1.12.2 - 1.12.1
- 1.13.3 - 1.13.2
- 1.14.1 - 1.14.0
- 1.15.1 - 1.15.0
- 1.16.3
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false
@ -58,12 +57,12 @@ jobs:
shell: bash shell: bash
- name: Install Crystal - name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.2 uses: crystal-lang/install-crystal@v1.8.0
with: with:
crystal: ${{ matrix.crystal }} crystal: ${{ matrix.crystal }}
- name: Cache Shards - name: Cache Shards
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: | path: |
./lib ./lib
@ -114,7 +113,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build Docker ARM64 image - name: Build Docker ARM64 image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile.arm64 file: docker/Dockerfile.arm64
@ -137,12 +136,12 @@ jobs:
- name: Install Crystal - name: Install Crystal
id: lint_step_install_crystal id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.8.2 uses: crystal-lang/install-crystal@v1.8.0
with: with:
crystal: latest crystal: latest
- name: Cache Shards - name: Cache Shards
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: | path: |
./lib ./lib

View File

@ -10,7 +10,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v8
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 730 days-before-stale: 730

View File

@ -2,15 +2,11 @@
## vX.Y.0 (future) ## 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 ## v2.20250504.0
Small release with quick workaround fix for issue #4251 (Nil assertion failed). 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 ## v2.20250314.0

View File

@ -81,9 +81,9 @@
- [Available in many languages](locales/), thanks to [our translators](#contribute) - [Available in many languages](locales/), thanks to [our translators](#contribute)
**Data import/export** **Data import/export**
- Import subscriptions from YouTube, NewPipe and FreeTube - Import subscriptions from YouTube, NewPipe and Freetube
- Import watch history from YouTube and NewPipe - Import watch history from YouTube and NewPipe
- Export subscriptions to NewPipe and FreeTube - Export subscriptions to NewPipe and Freetube
- Import/Export Invidious user data - Import/Export Invidious user data
**Technical features** **Technical features**
@ -95,11 +95,11 @@
## Quick start ## Quick start
**Using Invidious:** **Using invidious:**
- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now! - [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now!
**Hosting Invidious:** **Hosting invidious:**
- [Follow the installation instructions](https://docs.invidious.io/installation/) - [Follow the installation instructions](https://docs.invidious.io/installation/)
@ -114,8 +114,8 @@ https://github.com/iv-org/documentation
### Extensions ### Extensions
We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get), We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get),
a browser extension that automatically redirects YouTube URLs to any Invidious instance and replaces a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
embedded YouTube videos on other websites with Invidious. embedded youtube videos on other websites with invidious.
The documentation contains a list of browser extensions that we recommended to use along with Invidious. The documentation contains a list of browser extensions that we recommended to use along with Invidious.
@ -140,7 +140,7 @@ We use [Weblate](https://weblate.org) to manage Invidious translations.
You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/. You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/.
Creating an account is not required, but recommended, especially if you want to contribute regularly. Creating an account is not required, but recommended, especially if you want to contribute regularly.
Weblate also allows you to log-in with major SSO providers like GitHub, GitLab, BitBucket, Google, ... Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ...
## Projects using Invidious ## Projects using Invidious

View File

@ -550,10 +550,6 @@ span > select {
color: #565d64; color: #565d64;
} }
.light-theme .error-card {
border: 1px solid black;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
.no-theme a:hover, .no-theme a:hover,
.no-theme a:active, .no-theme a:active,
@ -600,10 +596,6 @@ span > select {
.light-theme .pure-menu-heading { .light-theme .pure-menu-heading {
color: #565d64; color: #565d64;
} }
.no-theme .error-card {
border: 1px solid black;
}
} }
@ -666,10 +658,6 @@ body.dark-theme {
color: inherit; color: inherit;
} }
.dark-theme .error-card {
border: 1px solid #5e5e5e;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.no-theme a:hover, .no-theme a:hover,
.no-theme a:active, .no-theme a:active,
@ -731,10 +719,6 @@ body.dark-theme {
.no-theme footer a { .no-theme footer a {
color: #adadad !important; color: #adadad !important;
} }
.no-theme .error-card {
border: 1px solid #5e5e5e;
}
} }
@ -832,57 +816,3 @@ h1, h2, h3, h4, h5, p,
#download_widget { #download_widget {
width: 100%; 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 */ /* This should hide the marker */
display: block; display: block;
@ -8,10 +8,10 @@
cursor: pointer; cursor: pointer;
} }
#filters-collapse summary::-webkit-details-marker, summary::-webkit-details-marker,
#filters-collapse summary::marker { display: none; } summary::marker { display: none; }
#filters-collapse summary:before { summary:before {
border-radius: 5px; border-radius: 5px;
content: "[ + ]"; content: "[ + ]";
margin: -2px 10px 0 10px; margin: -2px 10px 0 10px;
@ -20,7 +20,7 @@
width: 40px; width: 40px;
} }
#filters-collapse details[open] > summary:before { content: "[ ]"; } details[open] > summary:before { content: "[ ]"; }
#filters-box { #filters-box {

View File

@ -54,53 +54,6 @@ db:
## ##
#signature_server: #signature_server:
##
## Invidious companion is an external program
## for loading the video streams from YouTube servers.
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
## Note: multiple URL can be configured. In this case, invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
## to work. Once said cache has expired, requesting that video's data
## again will cause a new companion URL to be picked.
##
## The parameter private_url needs to be configured for the internal
## communication between the companion and Invidious.
## And public_url is the public URL from which companion is listening
## to the requests from the user(s).
##
## If you are using a reverse proxy then you will probably need to
## configure the public_url to be the same as the domain used for Invidious.
## Also apply when used from an external IP address (without a domain).
## Examples: https://MYINVIDIOUSDOMAIN or http://192.168.1.100:8282
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"
##
## API key for Invidious companion, used for securing the communication
## between Invidious and Invidious companion.
## The key needs to be exactly 16 characters long.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string (of length 16)
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"
######################################### #########################################
# #
@ -858,9 +811,9 @@ default_user_preferences:
## Default video quality. ## Default video quality.
## ##
## Accepted values: dash, hd720, medium, small ## Accepted values: dash, hd720, medium, small
## Default: dash ## Default: hd720
## ##
#quality: dash #quality: hd720
## ##
## Default dash video quality. ## Default dash video quality.

View File

@ -1,4 +1,4 @@
FROM crystallang/crystal:1.16.3-alpine AS builder FROM crystallang/crystal:1.12.2-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static RUN apk add --no-cache sqlite-static yaml-static
@ -32,7 +32,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM alpine:3.21 FROM alpine:3.20
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \

View File

@ -1,5 +1,5 @@
FROM alpine:3.21 AS builder FROM alpine:3.20 AS builder
RUN apk add --no-cache 'crystal=1.14.0-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \ RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \
zlib-static openssl-libs-static openssl-dev musl-dev xz-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release ARG release
@ -33,7 +33,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM alpine:3.21 FROM alpine:3.20
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \

View File

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

View File

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

View File

@ -204,7 +204,7 @@
"View JavaScript license information.": "Consulta la informació de la llicència de JavaScript.", "View JavaScript license information.": "Consulta la informació de la llicència de JavaScript.",
"Playlist privacy": "Privacitat de la llista de reproducció", "Playlist privacy": "Privacitat de la llista de reproducció",
"search_message_no_results": "No s'han trobat resultats.", "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: ", "Genre: ": "Gènere: ",
"Hidden field \"challenge\" is a required field": "El camp ocult \"repte\" és un camp obligatori", "Hidden field \"challenge\" is a required field": "El camp ocult \"repte\" és un camp obligatori",
"Burmese": "Birmà", "Burmese": "Birmà",
@ -489,16 +489,5 @@
"generic_button_delete": "Suprimeix", "generic_button_delete": "Suprimeix",
"Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)", "Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)",
"Answer": "Resposta", "Answer": "Resposta",
"toggle_theme": "Commuta el tema", "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ó"
} }

View File

@ -515,8 +515,5 @@
"carousel_skip": "Přeskočit galerii", "carousel_skip": "Přeskočit galerii",
"carousel_go_to": "Přejít na snímek `x`", "carousel_go_to": "Přejít na snímek `x`",
"preferences_preload_label": "Předem načíst data videa: ", "preferences_preload_label": "Předem načíst data videa: ",
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)", "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"
} }

View File

@ -141,7 +141,7 @@
"An alternative front-end to YouTube": "Pen blaen amgen i YouTube", "An alternative front-end to YouTube": "Pen blaen amgen i YouTube",
"source": "ffynhonnell", "source": "ffynhonnell",
"Log in": "Mewngofnodi", "Log in": "Mewngofnodi",
"Log in/register": "Mewngofnodi/cofrestru", "Log in/register": "Mewngofnodi/Cofrestru",
"User ID": "Enw defnyddiwr", "User ID": "Enw defnyddiwr",
"preferences_quality_option_dash": "DASH (ansawdd addasol)", "preferences_quality_option_dash": "DASH (ansawdd addasol)",
"Sign In": "Mewngofnodi", "Sign In": "Mewngofnodi",
@ -381,32 +381,5 @@
"channel_tab_channels_label": "Sianeli", "channel_tab_channels_label": "Sianeli",
"channel_tab_community_label": "Cymuned", "channel_tab_community_label": "Cymuned",
"channel_tab_shorts_label": "Fideos byrion", "channel_tab_shorts_label": "Fideos byrion",
"channel_tab_videos_label": "Fideos", "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."
} }

View File

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

View File

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

View File

@ -64,6 +64,8 @@
"User ID": "User ID", "User ID": "User ID",
"Password": "Password", "Password": "Password",
"Time (h:mm:ss):": "Time (h:mm:ss):", "Time (h:mm:ss):": "Time (h:mm:ss):",
"Text CAPTCHA": "Text CAPTCHA",
"Image CAPTCHA": "Image CAPTCHA",
"Sign In": "Sign In", "Sign In": "Sign In",
"Register": "Register", "Register": "Register",
"E-mail": "E-mail", "E-mail": "E-mail",
@ -499,8 +501,5 @@
"toggle_theme": "Toggle Theme", "toggle_theme": "Toggle Theme",
"carousel_slide": "Slide {{current}} of {{total}}", "carousel_slide": "Slide {{current}} of {{total}}",
"carousel_skip": "Skip the Carousel", "carousel_skip": "Skip the Carousel",
"carousel_go_to": "Go to slide `x`", "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"
} }

View File

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

View File

@ -515,8 +515,5 @@
"carousel_go_to": "Aller à la diapositive `x`", "carousel_go_to": "Aller à la diapositive `x`",
"toggle_theme": "Changer le Thème", "toggle_theme": "Changer le Thème",
"Filipino (auto-generated)": "Philippines (automatiquement générer)", "Filipino (auto-generated)": "Philippines (automatiquement générer)",
"preferences_preload_label": "Précharger les données de la vidéo : ", "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"
} }

View File

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

View File

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

View File

@ -343,7 +343,7 @@
"search_filters_type_label": "種類", "search_filters_type_label": "種類",
"search_filters_duration_label": "再生時間", "search_filters_duration_label": "再生時間",
"search_filters_features_label": "特徴", "search_filters_features_label": "特徴",
"search_filters_sort_label": "並べ替え", "search_filters_sort_label": "順番",
"search_filters_date_option_hour": "1時間以内", "search_filters_date_option_hour": "1時間以内",
"search_filters_date_option_today": "今日", "search_filters_date_option_today": "今日",
"search_filters_date_option_week": "今週", "search_filters_date_option_week": "今週",
@ -370,7 +370,7 @@
"footer_documentation": "説明書", "footer_documentation": "説明書",
"footer_source_code": "ソースコード", "footer_source_code": "ソースコード",
"footer_original_source_code": "元のソースコード", "footer_original_source_code": "元のソースコード",
"footer_modfied_source_code": "改変し使用", "footer_modfied_source_code": "改変し使用",
"adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL", "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL",
"search_filters_duration_option_long": "20分以上", "search_filters_duration_option_long": "20分以上",
"preferences_region_label": "地域: ", "preferences_region_label": "地域: ",
@ -446,7 +446,7 @@
"search_filters_duration_option_medium": "4 20分", "search_filters_duration_option_medium": "4 20分",
"preferences_save_player_pos_label": "再生位置を保存: ", "preferences_save_player_pos_label": "再生位置を保存: ",
"crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。", "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> を検索", "crash_page_search_issue": "<a href=\"`x`\">GitHub の既存の問題 (issue)</a> を検索",
"channel_tab_streams_label": "ライブ", "channel_tab_streams_label": "ライブ",
"channel_tab_playlists_label": "再生リスト", "channel_tab_playlists_label": "再生リスト",
@ -481,8 +481,5 @@
"carousel_skip": "画像のスライド表示をスキップ", "carousel_skip": "画像のスライド表示をスキップ",
"toggle_theme": "テーマの切り替え", "toggle_theme": "テーマの切り替え",
"preferences_preload_label": "動画データを事前に読み込む: ", "preferences_preload_label": "動画データを事前に読み込む: ",
"Filipino (auto-generated)": "フィリピノ語 (自動生成)", "Filipino (auto-generated)": "フィリピノ語 (自動生成)"
"First page": "最初のページ",
"channel_tab_posts_label": "投稿",
"channel_tab_courses_label": "コース"
} }

View File

@ -480,9 +480,5 @@
"Search for videos": "비디오 검색", "Search for videos": "비디오 검색",
"toggle_theme": "테마 전환", "toggle_theme": "테마 전환",
"carousel_slide": "{{total}}의 슬라이드 {{current}}", "carousel_slide": "{{total}}의 슬라이드 {{current}}",
"preferences_preload_label": "비디오 데이터 사전 로드: ", "preferences_preload_label": "비디오 데이터 사전 로드: "
"First page": "첫 페이지",
"Filipino (auto-generated)": "Filipino (auto-generated)",
"channel_tab_posts_label": "게시글",
"channel_tab_courses_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", "carousel_skip": "Carousel overslaan",
"toggle_theme": "Thema omschakelen", "toggle_theme": "Thema omschakelen",
"preferences_preload_label": "Videogegevens vooraf laden: ", "preferences_preload_label": "Videogegevens vooraf laden: ",
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)", "Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
"channel_tab_courses_label": "Cursussen",
"First page": "Eerste pagina",
"channel_tab_posts_label": "Gepost"
} }

View File

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

View File

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

View File

@ -1,27 +1,27 @@
{ {
"LIVE": "Direto", "LIVE": "Em direto",
"Shared `x` ago": "Partilhado `x` atrás", "Shared `x` ago": "Partilhado `x` atrás",
"Unsubscribe": "Anular subscrição", "Unsubscribe": "Anular subscrição",
"Subscribe": "Subscrever", "Subscribe": "Subscrever",
"View channel on YouTube": "Ver canal no YouTube", "View channel on YouTube": "Ver canal no YouTube",
"View playlist on YouTube": "Ver lista de reprodução no YouTube", "View playlist on YouTube": "Ver lista de reprodução no YouTube",
"newest": "recentes", "newest": "mais recentes",
"oldest": "antigos", "oldest": "mais antigos",
"popular": "populares", "popular": "popular",
"last": "últimos", "last": "últimos",
"Next page": "Página seguinte", "Next page": "Próxima página",
"Previous page": "Página anterior", "Previous page": "Página anterior",
"Clear watch history?": "Limpar histórico de reprodução?", "Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova palavra-passe", "New password": "Nova palavra-chave",
"New passwords must match": "As novas palavras-passe devem ser iguais", "New passwords must match": "As novas palavra-chaves devem corresponder",
"Authorize token?": "Autorizar 'token'?", "Authorize token?": "Autorizar token?",
"Authorize token for `x`?": "Autorizar 'token' para `x`?", "Authorize token for `x`?": "Autorizar token para `x`?",
"Yes": "Sim", "Yes": "Sim",
"No": "Não", "No": "Não",
"Import and Export Data": "Importar e exportar dados", "Import and Export Data": "Importar e exportar dados",
"Import": "Importar", "Import": "Importar",
"Import Invidious data": "Importar dados JSON do Invidious", "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 FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
@ -32,38 +32,38 @@
"Delete account?": "Eliminar conta?", "Delete account?": "Eliminar conta?",
"History": "Histórico", "History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
"JavaScript license information": "Informação da licença JavaScript", "JavaScript license information": "Informação de licença do JavaScript",
"source": "fonte", "source": "código-fonte",
"Log in": "Iniciar sessão", "Log in": "Iniciar sessão",
"Log in/register": "Iniciar sessão/registar", "Log in/register": "Iniciar sessão/registar",
"User ID": "Utilizador", "User ID": "Utilizador",
"Password": "Palavra-passe", "Password": "Palavra-chave",
"Time (h:mm:ss):": "Tempo (h:mm:ss):", "Time (h:mm:ss):": "Tempo (h:mm:ss):",
"Text CAPTCHA": "Texto CAPTCHA", "Text CAPTCHA": "Texto CAPTCHA",
"Image CAPTCHA": "Imagem CAPTCHA", "Image CAPTCHA": "Imagem CAPTCHA",
"Sign In": "Entrar", "Sign In": "Iniciar sessão",
"Register": "Registar", "Register": "Registar",
"E-mail": "E-mail", "E-mail": "E-mail",
"Preferences": "Preferências", "Preferences": "Preferências",
"preferences_category_player": "Preferências do reprodutor", "preferences_category_player": "Preferências do reprodutor",
"preferences_video_loop_label": "Repetir sempre: ", "preferences_video_loop_label": "Repetir sempre: ",
"preferences_autoplay_label": "Reprodução automática: ", "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_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ",
"preferences_listen_label": "Apenas áudio: ", "preferences_listen_label": "Apenas áudio: ",
"preferences_local_label": "Usar proxy nos vídeos: ", "preferences_local_label": "Usar proxy nos vídeos: ",
"preferences_speed_label": "Velocidade preferida: ", "preferences_speed_label": "Velocidade preferida: ",
"preferences_quality_label": "Qualidade de vídeo preferida: ", "preferences_quality_label": "Qualidade de vídeo preferida: ",
"preferences_volume_label": "Volume de reprodução: ", "preferences_volume_label": "Volume da reprodução: ",
"preferences_comments_label": "Comentários padrão: ", "preferences_comments_label": "Preferência dos comentários: ",
"youtube": "YouTube", "youtube": "YouTube",
"reddit": "Reddit", "reddit": "Reddit",
"preferences_captions_label": "Legendas padrão: ", "preferences_captions_label": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ", "Fallback captions: ": "Legendas alternativas: ",
"preferences_related_videos_label": "Mostrar vídeos relacionados: ", "preferences_related_videos_label": "Mostrar vídeos relacionados: ",
"preferences_annotations_label": "Mostrar anotações sempre: ", "preferences_annotations_label": "Mostrar anotações sempre: ",
"preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ", "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ", "preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ",
"preferences_category_visual": "Preferências visuais", "preferences_category_visual": "Preferências visuais",
"preferences_player_style_label": "Estilo do reprodutor: ", "preferences_player_style_label": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ", "Dark mode: ": "Modo escuro: ",
@ -74,9 +74,9 @@
"preferences_category_misc": "Preferências diversas", "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_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_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: ", "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: ", "preferences_sort_label": "Ordenar vídeos por: ",
"published": "publicado", "published": "publicado",
"published - reverse": "publicado - inverso", "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: ", "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_unseen_only_label": "Mostrar apenas vídeos não visualizados: ",
"preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ",
"Enable web notifications": "Ativar notificações web", "Enable web notifications": "Ativar notificações pela web",
"`x` uploaded a video": "`x` publicou um vídeo", "`x` uploaded a video": "`x` publicou um novo vídeo",
"`x` is live": "`x` está em direto", "`x` is live": "`x` está em direto",
"preferences_category_data": "Preferências de dados", "preferences_category_data": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução", "Clear watch history": "Limpar histórico de reprodução",
"Import/export data": "Importar / exportar dados", "Import/export data": "Importar / exportar dados",
"Change password": "Alterar palavra-passe", "Change password": "Alterar palavra-chave",
"Manage subscriptions": "Gerir subscrições", "Manage subscriptions": "Gerir as subscrições",
"Manage tokens": "Gerir tokens", "Manage tokens": "Gerir tokens",
"Watch history": "Histórico de reprodução", "Watch history": "Histórico de reprodução",
"Delete account": "Eliminar conta", "Delete account": "Eliminar conta",
"preferences_category_admin": "Preferências de administrador", "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_feed_menu_label": "Menu de subscrições: ",
"preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ",
"Top enabled: ": "Destaques ativados: ", "Top enabled: ": "Destaques ativados: ",
@ -109,29 +109,28 @@
"Registration enabled: ": "Registar ativado: ", "Registration enabled: ": "Registar ativado: ",
"Report statistics: ": "Relatório de estatísticas: ", "Report statistics: ": "Relatório de estatísticas: ",
"Save preferences": "Guardar preferências", "Save preferences": "Guardar preferências",
"Subscription manager": "Gestor de subscrições", "Subscription manager": "Gerir subscrições",
"Token manager": "Gestor de tokens", "Token manager": "Gerir tokens",
"Token": "Token", "Token": "Token",
"tokens_count_0": "{{count}} token", "tokens_count": "{{count}} token",
"tokens_count_1": "{{count}} tokens", "tokens_count_plural": "{{count}} tokens",
"tokens_count_2": "{{count}} tokens",
"Import/export": "Importar / exportar", "Import/export": "Importar / exportar",
"unsubscribe": "anular subscrição", "unsubscribe": "anular subscrição",
"revoke": "revogar", "revoke": "revogar",
"Subscriptions": "Subscrições", "Subscriptions": "Subscrições",
"search": "pesquisar", "search": "pesquisar",
"Log out": "Terminar sessão", "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.", "Source available here.": "Código-fonte disponível aqui.",
"View JavaScript license information.": "Ver informações da licença JavaScript.", "View JavaScript license information.": "Ver informações da licença do JavaScript.",
"View privacy policy.": "Ver política de privacidade.", "View privacy policy.": "Ver a política de privacidade.",
"Trending": "Tendências", "Trending": "Tendências",
"Public": "Público", "Public": "Público",
"Unlisted": "Não listado", "Unlisted": "Não listado",
"Private": "Privado", "Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução", "View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x`", "Updated `x` ago": "Atualizado `x` atrás",
"Delete playlist `x`?": "Eliminar lista de reprodução `x`?", "Delete playlist `x`?": "Eliminar a lista de reprodução `x`?",
"Delete playlist": "Eliminar lista de reprodução", "Delete playlist": "Eliminar lista de reprodução",
"Create playlist": "Criar lista de reprodução", "Create playlist": "Criar lista de reprodução",
"Title": "Título", "Title": "Título",
@ -140,7 +139,7 @@
"Show more": "Mostrar mais", "Show more": "Mostrar mais",
"Show less": "Mostrar menos", "Show less": "Mostrar menos",
"Watch on YouTube": "Ver no YouTube", "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", "Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações", "Show annotations": "Mostrar anotações",
"Genre: ": "Género: ", "Genre: ": "Género: ",
@ -151,27 +150,27 @@
"Whitelisted regions: ": "Regiões permitidas: ", "Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ", "Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Partilhado `x`", "Shared `x`": "Partilhado `x`",
"Premieres in `x`": "Estreia a `x`", "Premieres in `x`": "Estreias em `x`",
"Premieres `x`": "Estreia `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, mas tenha e conta que podem levar mais tempo para carregar.", "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 YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit", "View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments": { "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" "": "Ver `x` comentários"
}, },
"View Reddit comments": "Ver comentários do Reddit", "View Reddit comments": "Ver comentários do Reddit",
"Hide replies": "Ocultar respostas", "Hide replies": "Ocultar respostas",
"Show replies": "Mostrar respostas", "Show replies": "Mostrar respostas",
"Incorrect password": "Palavra-passe incorreta", "Incorrect password": "Palavra-chave incorreta",
"Wrong answer": "Resposta errada", "Wrong answer": "Resposta errada",
"Erroneous CAPTCHA": "CAPTCHA inválido", "Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
"User ID is a required field": "O nome de utilizador é 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", "Password is a required field": "Palavra-chave é um campo obrigatório",
"Wrong username or password": "Nome de utilizador ou palavra-passe incorreta", "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
"Password cannot be empty": "A palavra-passe não pode estar vazia", "Password cannot be empty": "A palavra-chave 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 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", "Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`", "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal:`x`", "channel:`x`": "canal:`x`",
@ -181,20 +180,20 @@
"Could not fetch comments": "Não foi possível obter os comentários", "Could not fetch comments": "Não foi possível obter os comentários",
"`x` ago": "`x` atrás", "`x` ago": "`x` atrás",
"Load more": "Carregar mais", "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", "Empty playlist": "Lista de reprodução vazia",
"Not a playlist.": "Não é uma lista de reprodução.", "Not a playlist.": "Não é uma lista de reprodução.",
"Playlist does not exist.": "A lista de reprodução não existe.", "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 \"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", "Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
"Erroneous challenge": "Desafio inválido", "Erroneous challenge": "Desafio inválido",
"Erroneous token": "Token inválido", "Erroneous token": "Token inválido",
"No such user": "Utilizador 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": "Inglês",
"English (auto-generated)": "Inglês (auto-gerado)", "English (auto-generated)": "Inglês (auto-gerado)",
"Afrikaans": "Africânder", "Afrikaans": "Africano",
"Albanian": "Albanês", "Albanian": "Albanês",
"Amharic": "Amárico", "Amharic": "Amárico",
"Arabic": "Árabe", "Arabic": "Árabe",
@ -210,7 +209,7 @@
"Cebuano": "Cebuano", "Cebuano": "Cebuano",
"Chinese (Simplified)": "Chinês (simplificado)", "Chinese (Simplified)": "Chinês (simplificado)",
"Chinese (Traditional)": "Chinês (tradicional)", "Chinese (Traditional)": "Chinês (tradicional)",
"Corsican": "Córsego", "Corsican": "Corso",
"Croatian": "Croata", "Croatian": "Croata",
"Czech": "Checo", "Czech": "Checo",
"Danish": "Dinamarquês", "Danish": "Dinamarquês",
@ -253,7 +252,7 @@
"Macedonian": "Macedónio", "Macedonian": "Macedónio",
"Malagasy": "Malgaxe", "Malagasy": "Malgaxe",
"Malay": "Malaio", "Malay": "Malaio",
"Malayalam": "Malaialaio", "Malayalam": "Malaiala",
"Maltese": "Maltês", "Maltese": "Maltês",
"Maori": "Maori", "Maori": "Maori",
"Marathi": "Marathi", "Marathi": "Marathi",
@ -298,37 +297,30 @@
"Yiddish": "Iídiche", "Yiddish": "Iídiche",
"Yoruba": "Ioruba", "Yoruba": "Ioruba",
"Zulu": "Zulu", "Zulu": "Zulu",
"generic_count_years_0": "{{count}} ano", "generic_count_years": "{{count}} ano",
"generic_count_years_1": "{{count}} anos", "generic_count_years_plural": "{{count}} anos",
"generic_count_years_2": "{{count}} anos", "generic_count_months": "{{count}} mês",
"generic_count_months_0": "{{count}} mês", "generic_count_months_plural": "{{count}} meses",
"generic_count_months_1": "{{count}} meses", "generic_count_weeks": "{{count}} seman",
"generic_count_months_2": "{{count}} meses", "generic_count_weeks_plural": "{{count}} semanas",
"generic_count_weeks_0": "{{count}} semana", "generic_count_days": "{{count}} dia",
"generic_count_weeks_1": "{{count}} semanas", "generic_count_days_plural": "{{count}} dias",
"generic_count_weeks_2": "{{count}} semanas", "generic_count_hours": "{{count}} hora",
"generic_count_days_0": "{{count}} dia", "generic_count_hours_plural": "{{count}} horas",
"generic_count_days_1": "{{count}} dias", "generic_count_minutes": "{{count}} minuto",
"generic_count_days_2": "{{count}} dias", "generic_count_minutes_plural": "{{count}} minutos",
"generic_count_hours_0": "{{count}} hora", "generic_count_seconds": "{{count}} segundo",
"generic_count_hours_1": "{{count}} horas", "generic_count_seconds_plural": "{{count}} segundos",
"generic_count_hours_2": "{{count}} horas", "Fallback comments: ": "Comentários alternativos: ",
"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: ",
"Popular": "Popular", "Popular": "Popular",
"Search": "Pesquisar", "Search": "Pesquisar",
"Top": "Destaques", "Top": "Destaques",
"About": "Acerca", "About": "Sobre",
"Rating: ": "Avaliação: ", "Rating: ": "Avaliação: ",
"preferences_locale_label": "Idioma: ", "preferences_locale_label": "Idioma: ",
"View as playlist": "Ver como lista de reprodução", "View as playlist": "Ver como lista de reprodução",
"Default": "Padrão", "Default": "Predefinido",
"Music": "Músicas", "Music": "Música",
"Gaming": "Jogos", "Gaming": "Jogos",
"News": "Notícias", "News": "Notícias",
"Movies": "Filmes", "Movies": "Filmes",
@ -336,9 +328,9 @@
"Download as: ": "Descarregar como: ", "Download as: ": "Descarregar como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)", "(edited)": "(editado)",
"YouTube comment permalink": "Ligação permanente do comentário no YouTube", "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
"permalink": "ligação permanente", "permalink": "hiperligação permanente",
"`x` marked it with a ❤": "`x` foi marcado com um ❤", "`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio", "Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"channel_tab_videos_label": "Vídeos", "channel_tab_videos_label": "Vídeos",
@ -346,7 +338,7 @@
"channel_tab_community_label": "Comunidade", "channel_tab_community_label": "Comunidade",
"search_filters_sort_option_relevance": "Relevância", "search_filters_sort_option_relevance": "Relevância",
"search_filters_sort_option_rating": "Avaliação", "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_sort_option_views": "Visualizações",
"search_filters_type_label": "Tipo", "search_filters_type_label": "Tipo",
"search_filters_duration_label": "Duração", "search_filters_duration_label": "Duração",
@ -361,44 +353,38 @@
"search_filters_type_option_channel": "Canal", "search_filters_type_option_channel": "Canal",
"search_filters_type_option_playlist": "Lista de reprodução", "search_filters_type_option_playlist": "Lista de reprodução",
"search_filters_type_option_movie": "Filme", "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_hd": "HD",
"search_filters_features_option_subtitles": "Legendas", "search_filters_features_option_subtitles": "Legendas",
"search_filters_features_option_c_commons": "Creative Commons", "search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_three_d": "3D", "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_four_k": "4K",
"search_filters_features_option_location": "Localização", "search_filters_features_option_location": "Localização",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"Current version: ": "Versão atual: ", "Current version: ": "Versão atual: ",
"next_steps_error_message": "Pode tentar as seguintes opções: ", "next_steps_error_message": "Pode tentar as seguintes opções: ",
"next_steps_error_message_refresh": "Recarregar", "next_steps_error_message_refresh": "Atualizar",
"next_steps_error_message_go_to_youtube": "Ir para o YouTube", "next_steps_error_message_go_to_youtube": "Ir ao YouTube",
"search_filters_title": "Filtro", "search_filters_title": "Filtro",
"generic_videos_count_0": "{{count}} vídeo", "generic_videos_count": "{{count}} vídeo",
"generic_videos_count_1": "{{count}} vídeos", "generic_videos_count_plural": "{{count}} vídeos",
"generic_videos_count_2": "{{count}} vídeos", "generic_playlists_count": "{{count}} lista de reprodução",
"generic_playlists_count_0": "{{count}} lista de reprodução", "generic_playlists_count_plural": "{{count}} listas de reprodução",
"generic_playlists_count_1": "{{count}} listas de reprodução", "generic_subscriptions_count": "{{count}} inscrição",
"generic_playlists_count_2": "{{count}} listas de reprodução", "generic_subscriptions_count_plural": "{{count}} inscrições",
"generic_subscriptions_count_0": "{{count}} subscrição", "generic_views_count": "{{count}} visualização",
"generic_subscriptions_count_1": "{{count}} subscrições", "generic_views_count_plural": "{{count}} visualizações",
"generic_subscriptions_count_2": "{{count}} subscrições", "generic_subscribers_count": "{{count}} inscrito",
"generic_views_count_0": "{{count}} visualização", "generic_subscribers_count_plural": "{{count}} inscritos",
"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",
"preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ", "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
"preferences_quality_dash_option_2160p": "2160p", "preferences_quality_dash_option_2160p": "2160p",
"subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista", "subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
"subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas", "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
"subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas",
"Popular enabled: ": "Página \"popular\" ativada: ", "Popular enabled: ": "Página \"popular\" ativada: ",
"search_message_no_results": "Nenhum resultado encontrado.", "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_region_label": "País do conteúdo: ",
"preferences_quality_dash_option_1440p": "1440p", "preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_720p": "720p", "preferences_quality_dash_option_720p": "720p",
@ -417,12 +403,10 @@
"preferences_quality_dash_option_240p": "240p", "preferences_quality_dash_option_240p": "240p",
"Video unavailable": "Vídeo não disponível", "Video unavailable": "Vídeo não disponível",
"Russian (auto-generated)": "Russo (gerado automaticamente)", "Russian (auto-generated)": "Russo (gerado automaticamente)",
"comments_view_x_replies_0": "Ver {{count}} resposta", "comments_view_x_replies": "Ver {{count}} resposta",
"comments_view_x_replies_1": "Ver {{count}} respostas", "comments_view_x_replies_plural": "Ver {{count}} respostas",
"comments_view_x_replies_2": "Ver {{count}} respostas", "comments_points_count": "{{count}} ponto",
"comments_points_count_0": "{{count}} ponto", "comments_points_count_plural": "{{count}} pontos",
"comments_points_count_1": "{{count}} pontos",
"comments_points_count_2": "{{count}} pontos",
"English (United Kingdom)": "Inglês (Reino Unido)", "English (United Kingdom)": "Inglês (Reino Unido)",
"Chinese (Hong Kong)": "Chinês (Hong Kong)", "Chinese (Hong Kong)": "Chinês (Hong Kong)",
"Chinese (Taiwan)": "Chinês (Taiwan)", "Chinese (Taiwan)": "Chinês (Taiwan)",
@ -448,13 +432,13 @@
"videoinfo_watch_on_youTube": "Ver no YouTube", "videoinfo_watch_on_youTube": "Ver no YouTube",
"videoinfo_youTube_embed_link": "Incorporar", "videoinfo_youTube_embed_link": "Incorporar",
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado", "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", "none": "nenhum",
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`", "videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
"download_subtitles": "Legendas - `x` (.vtt)", "download_subtitles": "Legendas - `x` (.vtt)",
"user_created_playlists": "`x` listas de reprodução criadas", "user_created_playlists": "`x` listas de reprodução criadas",
"user_saved_playlists": "`x` listas de reprodução guardadas", "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)", "Turkish (auto-generated)": "Turco (gerado automaticamente)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)", "Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
"Chinese (China)": "Chinês (China)", "Chinese (China)": "Chinês (China)",
@ -474,49 +458,18 @@
"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_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:", "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_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.", "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_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
"crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</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: ", "Artist: ": "Artista: ",
"Album: ": "Álbum: ", "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_playlists_label": "Listas de reprodução",
"channel_tab_channels_label": "Canais", "channel_tab_channels_label": "Canais",
"Music in this video": "Música neste vídeo", "Music in this video": "Música neste vídeo",
"channel_tab_shorts_label": "Curtos", "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"
} }

View File

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

View File

@ -515,7 +515,5 @@
"carousel_slide": "Пролистано {{current}} из {{total}}", "carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё", "carousel_skip": "Пропустить всё",
"carousel_go_to": "Перейти к странице `x`", "carousel_go_to": "Перейти к странице `x`",
"preferences_preload_label": "Предзагрузка видеоданных: ", "preferences_preload_label": "Предзагрузка видеоданных: "
"channel_tab_courses_label": "Курсы",
"channel_tab_posts_label": "Записи"
} }

View File

@ -494,9 +494,5 @@
"carousel_slide": "Diapozitiv {{current}} nga {{total}}", "carousel_slide": "Diapozitiv {{current}} nga {{total}}",
"carousel_go_to": "Kalo te diapozitivi `x`", "carousel_go_to": "Kalo te diapozitivi `x`",
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)", "Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
"preferences_preload_label": "Parangarko të dhëna videoje: ", "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ë"
} }

View File

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

View File

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

View File

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

View File

@ -497,9 +497,5 @@
"carousel_skip": "Kayar menüyü atla", "carousel_skip": "Kayar menüyü atla",
"carousel_go_to": "`x` sunumuna git", "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ı.", "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: ", "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"
} }

View File

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

View File

@ -314,11 +314,11 @@
"search_filters_duration_label": "Thời lượng", "search_filters_duration_label": "Thời lượng",
"search_filters_features_label": "Đặc điểm", "search_filters_features_label": "Đặc điểm",
"search_filters_sort_label": "Sắp xếp theo", "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_today": "Hôm nay",
"search_filters_date_option_week": "Tuần này", "search_filters_date_option_week": "Tuần này",
"search_filters_date_option_month": "Tháng 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_video": "video",
"search_filters_type_option_channel": "Kênh", "search_filters_type_option_channel": "Kênh",
"search_filters_type_option_playlist": "Danh sách phát", "search_filters_type_option_playlist": "Danh sách phát",
@ -479,8 +479,5 @@
"carousel_skip": "Bỏ qua Carousel", "carousel_skip": "Bỏ qua Carousel",
"carousel_go_to": "Đi tới trang `x`", "carousel_go_to": "Đi tới trang `x`",
"Search for videos": "Tìm kiếm video", "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ý.", "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"
} }

View File

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

View File

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

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: exception_page:
git: https://github.com/crystal-loot/exception_page.git git: https://github.com/crystal-loot/exception_page.git
version: 0.4.1 version: 0.2.2
http_proxy: http_proxy:
git: https://github.com/mamantoha/http_proxy.git git: https://github.com/mamantoha/http_proxy.git
@ -26,7 +26,11 @@ shards:
kemal: kemal:
git: https://github.com/kemalcr/kemal.git 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: pg:
git: https://github.com/will/crystal-pg.git git: https://github.com/will/crystal-pg.git

View File

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

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 filesize = data.bytesize
attachment(env, filename, disposition) 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) file = IO::Memory.new(data)
if env.request.method == "GET" && env.request.headers.has_key?("Range") if env.request.method == "GET" && env.request.headers.has_key?("Range")

View File

@ -17,8 +17,10 @@
require "digest/md5" require "digest/md5"
require "file_utils" require "file_utils"
# Require kemal, then our own overrides # Require kemal, kilt, then our own overrides
require "kemal" require "kemal"
require "kilt"
require "./ext/kemal_content_for.cr"
require "./ext/kemal_static_file_handler.cr" require "./ext/kemal_static_file_handler.cr"
require "http_proxy" require "http_proxy"
@ -47,8 +49,7 @@ require "./invidious/channels/*"
require "./invidious/user/*" require "./invidious/user/*"
require "./invidious/search/*" require "./invidious/search/*"
require "./invidious/routes/**" require "./invidious/routes/**"
require "./invidious/jobs/base_job" require "./invidious/jobs/**"
require "./invidious/jobs/*"
# Declare the base namespace for invidious # Declare the base namespace for invidious
module Invidious module Invidious
@ -96,10 +97,6 @@ YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
COMPANION_POOL = CompanionConnectionPool.new(
capacity: CONFIG.pool_size
)
# CLI # CLI
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
@ -170,9 +167,16 @@ DECRYPT_FUNCTION =
if sig_helper_address = CONFIG.signature_server.presence if sig_helper_address = CONFIG.signature_server.presence
IV::DecryptFunction.new(sig_helper_address) IV::DecryptFunction.new(sig_helper_address)
else else
LOGGER.warn("WARNING: inv-sig-helper is required for video playback. For more information see https://docs.invidious.io/installation")
nil nil
end end
{% for field in %w(po_token visitor_data) %}
if !CONFIG.{{field.id}}
LOGGER.warn("WARNING: {{field.id}} is required to view and playback videos. For more information see https://docs.invidious.io/installation")
end
{% end %}
# Start jobs # Start jobs
if CONFIG.channel_threads > 0 if CONFIG.channel_threads > 0
@ -225,8 +229,8 @@ error 500 do |env, ex|
error_template(500, ex) error_template(500, ex)
end end
static_headers do |env| static_headers do |response|
env.response.headers.add("Cache-Control", "max-age=2629800") response.headers.add("Cache-Control", "max-age=2629800")
end end
# Init Kemal # Init Kemal

View File

@ -35,7 +35,7 @@ struct ConfigPreferences
property max_results : Int32 = 40 property max_results : Int32 = 40
property notifications_only : Bool = false property notifications_only : Bool = false
property player_style : String = "invidious" property player_style : String = "invidious"
property quality : String = "dash" property quality : String = "hd720"
property quality_dash : String = "auto" property quality_dash : String = "auto"
property default_home : String? = "Popular" property default_home : String? = "Popular"
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
@ -74,16 +74,6 @@ end
class Config class Config
include YAML::Serializable include YAML::Serializable
class CompanionConfig
include YAML::Serializable
@[YAML::Field(converter: Preferences::URIConverter)]
property private_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("")
end
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1 property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update). # Time interval between two executions of the job that crawls channel videos (subscriptions update).
@ -170,12 +160,6 @@ class Config
# poToken for passing bot attestation # poToken for passing bot attestation
property po_token : String? = nil property po_token : String? = nil
# Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig
# Invidious companion API key
property invidious_companion_key : String = ""
# Saved cookies in "name1=value1; name2=value2..." format # Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new property cookies : HTTP::Cookies = HTTP::Cookies.new
@ -256,27 +240,6 @@ class Config
end end
{% end %} {% end %}
if config.invidious_companion.present?
# invidious_companion and signature_server can't work together
if config.signature_server
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
exit(1)
elsif config.invidious_companion_key.empty?
puts "Config: Please configure a key if you are using invidious companion."
exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!"
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
exit(1)
elsif config.invidious_companion_key.size != 16
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
exit(1)
end
elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
else
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/")
end
# HMAC_key is mandatory # HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854 # See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty? if config.hmac_key.empty?

View File

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

View File

@ -23,16 +23,10 @@ module Invidious::Frontend::WatchPage
return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>" return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>"
end end
url = "/download"
if (CONFIG.invidious_companion.present?)
invidious_companion = CONFIG.invidious_companion.sample
url = "#{invidious_companion.public_url}/download?check=#{invidious_companion_encrypt(video.id)}"
end
return String.build(4000) do |str| return String.build(4000) do |str|
str << "<form" str << "<form"
str << " class=\"pure-form pure-form-stacked\"" str << " class=\"pure-form pure-form-stacked\""
str << " action='#{url}'" str << " action='/download'"
str << " method='post'" str << " method='post'"
str << " rel='noopener'" str << " rel='noopener'"
str << " target='_blank'>" str << " target='_blank'>"

View File

@ -18,7 +18,16 @@ def github_details(summary : String, content : String)
return HTML.escape(details) return HTML.escape(details)
end 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_title = "#{exception.message} (#{exception.class})"
issue_template = <<-TEXT 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) 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 # URLs for the error message below
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
url_search_issues = "https://github.com/iv-org/invidious/issues" 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> <p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
<!-- TODO: Add a "copy to clipboard" button --> <!-- 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> </div>
END_HTML END_HTML

View File

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

View File

@ -291,55 +291,6 @@ struct SearchHashtag
end end
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 class Category
include DB::Serializable include DB::Serializable
@ -382,4 +333,4 @@ struct Continuation
end end
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 end
referer = referer.request_target 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 if referer == env.request.path
referer = fallback referer = fallback
@ -383,22 +383,3 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
end end
return text return text
end end
def encrypt_ecb_without_salt(data, key)
cipher = OpenSSL::Cipher.new("aes-128-ecb")
cipher.encrypt
cipher.key = key
io = IO::Memory.new
io.write(cipher.update(data))
io.write(cipher.final)
io.rewind
return io
end
def invidious_companion_encrypt(data)
timestamp = Time.utc.to_unix
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
return Base64.urlsafe_encode(encrypted_data)
end

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

View File

@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]?
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
end
# Since some implementations create playlists based on resolution regardless of different codecs, # Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation # we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }

View File

@ -20,6 +20,14 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff" 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 # Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed") if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' file: http: https:" frame_ancestors = "'self' file: http: https:"
@ -37,7 +45,7 @@ module Invidious::Routes::BeforeAll
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self'", "connect-src 'self'",
"manifest-src 'self'", "manifest-src 'self'",
"media-src 'self' blob:", "media-src 'self' blob:" + extra_media_csp,
"child-src 'self' blob:", "child-src 'self' blob:",
"frame-src 'self'", "frame-src 'self'",
"frame-ancestors " + frame_ancestors, "frame-ancestors " + frame_ancestors,
@ -102,21 +110,6 @@ module Invidious::Routes::BeforeAll
preferences.locale = locale preferences.locale = locale
env.set "preferences", preferences 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 current_page = env.request.path
if env.request.query if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!) query = HTTP::Params.parse(env.request.query.not_nil!)

View File

@ -12,15 +12,13 @@ module Invidious::Routes::Embed
url = "/playlist?list=#{plid}" url = "/playlist?list=#{plid}"
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end end
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_template(404, ex) return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end
url = "/embed/#{first_playlist_video}?#{env.params.query}" url = "/embed/#{videos[0].id}?#{env.params.query}"
if env.params.query.size > 0 if env.params.query.size > 0
url += "?#{env.params.query}" url += "?#{env.params.query}"
@ -74,15 +72,13 @@ module Invidious::Routes::Embed
url = "/playlist?list=#{plid}" url = "/playlist?list=#{plid}"
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
end end
first_playlist_video = videos[0].as(PlaylistVideo)
rescue ex : NotFoundException rescue ex : NotFoundException
return error_template(404, ex) return error_template(404, ex)
rescue ex rescue ex
return error_template(500, ex) return error_template(500, ex)
end end
url = "/embed/#{first_playlist_video.id}" url = "/embed/#{videos[0].id}"
elsif video_series elsif video_series
url = "/embed/#{video_series.shift}" url = "/embed/#{video_series.shift}"
env.params.query["playlist"] = video_series.join(",") env.params.query["playlist"] = video_series.join(",")
@ -207,14 +203,6 @@ module Invidious::Routes::Embed
return env.redirect url return env.redirect url
end end
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
end
rendered "embed" rendered "embed"
end end
end end

View File

@ -202,7 +202,7 @@ module Invidious::Routes::Feeds
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
xml.element("id") { xml.text "yt:channel:#{ucid}" } xml.element("id") { xml.text "yt:channel:#{ucid}" }
xml.element("yt:channelId") { xml.text 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("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}")
xml.element("author") do xml.element("author") do
@ -296,13 +296,7 @@ module Invidious::Routes::Feeds
xml.element("name") { xml.text playlist.author } xml.element("name") { xml.text playlist.author }
end end
videos.each do |video| videos.each &.to_xml(xml)
if video.is_a? PlaylistVideo
video.to_xml(xml)
else
video.to_xml(env, locale, xml)
end
end
end end
end end
else else

View File

@ -21,6 +21,9 @@ module Invidious::Routes::Login
account_type = env.params.query["type"]? account_type = env.params.query["type"]?
account_type ||= "invidious" account_type ||= "invidious"
captcha_type = env.params.query["captcha"]?
captcha_type ||= "image"
templated "user/login" templated "user/login"
end end
@ -85,14 +88,34 @@ module Invidious::Routes::Login
password = password.byte_slice(0, 55) password = password.byte_slice(0, 55)
if CONFIG.captcha_enabled if CONFIG.captcha_enabled
captcha_type = env.params.body["captcha_type"]?
answer = env.params.body["answer"]? answer = env.params.body["answer"]?
change_type = env.params.body["change_type"]?
if !captcha_type || change_type
if change_type
captcha_type = change_type
end
captcha_type ||= "image"
account_type = "invidious" account_type = "invidious"
if captcha_type == "image"
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) 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 } 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 = answer.lstrip('0')
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
@ -101,8 +124,27 @@ module Invidious::Routes::Login
rescue ex rescue ex
return error_template(400, ex) return error_template(400, ex)
end end
else else # "text"
return templated "user/login" 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
end end

View File

@ -58,11 +58,7 @@ module Invidious::Routes::Search
end end
begin begin
if user
items = query.process(user.as(User))
else
items = query.process items = query.process
end
rescue ex : ChannelSearchException 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'.") 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 rescue ex

View File

@ -21,7 +21,7 @@ module Invidious::Routes::VideoPlayback
end end
# Sanity check, to avoid being used as an open proxy # Sanity check, to avoid being used as an open proxy
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/) if !host.matches?(/[\w-]+.googlevideo.com/)
return error_template(400, "Invalid \"host\" parameter.") return error_template(400, "Invalid \"host\" parameter.")
end end
@ -37,8 +37,7 @@ module Invidious::Routes::VideoPlayback
# See: https://github.com/iv-org/invidious/issues/3302 # See: https://github.com/iv-org/invidious/issues/3302
range_header = env.request.headers["Range"]? range_header = env.request.headers["Range"]?
sq = query_params["sq"]? if range_header.nil?
if range_header.nil? && sq.nil?
range_for_head = query_params["range"]? || "0-640" range_for_head = query_params["range"]? || "0-640"
headers["Range"] = "bytes=#{range_for_head}" headers["Range"] = "bytes=#{range_for_head}"
end end
@ -257,11 +256,6 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours, # YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version # so we have a mechanism here to redirect to the latest version
def self.latest_version(env) def self.latest_version(env)
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
end
id = env.params.query["id"]? id = env.params.query["id"]?
itag = env.params.query["itag"]?.try &.to_i? itag = env.params.query["itag"]?.try &.to_i?

View File

@ -192,14 +192,6 @@ module Invidious::Routes::Watch
captions: video.captions captions: video.captions
) )
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
end
templated "watch" templated "watch"
end end
@ -293,9 +285,6 @@ module Invidious::Routes::Watch
if CONFIG.disabled?("downloads") if CONFIG.disabled?("downloads")
return error_template(403, "Administrator has disabled this endpoint.") return error_template(403, "Administrator has disabled this endpoint.")
end end
if CONFIG.invidious_companion.present?
return error_template(403, "Downloads should be routed through Companion when present")
end
title = env.params.body["title"]? || "" title = env.params.body["title"]? || ""
video_id = env.params.body["id"]? || "" video_id = env.params.body["id"]? || ""
@ -325,9 +314,10 @@ module Invidious::Routes::Watch
env.params.query["label"] = URI.decode_www_form(label.as_s) env.params.query["label"] = URI.decode_www_form(label.as_s)
return Invidious::Routes::API::V1::Videos.captions(env) return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i.to_s elsif itag = download_widget["itag"]?.try &.as_i
# URL params specific to /latest_version # URL params specific to /latest_version
env.params.query["id"] = video_id env.params.query["id"] = video_id
env.params.query["itag"] = itag.to_s
env.params.query["title"] = filename env.params.query["title"] = filename
env.params.query["local"] = "true" env.params.query["local"] = "true"

View File

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

View File

@ -4,6 +4,8 @@ struct Invidious::User
module Captcha module Captcha
extend self extend self
private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
def generate_image(key) def generate_image(key)
second = Random::Secure.rand(12) second = Random::Secure.rand(12)
second_angle = second * 30 second_angle = second * 30
@ -58,5 +60,19 @@ struct Invidious::User
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
} }
end 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
end end

View File

@ -15,7 +15,7 @@ struct Video
# NOTE: don't forget to bump this number if any change is made to # NOTE: don't forget to bump this number if any change is made to
# the `params` structure in videos/parser.cr!!! # the `params` structure in videos/parser.cr!!!
# #
SCHEMA_VERSION = 3 SCHEMA_VERSION = 2
property id : String property id : String

View File

@ -82,7 +82,7 @@ def extract_video_info(video_id : String)
"reason" => JSON::Any.new(reason), "reason" => JSON::Any.new(reason),
} }
end 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. # YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713 # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue. # Line to be reverted if one day we solve the video not available issue.
@ -108,36 +108,22 @@ def extract_video_info(video_id : String)
params = parse_video_info(video_id, player_response) params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason params["reason"] = JSON::Any.new(reason) if reason
if !CONFIG.invidious_companion.present? if player_response["streamingData"]? && player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") 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::WebMobile, YoutubeAPI::ClientType::TvHtml5]
players_fallback.each do |player_fallback| players_fallback.each do |player_fallback|
client_config.client_type = player_fallback client_config.client_type = player_fallback
player_fallback_response = try_fetch_streaming_data(video_id, client_config)
next if !(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")
if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url")
streaming_data = player_response["streamingData"].as_h streaming_data = player_response["streamingData"].as_h
streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"]
player_response["streamingData"] = JSON::Any.new(streaming_data) player_response["streamingData"] = JSON::Any.new(streaming_data)
break break
end end
rescue InfoException
next LOGGER.warn("Failed to fetch streams with #{player_fallback}")
end end
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| {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]? params[f] = player_response[f] if player_response[f]?
end end
@ -166,7 +152,7 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf
playability_status = response["playabilityStatus"]["status"] playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_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. # YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713 # See: https://github.com/TeamNewPipe/NewPipe/issues/8713
raise InfoException.new( raise InfoException.new(

View File

@ -1,6 +1,6 @@
<%- <%-
thin_mode = env.get("preferences").as(Preferences).thin_mode 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 author_verified = item.responds_to?(:author_verified) && item.author_verified
-%> -%>
@ -97,18 +97,6 @@
</div> </div>
</div> </div>
<% when Category %> <% 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 %> <% else %>
<%- <%-
# `endpoint_params` is used for the "video-context-buttons" component # `endpoint_params` is used for the "video-context-buttons" component

View File

@ -22,8 +22,6 @@
audio_streams.each_with_index do |fmt, i| audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
bitrate = fmt["bitrate"] bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)
@ -36,12 +34,8 @@
<% end %> <% end %>
<% end %> <% end %>
<% else %> <% else %>
<% if params.quality == "dash" <% if params.quality == "dash" %>
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1" <source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
%>
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
<% end %> <% end %>
<% <%
@ -50,8 +44,6 @@
fmt_stream.each_with_index do |fmt, i| fmt_stream.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
quality = fmt["quality"] quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)

View File

@ -9,6 +9,90 @@
<body> <body>
<h1><%= translate(locale, "JavaScript license information") %></h1> <h1><%= translate(locale, "JavaScript license information") %></h1>
<table id="jslicense-labels1"> <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> <tr>
<td> <td>
<a href="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>">silvermine-videojs-quality-selector.min.js</a> <a href="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>">silvermine-videojs-quality-selector.min.js</a>
@ -37,6 +121,34 @@
</td> </td>
</tr> </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> <tr>
<td> <td>
<a href="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>">videojs-contrib-quality-levels.js</a> <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> </td>
</tr> </tr>
<%- {% for row in run("../../../scripts/generate_js_licenses.cr").stringify.split('\n') %} %> <tr>
<%-= {{row.id}} -%> <td>
<% {% end %} -%> <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> </table>
</body> </body>
</html> </html>

View File

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

View File

@ -46,43 +46,6 @@ struct YoutubeConnectionPool
end 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) def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" 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["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"
@ -98,9 +61,9 @@ def add_yt_headers(request)
end end
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true) def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false)
client = HTTP::Client.new(url) client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
# Force the usage of a specific configured IP Family # Force the usage of a specific configured IP Family
if force_resolve if force_resolve
@ -115,8 +78,8 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you
return client return client
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &) def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy) client = make_client(url, region, force_resolve: force_resolve)
begin begin
yield client yield client
ensure ensure

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

View File

@ -500,12 +500,8 @@ module YoutubeAPI
data["params"] = params data["params"] = params
end end
if CONFIG.invidious_companion.present?
return self._post_invidious_companion("/youtubei/v1/player", data)
else
return self._post_json("/youtubei/v1/player", data, client_config) return self._post_json("/youtubei/v1/player", data, client_config)
end end
end
#################################################################### ####################################################################
# resolve_url(url, client_config?) # resolve_url(url, client_config?)
@ -670,49 +666,6 @@ module YoutubeAPI
return initial_data return initial_data
end end
####################################################################
# _post_invidious_companion(endpoint, data)
#
# Internal function that does the actual request to Invidious companion
# and handles errors.
#
# The requested data is an endpoint (URL without the domain part)
# and the data as a Hash object.
#
def _post_invidious_companion(
endpoint : String,
data : Hash,
) : Hash(String, JSON::Any)
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
"Authorization" => "Bearer #{CONFIG.invidious_companion_key}",
}
# Logging
LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("Invidious companion: POST data: #{data}")
# Send the POST request
begin
response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json)
body = response.body
if (response.status_code != 200)
raise Exception.new(
"Error while communicating with Invidious companion: \
status code: #{response.status_code} and body: #{body.dump}"
)
end
rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end
# Convert result to Hash
initial_data = JSON.parse(body).as_h
return initial_data
end
#################################################################### ####################################################################
# _decompress(body_io, headers) # _decompress(body_io, headers)
# #