mirror of
https://github.com/iv-org/invidious.git
synced 2025-05-30 21:51:37 +00:00
Compare commits
2 Commits
master
...
v2.2025050
Author | SHA1 | Date | |
---|---|---|---|
![]() |
aa7de1ed4c | ||
![]() |
5f1f8ff4b1 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@ -1,3 +1,6 @@
|
||||
# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review.
|
||||
* @iv-org/developers
|
||||
|
||||
docker-compose.yml @unixfox
|
||||
docker/ @unixfox
|
||||
kubernetes/ @unixfox
|
||||
|
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
@ -1,10 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: "weekly"
|
@ -50,7 +50,7 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker AMD64 image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@ -75,7 +75,7 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker ARM64 image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.arm64
|
||||
|
4
.github/workflows/build-stable-container.yml
vendored
4
.github/workflows/build-stable-container.yml
vendored
@ -43,7 +43,7 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker AMD64 image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@ -69,7 +69,7 @@ jobs:
|
||||
quay.expires-after=12w
|
||||
|
||||
- name: Build and push Docker ARM64 image for Push Event
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.arm64
|
||||
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@ -38,11 +38,10 @@ jobs:
|
||||
matrix:
|
||||
stable: [true]
|
||||
crystal:
|
||||
- 1.12.2
|
||||
- 1.13.3
|
||||
- 1.14.1
|
||||
- 1.15.1
|
||||
- 1.16.3
|
||||
- 1.12.1
|
||||
- 1.13.2
|
||||
- 1.14.0
|
||||
- 1.15.0
|
||||
include:
|
||||
- crystal: nightly
|
||||
stable: false
|
||||
@ -58,12 +57,12 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.8.2
|
||||
uses: crystal-lang/install-crystal@v1.8.0
|
||||
with:
|
||||
crystal: ${{ matrix.crystal }}
|
||||
|
||||
- name: Cache Shards
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./lib
|
||||
@ -114,7 +113,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker ARM64 image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.arm64
|
||||
@ -137,12 +136,12 @@ jobs:
|
||||
|
||||
- name: Install Crystal
|
||||
id: lint_step_install_crystal
|
||||
uses: crystal-lang/install-crystal@v1.8.2
|
||||
uses: crystal-lang/install-crystal@v1.8.0
|
||||
with:
|
||||
crystal: latest
|
||||
|
||||
- name: Cache Shards
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./lib
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 730
|
||||
|
@ -2,15 +2,11 @@
|
||||
|
||||
## vX.Y.0 (future)
|
||||
|
||||
## v2.20250517.0
|
||||
|
||||
Inverse fallback for the YouTube client from TVHTML then MWEB. Fixes https://github.com/iv-org/invidious/issues/5273
|
||||
|
||||
## v2.20250504.0
|
||||
|
||||
Small release with quick workaround fix for issue #4251 (Nil assertion failed).
|
||||
|
||||
PR: https://github.com/iv-org/invidious/issues/5262
|
||||
PR: https://github.com/iv-org/invidious/issues/5263
|
||||
|
||||
## v2.20250314.0
|
||||
|
||||
|
14
README.md
14
README.md
@ -81,9 +81,9 @@
|
||||
- [Available in many languages](locales/), thanks to [our translators](#contribute)
|
||||
|
||||
**Data import/export**
|
||||
- Import subscriptions from YouTube, NewPipe and FreeTube
|
||||
- Import subscriptions from YouTube, NewPipe and Freetube
|
||||
- Import watch history from YouTube and NewPipe
|
||||
- Export subscriptions to NewPipe and FreeTube
|
||||
- Export subscriptions to NewPipe and Freetube
|
||||
- Import/Export Invidious user data
|
||||
|
||||
**Technical features**
|
||||
@ -95,11 +95,11 @@
|
||||
|
||||
## Quick start
|
||||
|
||||
**Using Invidious:**
|
||||
**Using invidious:**
|
||||
|
||||
- [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/)
|
||||
|
||||
@ -114,8 +114,8 @@ https://github.com/iv-org/documentation
|
||||
### Extensions
|
||||
|
||||
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
|
||||
embedded YouTube videos on other websites with Invidious.
|
||||
a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
|
||||
embedded youtube videos on other websites 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/.
|
||||
|
||||
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
|
||||
|
@ -550,10 +550,6 @@ span > select {
|
||||
color: #565d64;
|
||||
}
|
||||
|
||||
.light-theme .error-card {
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.no-theme a:hover,
|
||||
.no-theme a:active,
|
||||
@ -600,10 +596,6 @@ span > select {
|
||||
.light-theme .pure-menu-heading {
|
||||
color: #565d64;
|
||||
}
|
||||
|
||||
.no-theme .error-card {
|
||||
border: 1px solid black;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -666,10 +658,6 @@ body.dark-theme {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark-theme .error-card {
|
||||
border: 1px solid #5e5e5e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.no-theme a:hover,
|
||||
.no-theme a:active,
|
||||
@ -731,10 +719,6 @@ body.dark-theme {
|
||||
.no-theme footer a {
|
||||
color: #adadad !important;
|
||||
}
|
||||
|
||||
.no-theme .error-card {
|
||||
border: 1px solid #5e5e5e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -832,57 +816,3 @@ h1, h2, h3, h4, h5, p,
|
||||
#download_widget {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 25px;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-card > .explanation {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
grid-template-rows: 1fr max-content;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.error-card > .explanation > i {
|
||||
color: #f44;
|
||||
font-size: 24px;
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.error-card > .explanation > h4 {
|
||||
grid-area: 1 / 2 / 2 / 3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-card > .explanation > p {
|
||||
grid-area: 2 / 2 / 3 / 3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-card details {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-card summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-card pre {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.error-issue-template {
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.12345);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
#filters-collapse summary {
|
||||
summary {
|
||||
/* This should hide the marker */
|
||||
display: block;
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#filters-collapse summary::-webkit-details-marker,
|
||||
#filters-collapse summary::marker { display: none; }
|
||||
summary::-webkit-details-marker,
|
||||
summary::marker { display: none; }
|
||||
|
||||
#filters-collapse summary:before {
|
||||
summary:before {
|
||||
border-radius: 5px;
|
||||
content: "[ + ]";
|
||||
margin: -2px 10px 0 10px;
|
||||
@ -20,7 +20,7 @@
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
#filters-collapse details[open] > summary:before { content: "[ − ]"; }
|
||||
details[open] > summary:before { content: "[ − ]"; }
|
||||
|
||||
|
||||
#filters-box {
|
||||
|
@ -54,53 +54,6 @@ db:
|
||||
##
|
||||
#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.
|
||||
##
|
||||
## Accepted values: dash, hd720, medium, small
|
||||
## Default: dash
|
||||
## Default: hd720
|
||||
##
|
||||
#quality: dash
|
||||
#quality: hd720
|
||||
|
||||
##
|
||||
## Default dash video quality.
|
||||
|
@ -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
|
||||
|
||||
@ -32,7 +32,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
|
||||
--link-flags "-lxml2 -llzma"; \
|
||||
fi
|
||||
|
||||
FROM alpine:3.21
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||
WORKDIR /invidious
|
||||
RUN addgroup -g 1000 -S invidious && \
|
||||
|
@ -1,5 +1,5 @@
|
||||
FROM alpine:3.21 AS builder
|
||||
RUN apk add --no-cache 'crystal=1.14.0-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \
|
||||
FROM alpine:3.20 AS builder
|
||||
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
|
||||
|
||||
ARG release
|
||||
@ -33,7 +33,7 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
|
||||
--link-flags "-lxml2 -llzma"; \
|
||||
fi
|
||||
|
||||
FROM alpine:3.21
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||
WORKDIR /invidious
|
||||
RUN addgroup -g 1000 -S invidious && \
|
||||
|
@ -154,8 +154,8 @@
|
||||
"View YouTube comments": "عرض تعليقات اليوتيوب",
|
||||
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت",
|
||||
"View `x` comments": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليق",
|
||||
"": "عرض `x` تعليقات"
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات",
|
||||
"": "عرض `x` تعليقات."
|
||||
},
|
||||
"View Reddit comments": "عرض تعليقات ريديت",
|
||||
"Hide replies": "إخفاء الردود",
|
||||
@ -566,8 +566,5 @@
|
||||
"carousel_skip": "تخطي الكاروسيل",
|
||||
"carousel_go_to": "انتقل إلى الشريحة `x`",
|
||||
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
|
||||
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)",
|
||||
"channel_tab_courses_label": "الدورات",
|
||||
"channel_tab_posts_label": "المنشورات",
|
||||
"First page": "الصفحة الأولى"
|
||||
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
|
||||
}
|
||||
|
@ -403,7 +403,7 @@
|
||||
"comments_view_x_replies": "Виж {{count}} отговор",
|
||||
"comments_view_x_replies_plural": "Виж {{count}} отговора",
|
||||
"footer_original_source_code": "Оригинален изходен код",
|
||||
"Import YouTube subscriptions": "Импортиране на YouTube-CSV/OPML абонаменти",
|
||||
"Import YouTube subscriptions": "Импортиране на YouTube/OPML абонаменти",
|
||||
"Lithuanian": "Литовски",
|
||||
"Nyanja": "Нянджа",
|
||||
"Updated `x` ago": "Актуализирано преди `x`",
|
||||
@ -493,8 +493,5 @@
|
||||
"Add to playlist: ": "Добави към плейлист: ",
|
||||
"Answer": "Отговор",
|
||||
"Search for videos": "Търсене на видеа",
|
||||
"The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора.",
|
||||
"Filipino (auto-generated)": "Филипински (автоматично генериран)",
|
||||
"preferences_preload_label": "Предварително заредете видео данни: ",
|
||||
"First page": "Първа страница"
|
||||
"The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора."
|
||||
}
|
||||
|
@ -204,7 +204,7 @@
|
||||
"View JavaScript license information.": "Consulta la informació de la llicència de JavaScript.",
|
||||
"Playlist privacy": "Privacitat de la llista de reproducció",
|
||||
"search_message_no_results": "No s'han trobat resultats.",
|
||||
"search_message_use_another_instance": "També es pot <a href=\"`x`\">cercar en una altra instància</a>.",
|
||||
"search_message_use_another_instance": " També es pot <a href=\"`x`\">buscar en una altra instància</a>.",
|
||||
"Genre: ": "Gènere: ",
|
||||
"Hidden field \"challenge\" is a required field": "El camp ocult \"repte\" és un camp obligatori",
|
||||
"Burmese": "Birmà",
|
||||
@ -489,16 +489,5 @@
|
||||
"generic_button_delete": "Suprimeix",
|
||||
"Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)",
|
||||
"Answer": "Resposta",
|
||||
"toggle_theme": "Commuta el tema",
|
||||
"Add to playlist": "Afegeix a la llista de reproducció",
|
||||
"Add to playlist: ": "Afegeix a la llista de reproducció: ",
|
||||
"Search for videos": "Cercar vídeos",
|
||||
"carousel_slide": "Diapositiva {{current}} de {{total}}",
|
||||
"preferences_preload_label": "Precarregar dades del vídeo: ",
|
||||
"carousel_go_to": "Anar a la diapositiva `x`",
|
||||
"First page": "Primera pàgina",
|
||||
"Filipino (auto-generated)": "Filipí (generat automàticament)",
|
||||
"channel_tab_courses_label": "Cursos",
|
||||
"channel_tab_posts_label": "Missatges",
|
||||
"carousel_skip": "Saltar l'exhibició"
|
||||
"toggle_theme": "Commuta el tema"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Přeskočit galerii",
|
||||
"carousel_go_to": "Přejít na snímek `x`",
|
||||
"preferences_preload_label": "Předem načíst data videa: ",
|
||||
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)",
|
||||
"First page": "První stránka",
|
||||
"channel_tab_courses_label": "Kurzy",
|
||||
"channel_tab_posts_label": "Příspěvky"
|
||||
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
|
||||
}
|
||||
|
@ -141,7 +141,7 @@
|
||||
"An alternative front-end to YouTube": "Pen blaen amgen i YouTube",
|
||||
"source": "ffynhonnell",
|
||||
"Log in": "Mewngofnodi",
|
||||
"Log in/register": "Mewngofnodi/cofrestru",
|
||||
"Log in/register": "Mewngofnodi/Cofrestru",
|
||||
"User ID": "Enw defnyddiwr",
|
||||
"preferences_quality_option_dash": "DASH (ansawdd addasol)",
|
||||
"Sign In": "Mewngofnodi",
|
||||
@ -381,32 +381,5 @@
|
||||
"channel_tab_channels_label": "Sianeli",
|
||||
"channel_tab_community_label": "Cymuned",
|
||||
"channel_tab_shorts_label": "Fideos byrion",
|
||||
"channel_tab_videos_label": "Fideos",
|
||||
"generic_playlists_count_0": "{{count}} rhestr chwarae",
|
||||
"generic_playlists_count_1": "{{count}} rhestr chwarae",
|
||||
"generic_playlists_count_2": "{{count}} rhestri chwarae",
|
||||
"generic_playlists_count_3": "{{count}} rhestri chwarae",
|
||||
"generic_playlists_count_4": "{{count}} rhestri chwarae",
|
||||
"generic_playlists_count_5": "{{count}} rhestri chwarae",
|
||||
"New passwords must match": "Rhaid i'r cyfrineiriau newydd cyfateb â'i gilydd",
|
||||
"last": "diwethaf",
|
||||
"First page": "Tudalen gyntaf",
|
||||
"preferences_preload_label": "Cynlwytho data fideo: ",
|
||||
"preferences_extend_desc_label": "Ymestyn disgrifiad fideo'n awtomatig: ",
|
||||
"preferences_vr_mode_label": "Fideos rhyngweithiol 360 gradd (angen WebGL): ",
|
||||
"preferences_video_loop_label": "Doleniwch bob amser: ",
|
||||
"Top enabled: ": "Tudalen fideos brig wedi'i alluogi: ",
|
||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Allforio tanysgrifiadau ar fformat OPML (i NewPipe a FreeTube)",
|
||||
"Export subscriptions as OPML": "Allforio tanysgrifiadau ar fformat OPML",
|
||||
"preferences_annotations_subscribed_label": "Ddangos nodiadau sianeli tanysgrifiwyd fel rhagosodiad? ",
|
||||
"Redirect homepage to feed: ": "Ailgyfeirio tudalen gartref i'r borthiant: ",
|
||||
"preferences_feed_menu_label": "Dewislen porthiant: ",
|
||||
"Login enabled: ": "Mewngofnodi wedi'i alluogi: ",
|
||||
"tokens_count_0": "",
|
||||
"tokens_count_1": "tocyn",
|
||||
"tokens_count_2": "",
|
||||
"tokens_count_3": "",
|
||||
"tokens_count_4": "tocynnau",
|
||||
"tokens_count_5": "",
|
||||
"Source available here.": "Tarddle ar gael yma."
|
||||
"channel_tab_videos_label": "Fideos"
|
||||
}
|
||||
|
@ -499,7 +499,5 @@
|
||||
"carousel_go_to": "Zu Element `x` springen",
|
||||
"carousel_slide": "Seite {{current}} von {{total}}",
|
||||
"carousel_skip": "Galerie überspringen",
|
||||
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)",
|
||||
"channel_tab_courses_label": "Kurse",
|
||||
"channel_tab_posts_label": "Beiträge"
|
||||
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
|
||||
}
|
||||
|
@ -490,7 +490,7 @@
|
||||
"Search for videos": "Αναζήτηση βίντεο",
|
||||
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
|
||||
"Answer": "Απάντηση",
|
||||
"Add to playlist": "Προσθήκη στην λίστα αναπαραγωγής",
|
||||
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής",
|
||||
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ",
|
||||
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
|
||||
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
|
||||
@ -498,8 +498,5 @@
|
||||
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
|
||||
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
|
||||
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
|
||||
"carousel_skip": "Αποφυγή εμφάνισης εικόνων",
|
||||
"First page": "Πρώτη σελίδα",
|
||||
"channel_tab_courses_label": "Μαθήματα",
|
||||
"channel_tab_posts_label": "Δημοσιεύσεις"
|
||||
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
|
||||
}
|
||||
|
@ -64,6 +64,8 @@
|
||||
"User ID": "User ID",
|
||||
"Password": "Password",
|
||||
"Time (h:mm:ss):": "Time (h:mm:ss):",
|
||||
"Text CAPTCHA": "Text CAPTCHA",
|
||||
"Image CAPTCHA": "Image CAPTCHA",
|
||||
"Sign In": "Sign In",
|
||||
"Register": "Register",
|
||||
"E-mail": "E-mail",
|
||||
@ -499,8 +501,5 @@
|
||||
"toggle_theme": "Toggle Theme",
|
||||
"carousel_slide": "Slide {{current}} of {{total}}",
|
||||
"carousel_skip": "Skip the Carousel",
|
||||
"carousel_go_to": "Go to slide `x`",
|
||||
"timeline_parse_error_placeholder_heading": "Unable to parse item",
|
||||
"timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:",
|
||||
"timeline_parse_error_show_technical_details": "Show technical details"
|
||||
"carousel_go_to": "Go to slide `x`"
|
||||
}
|
||||
|
@ -187,10 +187,10 @@
|
||||
"Hidden field \"token\" is a required field": "El campo oculto «símbolo» es un campo obligatorio",
|
||||
"Erroneous challenge": "Desafío no válido",
|
||||
"Erroneous token": "Símbolo no válido",
|
||||
"No such user": "El usuario no existe",
|
||||
"No such user": "Usuario no existe",
|
||||
"Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo",
|
||||
"English": "Inglés",
|
||||
"English (auto-generated)": "Inglés (generados automáticamente)",
|
||||
"English (auto-generated)": "Inglés (generado automáticamente)",
|
||||
"Afrikaans": "Afrikáans",
|
||||
"Albanian": "Albanés",
|
||||
"Amharic": "Amárico",
|
||||
@ -276,7 +276,7 @@
|
||||
"Somali": "Somalí",
|
||||
"Southern Sotho": "Sesoto",
|
||||
"Spanish": "Español",
|
||||
"Spanish (Latin America)": "Español (Latinoamérica)",
|
||||
"Spanish (Latin America)": "Español (Hispanoamérica)",
|
||||
"Sundanese": "Sondanés",
|
||||
"Swahili": "Suajili",
|
||||
"Swedish": "Sueco",
|
||||
@ -412,8 +412,8 @@
|
||||
"generic_count_weeks_1": "{{count}} semanas",
|
||||
"generic_count_weeks_2": "{{count}} semanas",
|
||||
"generic_playlists_count_0": "{{count}} lista de reproducción",
|
||||
"generic_playlists_count_1": "{{count}} listas de reproducción",
|
||||
"generic_playlists_count_2": "{{count}} listas de reproducción",
|
||||
"generic_playlists_count_1": "{{count}} listas de reproducciones",
|
||||
"generic_playlists_count_2": "{{count}} listas de reproducciones",
|
||||
"generic_videos_count_0": "{{count}} video",
|
||||
"generic_videos_count_1": "{{count}} videos",
|
||||
"generic_videos_count_2": "{{count}} videos",
|
||||
@ -463,7 +463,7 @@
|
||||
"Chinese (Hong Kong)": "Chino (Hong Kong)",
|
||||
"Chinese (China)": "Chino (China)",
|
||||
"Korean (auto-generated)": "Coreano (generados automáticamente)",
|
||||
"Spanish (Mexico)": "Español (México)",
|
||||
"Spanish (Mexico)": "Español (Méjico)",
|
||||
"Spanish (auto-generated)": "Español (generados automáticamente)",
|
||||
"preferences_watch_history_label": "Habilitar historial de reproducciones: ",
|
||||
"search_message_no_results": "No se han encontrado resultados.",
|
||||
@ -500,7 +500,7 @@
|
||||
"generic_button_cancel": "Cancelar",
|
||||
"generic_button_rss": "RSS",
|
||||
"channel_tab_podcasts_label": "Podcasts",
|
||||
"channel_tab_releases_label": "Lanzamientos",
|
||||
"channel_tab_releases_label": "Publicaciones",
|
||||
"generic_channels_count_0": "{{count}} canal",
|
||||
"generic_channels_count_1": "{{count}} canales",
|
||||
"generic_channels_count_2": "{{count}} canales",
|
||||
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Saltar el carrusel",
|
||||
"carousel_go_to": "Ir a la diapositiva `x`",
|
||||
"preferences_preload_label": "Precargar datos del vídeo: ",
|
||||
"Filipino (auto-generated)": "Filipino (generados automáticamente)",
|
||||
"channel_tab_posts_label": "Publicaciones",
|
||||
"First page": "Primera página",
|
||||
"channel_tab_courses_label": "Cursos"
|
||||
"Filipino (auto-generated)": "Filipino (generado automáticamente)"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_go_to": "Aller à la diapositive `x`",
|
||||
"toggle_theme": "Changer le Thème",
|
||||
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
|
||||
"preferences_preload_label": "Précharger les données de la vidéo : ",
|
||||
"First page": "Première page",
|
||||
"channel_tab_courses_label": "Cours",
|
||||
"channel_tab_posts_label": "Messages"
|
||||
"preferences_preload_label": "Précharger les données de la vidéo : "
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
"LIVE": "BEINT",
|
||||
"Shared `x` ago": "Deilt fyrir `x` síðan",
|
||||
"Unsubscribe": "Afskrá",
|
||||
"Subscribe": "Setja í áskrift",
|
||||
"Subscribe": "Áskrifa",
|
||||
"View channel on YouTube": "Skoða rás á YouTube",
|
||||
"View playlist on YouTube": "Skoða spilunarlista á YouTube",
|
||||
"newest": "nýjasta",
|
||||
@ -14,8 +14,8 @@
|
||||
"Clear watch history?": "Hreinsa áhorfsferil?",
|
||||
"New password": "Nýtt lykilorð",
|
||||
"New passwords must match": "Nýtt lykilorð verður að passa",
|
||||
"Authorize token?": "Auðkenna teikn?",
|
||||
"Authorize token for `x`?": "Auðkenna teikn fyrir `x`?",
|
||||
"Authorize token?": "Leyfa teikn?",
|
||||
"Authorize token for `x`?": "Leyfa teikn fyrir `x`?",
|
||||
"Yes": "Já",
|
||||
"No": "Nei",
|
||||
"Import and Export Data": "Inn- og útflutningur gagna",
|
||||
@ -36,17 +36,17 @@
|
||||
"source": "uppruni",
|
||||
"Log in": "Skrá inn",
|
||||
"Log in/register": "Innskráning/nýskráning",
|
||||
"User ID": "Auðkenni notanda",
|
||||
"User ID": "Notandakenni",
|
||||
"Password": "Lykilorð",
|
||||
"Time (h:mm:ss):": "Tími (h:mm: ss):",
|
||||
"Text CAPTCHA": "CAPTCHA-texti",
|
||||
"Image CAPTCHA": "CAPTCHA-mynd",
|
||||
"Text CAPTCHA": "Texta CAPTCHA",
|
||||
"Image CAPTCHA": "Mynd CAPTCHA",
|
||||
"Sign In": "Skrá inn",
|
||||
"Register": "Nýskrá",
|
||||
"E-mail": "Tölvupóstur",
|
||||
"Preferences": "Kjörstillingar",
|
||||
"preferences_category_player": "Kjörstillingar spilara",
|
||||
"preferences_video_loop_label": "Alltaf endurtaka: ",
|
||||
"preferences_video_loop_label": "Alltaf lykkja: ",
|
||||
"preferences_autoplay_label": "Sjálfvirk spilun: ",
|
||||
"preferences_continue_label": "Spila næst sjálfgefið: ",
|
||||
"preferences_continue_autoplay_label": "Spila næsta myndskeið sjálfkrafa: ",
|
||||
@ -85,7 +85,7 @@
|
||||
"preferences_unseen_only_label": "Sýna aðeins óséð: ",
|
||||
"preferences_notifications_only_label": "Sýna aðeins tilkynningar (ef einhverjar eru): ",
|
||||
"Enable web notifications": "Virkja veftilkynningar",
|
||||
"`x` uploaded a video": "`x` sendi inn myndskeið",
|
||||
"`x` uploaded a video": "`x` hlóð upp myndband",
|
||||
"`x` is live": "`x` er í beinni",
|
||||
"preferences_category_data": "Gagnastillingar",
|
||||
"Clear watch history": "Hreinsa áhorfsferil",
|
||||
@ -104,8 +104,8 @@
|
||||
"Registration enabled: ": "Nýskráning virkjuð? ",
|
||||
"Report statistics: ": "Skrá tölfræði? ",
|
||||
"Save preferences": "Vista stillingar",
|
||||
"Subscription manager": "Áskriftastýring",
|
||||
"Token manager": "Teiknastýring",
|
||||
"Subscription manager": "Áskriftarstjóri",
|
||||
"Token manager": "Teiknastjórnun",
|
||||
"Token": "Teikn",
|
||||
"Import/export": "Flytja inn/út",
|
||||
"unsubscribe": "afskrá",
|
||||
@ -233,7 +233,7 @@
|
||||
"Korean": "Kóreska",
|
||||
"Kurdish": "Kúrdíska",
|
||||
"Kyrgyz": "Kirgisíska",
|
||||
"Lao": "Laóska",
|
||||
"Lao": "Laó",
|
||||
"Latin": "Latína",
|
||||
"Latvian": "Lettneska",
|
||||
"Lithuanian": "Litháíska",
|
||||
@ -295,18 +295,18 @@
|
||||
"View as playlist": "Skoða sem spilunarlista",
|
||||
"Default": "Sjálfgefið",
|
||||
"Music": "Tónlist",
|
||||
"Gaming": "Spilun leikja",
|
||||
"Gaming": "Tólvuleikja",
|
||||
"News": "Fréttir",
|
||||
"Movies": "Kvikmyndir",
|
||||
"Download": "Niðurhal",
|
||||
"Download as: ": "Sækja sem: ",
|
||||
"Download as: ": "Niðurhala sem: ",
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(breytt)",
|
||||
"YouTube comment permalink": "Varanlegur tengill á YouTube-ummæli",
|
||||
"YouTube comment permalink": "YouTube ummæli varanlegur tengill",
|
||||
"permalink": "Varanlegur tengill",
|
||||
"`x` marked it with a ❤": "`x` merkti það með ❤",
|
||||
"Audio mode": "Hljóðhamur",
|
||||
"Video mode": "Myndhamur",
|
||||
"Audio mode": "Hljóð ham",
|
||||
"Video mode": "Myndband ham",
|
||||
"channel_tab_videos_label": "Myndskeið",
|
||||
"Playlists": "Spilunarlistar",
|
||||
"channel_tab_community_label": "Samfélag",
|
||||
@ -388,7 +388,7 @@
|
||||
"crash_page_before_reporting": "Áður en þú tilkynnir villu, gakktu úr skugga um að þú hafir:",
|
||||
"crash_page_switch_instance": "reynt að <a href=\"`x`\">nota annað tilvik</a>",
|
||||
"crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að <a href=\"`x`\">opna nýja verkbeiðni (issue) á GitHub</a> (helst á ensku) og láta fylgja eftirfarandi texta í skilaboðunum þínum (alls EKKI þýða þennan texta):",
|
||||
"channel_tab_shorts_label": "Símamyndir",
|
||||
"channel_tab_shorts_label": "Stuttmyndir",
|
||||
"carousel_slide": "Skyggna {{current}} af {{total}}",
|
||||
"carousel_go_to": "Fara á skyggnu `x`",
|
||||
"channel_tab_streams_label": "Bein streymi",
|
||||
@ -401,8 +401,8 @@
|
||||
"English (United Kingdom)": "Enska (Bretland)",
|
||||
"English (United States)": "Enska (Bandarísk)",
|
||||
"Vietnamese (auto-generated)": "Víetnamska (sjálfvirkt útbúið)",
|
||||
"generic_count_months": "{{count}} mánuði",
|
||||
"generic_count_months_plural": "{{count}} mánuðum",
|
||||
"generic_count_months": "{{count}} mánuður",
|
||||
"generic_count_months_plural": "{{count}} mánuðir",
|
||||
"search_filters_sort_option_rating": "Einkunn",
|
||||
"videoinfo_youTube_embed_link": "Ívefja",
|
||||
"error_video_not_in_playlist": "Umbeðið myndskeið fyrirfinnst ekki í þessum spilunarlista. <a href=\"`x`\">Smelltu hér til að fara á heimasíðu spilunarlistans.</a>",
|
||||
@ -429,11 +429,11 @@
|
||||
"Spanish (auto-generated)": "Spænska (sjálfvirkt útbúið)",
|
||||
"Spanish (Mexico)": "Spænska (Mexíkó)",
|
||||
"generic_count_hours": "{{count}} klukkustund",
|
||||
"generic_count_hours_plural": "{{count}} klukkustundum",
|
||||
"generic_count_years": "{{count}} ári",
|
||||
"generic_count_years_plural": "{{count}} árum",
|
||||
"generic_count_weeks": "{{count}} viku",
|
||||
"generic_count_weeks_plural": "{{count}} vikum",
|
||||
"generic_count_hours_plural": "{{count}} klukkustundir",
|
||||
"generic_count_years": "{{count}} ár",
|
||||
"generic_count_years_plural": "{{count}} ár",
|
||||
"generic_count_weeks": "{{count}} vika",
|
||||
"generic_count_weeks_plural": "{{count}} vikur",
|
||||
"search_filters_date_option_none": "Hvaða dagsetning sem er",
|
||||
"Channel Sponsor": "Styrktaraðili rásar",
|
||||
"search_filters_date_option_week": "Í þessari viku",
|
||||
@ -476,8 +476,8 @@
|
||||
"preferences_quality_dash_option_144p": "144p",
|
||||
"invidious": "Invidious",
|
||||
"Korean (auto-generated)": "Kóreska (sjálfvirkt útbúið)",
|
||||
"generic_count_days": "{{count}} degi",
|
||||
"generic_count_days_plural": "{{count}} dögum",
|
||||
"generic_count_days": "{{count}} dagur",
|
||||
"generic_count_days_plural": "{{count}} dagar",
|
||||
"search_filters_date_option_today": "Í dag",
|
||||
"search_filters_type_label": "Tegund",
|
||||
"search_filters_type_option_all": "Hvaða tegund sem er",
|
||||
@ -498,8 +498,5 @@
|
||||
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
|
||||
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)",
|
||||
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
|
||||
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)",
|
||||
"channel_tab_posts_label": "Færslur",
|
||||
"First page": "Fyrsta síða",
|
||||
"channel_tab_courses_label": "Kennsluefni"
|
||||
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Salta la galleria",
|
||||
"carousel_go_to": "Vai al fotogramma `x`",
|
||||
"preferences_preload_label": "Precarica dati video: ",
|
||||
"Filipino (auto-generated)": "Filippino (generati automaticamente)",
|
||||
"First page": "Prima pagina",
|
||||
"channel_tab_courses_label": "Corsi",
|
||||
"channel_tab_posts_label": "Post"
|
||||
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
|
||||
}
|
||||
|
@ -343,7 +343,7 @@
|
||||
"search_filters_type_label": "種類",
|
||||
"search_filters_duration_label": "再生時間",
|
||||
"search_filters_features_label": "特徴",
|
||||
"search_filters_sort_label": "並べ替え",
|
||||
"search_filters_sort_label": "順番",
|
||||
"search_filters_date_option_hour": "1時間以内",
|
||||
"search_filters_date_option_today": "今日",
|
||||
"search_filters_date_option_week": "今週",
|
||||
@ -370,7 +370,7 @@
|
||||
"footer_documentation": "説明書",
|
||||
"footer_source_code": "ソースコード",
|
||||
"footer_original_source_code": "元のソースコード",
|
||||
"footer_modfied_source_code": "改変し使用中",
|
||||
"footer_modfied_source_code": "改変して使用",
|
||||
"adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL",
|
||||
"search_filters_duration_option_long": "20分以上",
|
||||
"preferences_region_label": "地域: ",
|
||||
@ -446,7 +446,7 @@
|
||||
"search_filters_duration_option_medium": "4 ~ 20分",
|
||||
"preferences_save_player_pos_label": "再生位置を保存: ",
|
||||
"crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。",
|
||||
"crash_page_report_issue": "上記が助けにならない場合、<a href=\"`x`\">GitHub</a> に新しい issue を作成し (できれば英語で) 、メッセージに次のテキストを含めてください (テキストは翻訳しない) 。",
|
||||
"crash_page_report_issue": "上記が助けにならないなら、<a href=\"`x`\">GitHub</a> に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてください(テキストは翻訳しない)。",
|
||||
"crash_page_search_issue": "<a href=\"`x`\">GitHub の既存の問題 (issue)</a> を検索",
|
||||
"channel_tab_streams_label": "ライブ",
|
||||
"channel_tab_playlists_label": "再生リスト",
|
||||
@ -481,8 +481,5 @@
|
||||
"carousel_skip": "画像のスライド表示をスキップ",
|
||||
"toggle_theme": "テーマの切り替え",
|
||||
"preferences_preload_label": "動画データを事前に読み込む: ",
|
||||
"Filipino (auto-generated)": "フィリピノ語 (自動生成)",
|
||||
"First page": "最初のページ",
|
||||
"channel_tab_posts_label": "投稿",
|
||||
"channel_tab_courses_label": "コース"
|
||||
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
|
||||
}
|
||||
|
@ -480,9 +480,5 @@
|
||||
"Search for videos": "비디오 검색",
|
||||
"toggle_theme": "테마 전환",
|
||||
"carousel_slide": "{{total}}의 슬라이드 {{current}}",
|
||||
"preferences_preload_label": "비디오 데이터 사전 로드: ",
|
||||
"First page": "첫 페이지",
|
||||
"Filipino (auto-generated)": "Filipino (auto-generated)",
|
||||
"channel_tab_posts_label": "게시글",
|
||||
"channel_tab_courses_label": "코스"
|
||||
"preferences_preload_label": "비디오 데이터 사전 로드: "
|
||||
}
|
||||
|
@ -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ē"
|
||||
}
|
@ -498,8 +498,5 @@
|
||||
"carousel_skip": "Carousel overslaan",
|
||||
"toggle_theme": "Thema omschakelen",
|
||||
"preferences_preload_label": "Videogegevens vooraf laden: ",
|
||||
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)",
|
||||
"channel_tab_courses_label": "Cursussen",
|
||||
"First page": "Eerste pagina",
|
||||
"channel_tab_posts_label": "Gepost"
|
||||
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Pomiń karuzelę",
|
||||
"carousel_go_to": "Przejdź do slajdu `x`",
|
||||
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
|
||||
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)",
|
||||
"First page": "Pierwsza strona",
|
||||
"channel_tab_posts_label": "Posty",
|
||||
"channel_tab_courses_label": "Kursy"
|
||||
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Ignorar carrossel",
|
||||
"carousel_go_to": "Ir ao slide `x`",
|
||||
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
|
||||
"channel_tab_posts_label": "Postagens",
|
||||
"First page": "Primeira página",
|
||||
"channel_tab_courses_label": "Cursos"
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||
}
|
||||
|
@ -1,27 +1,27 @@
|
||||
{
|
||||
"LIVE": "Direto",
|
||||
"LIVE": "Em direto",
|
||||
"Shared `x` ago": "Partilhado `x` atrás",
|
||||
"Unsubscribe": "Anular subscrição",
|
||||
"Subscribe": "Subscrever",
|
||||
"View channel on YouTube": "Ver canal no YouTube",
|
||||
"View playlist on YouTube": "Ver lista de reprodução no YouTube",
|
||||
"newest": "recentes",
|
||||
"oldest": "antigos",
|
||||
"popular": "populares",
|
||||
"newest": "mais recentes",
|
||||
"oldest": "mais antigos",
|
||||
"popular": "popular",
|
||||
"last": "últimos",
|
||||
"Next page": "Página seguinte",
|
||||
"Next page": "Próxima página",
|
||||
"Previous page": "Página anterior",
|
||||
"Clear watch history?": "Limpar histórico de reprodução?",
|
||||
"New password": "Nova palavra-passe",
|
||||
"New passwords must match": "As novas palavras-passe devem ser iguais",
|
||||
"Authorize token?": "Autorizar 'token'?",
|
||||
"Authorize token for `x`?": "Autorizar 'token' para `x`?",
|
||||
"New password": "Nova palavra-chave",
|
||||
"New passwords must match": "As novas palavra-chaves devem corresponder",
|
||||
"Authorize token?": "Autorizar token?",
|
||||
"Authorize token for `x`?": "Autorizar token para `x`?",
|
||||
"Yes": "Sim",
|
||||
"No": "Não",
|
||||
"Import and Export Data": "Importar e exportar dados",
|
||||
"Import": "Importar",
|
||||
"Import Invidious data": "Importar dados JSON do Invidious",
|
||||
"Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML",
|
||||
"Import YouTube subscriptions": "Importar subscrições do YouTube/OPML",
|
||||
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
|
||||
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
|
||||
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
|
||||
@ -32,38 +32,38 @@
|
||||
"Delete account?": "Eliminar conta?",
|
||||
"History": "Histórico",
|
||||
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
|
||||
"JavaScript license information": "Informação da licença JavaScript",
|
||||
"source": "fonte",
|
||||
"JavaScript license information": "Informação de licença do JavaScript",
|
||||
"source": "código-fonte",
|
||||
"Log in": "Iniciar sessão",
|
||||
"Log in/register": "Iniciar sessão/registar",
|
||||
"User ID": "Utilizador",
|
||||
"Password": "Palavra-passe",
|
||||
"Password": "Palavra-chave",
|
||||
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
|
||||
"Text CAPTCHA": "Texto CAPTCHA",
|
||||
"Image CAPTCHA": "Imagem CAPTCHA",
|
||||
"Sign In": "Entrar",
|
||||
"Sign In": "Iniciar sessão",
|
||||
"Register": "Registar",
|
||||
"E-mail": "E-mail",
|
||||
"Preferences": "Preferências",
|
||||
"preferences_category_player": "Preferências do reprodutor",
|
||||
"preferences_video_loop_label": "Repetir sempre: ",
|
||||
"preferences_autoplay_label": "Reprodução automática: ",
|
||||
"preferences_continue_label": "Reproduzir sempre o seguinte: ",
|
||||
"preferences_continue_label": "Reproduzir sempre o próximo: ",
|
||||
"preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ",
|
||||
"preferences_listen_label": "Apenas áudio: ",
|
||||
"preferences_local_label": "Usar proxy nos vídeos: ",
|
||||
"preferences_speed_label": "Velocidade preferida: ",
|
||||
"preferences_quality_label": "Qualidade de vídeo preferida: ",
|
||||
"preferences_volume_label": "Volume de reprodução: ",
|
||||
"preferences_comments_label": "Comentários padrão: ",
|
||||
"preferences_volume_label": "Volume da reprodução: ",
|
||||
"preferences_comments_label": "Preferência dos comentários: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "Reddit",
|
||||
"preferences_captions_label": "Legendas padrão: ",
|
||||
"preferences_captions_label": "Legendas predefinidas: ",
|
||||
"Fallback captions: ": "Legendas alternativas: ",
|
||||
"preferences_related_videos_label": "Mostrar vídeos relacionados: ",
|
||||
"preferences_annotations_label": "Mostrar anotações sempre: ",
|
||||
"preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ",
|
||||
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ",
|
||||
"preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
|
||||
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ",
|
||||
"preferences_category_visual": "Preferências visuais",
|
||||
"preferences_player_style_label": "Estilo do reprodutor: ",
|
||||
"Dark mode: ": "Modo escuro: ",
|
||||
@ -74,9 +74,9 @@
|
||||
"preferences_category_misc": "Preferências diversas",
|
||||
"preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ",
|
||||
"preferences_category_subscription": "Preferências de subscrições",
|
||||
"preferences_annotations_subscribed_label": "Mostrar sempre anotações nos canais subscritos: ",
|
||||
"preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ",
|
||||
"Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
|
||||
"preferences_max_results_label": "Número de vídeos nas subscrições: ",
|
||||
"preferences_max_results_label": "Quantidade de vídeos nas subscrições: ",
|
||||
"preferences_sort_label": "Ordenar vídeos por: ",
|
||||
"published": "publicado",
|
||||
"published - reverse": "publicado - inverso",
|
||||
@ -88,19 +88,19 @@
|
||||
"Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
|
||||
"preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ",
|
||||
"preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ",
|
||||
"Enable web notifications": "Ativar notificações web",
|
||||
"`x` uploaded a video": "`x` publicou um vídeo",
|
||||
"Enable web notifications": "Ativar notificações pela web",
|
||||
"`x` uploaded a video": "`x` publicou um novo vídeo",
|
||||
"`x` is live": "`x` está em direto",
|
||||
"preferences_category_data": "Preferências de dados",
|
||||
"Clear watch history": "Limpar histórico de reprodução",
|
||||
"Import/export data": "Importar / exportar dados",
|
||||
"Change password": "Alterar palavra-passe",
|
||||
"Manage subscriptions": "Gerir subscrições",
|
||||
"Change password": "Alterar palavra-chave",
|
||||
"Manage subscriptions": "Gerir as subscrições",
|
||||
"Manage tokens": "Gerir tokens",
|
||||
"Watch history": "Histórico de reprodução",
|
||||
"Delete account": "Eliminar conta",
|
||||
"preferences_category_admin": "Preferências de administrador",
|
||||
"preferences_default_home_label": "Página inicial padrão: ",
|
||||
"preferences_default_home_label": "Página inicial predefinida: ",
|
||||
"preferences_feed_menu_label": "Menu de subscrições: ",
|
||||
"preferences_show_nick_label": "Mostrar nome de utilizador em cima: ",
|
||||
"Top enabled: ": "Destaques ativados: ",
|
||||
@ -109,29 +109,28 @@
|
||||
"Registration enabled: ": "Registar ativado: ",
|
||||
"Report statistics: ": "Relatório de estatísticas: ",
|
||||
"Save preferences": "Guardar preferências",
|
||||
"Subscription manager": "Gestor de subscrições",
|
||||
"Token manager": "Gestor de tokens",
|
||||
"Subscription manager": "Gerir subscrições",
|
||||
"Token manager": "Gerir tokens",
|
||||
"Token": "Token",
|
||||
"tokens_count_0": "{{count}} token",
|
||||
"tokens_count_1": "{{count}} tokens",
|
||||
"tokens_count_2": "{{count}} tokens",
|
||||
"tokens_count": "{{count}} token",
|
||||
"tokens_count_plural": "{{count}} tokens",
|
||||
"Import/export": "Importar / exportar",
|
||||
"unsubscribe": "anular subscrição",
|
||||
"revoke": "revogar",
|
||||
"Subscriptions": "Subscrições",
|
||||
"search": "pesquisar",
|
||||
"Log out": "Terminar sessão",
|
||||
"Released under the AGPLv3 on Github.": "Disponibilizada sob a AGPLv3 no GitHub.",
|
||||
"Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.",
|
||||
"Source available here.": "Código-fonte disponível aqui.",
|
||||
"View JavaScript license information.": "Ver informações da licença JavaScript.",
|
||||
"View privacy policy.": "Ver política de privacidade.",
|
||||
"View JavaScript license information.": "Ver informações da licença do JavaScript.",
|
||||
"View privacy policy.": "Ver a política de privacidade.",
|
||||
"Trending": "Tendências",
|
||||
"Public": "Público",
|
||||
"Unlisted": "Não listado",
|
||||
"Private": "Privado",
|
||||
"View all playlists": "Ver todas as listas de reprodução",
|
||||
"Updated `x` ago": "Atualizado há `x`",
|
||||
"Delete playlist `x`?": "Eliminar lista de reprodução `x`?",
|
||||
"Updated `x` ago": "Atualizado `x` atrás",
|
||||
"Delete playlist `x`?": "Eliminar a lista de reprodução `x`?",
|
||||
"Delete playlist": "Eliminar lista de reprodução",
|
||||
"Create playlist": "Criar lista de reprodução",
|
||||
"Title": "Título",
|
||||
@ -140,7 +139,7 @@
|
||||
"Show more": "Mostrar mais",
|
||||
"Show less": "Mostrar menos",
|
||||
"Watch on YouTube": "Ver no YouTube",
|
||||
"Switch Invidious Instance": "Alterar instância Invidious",
|
||||
"Switch Invidious Instance": "Mudar a instância do Invidious",
|
||||
"Hide annotations": "Ocultar anotações",
|
||||
"Show annotations": "Mostrar anotações",
|
||||
"Genre: ": "Género: ",
|
||||
@ -151,27 +150,27 @@
|
||||
"Whitelisted regions: ": "Regiões permitidas: ",
|
||||
"Blacklisted regions: ": "Regiões bloqueadas: ",
|
||||
"Shared `x`": "Partilhado `x`",
|
||||
"Premieres in `x`": "Estreia a `x`",
|
||||
"Premieres `x`": "Estreia `x`",
|
||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, mas tenha e conta que podem levar mais tempo para carregar.",
|
||||
"Premieres in `x`": "Estreias em `x`",
|
||||
"Premieres `x`": "Estreias `x`",
|
||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
|
||||
"View YouTube comments": "Ver comentários do YouTube",
|
||||
"View more comments on Reddit": "Ver mais comentários no Reddit",
|
||||
"View `x` comments": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário",
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários",
|
||||
"": "Ver `x` comentários"
|
||||
},
|
||||
"View Reddit comments": "Ver comentários do Reddit",
|
||||
"Hide replies": "Ocultar respostas",
|
||||
"Show replies": "Mostrar respostas",
|
||||
"Incorrect password": "Palavra-passe incorreta",
|
||||
"Incorrect password": "Palavra-chave incorreta",
|
||||
"Wrong answer": "Resposta errada",
|
||||
"Erroneous CAPTCHA": "CAPTCHA inválido",
|
||||
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
|
||||
"User ID is a required field": "O nome de utilizador é um campo obrigatório",
|
||||
"Password is a required field": "Palavra-passe é um campo obrigatório",
|
||||
"Wrong username or password": "Nome de utilizador ou palavra-passe incorreta",
|
||||
"Password cannot be empty": "A palavra-passe não pode estar vazia",
|
||||
"Password cannot be longer than 55 characters": "A palavra-passe não pode ter mais do que 55 caracteres",
|
||||
"Password is a required field": "Palavra-chave é um campo obrigatório",
|
||||
"Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
|
||||
"Password cannot be empty": "A palavra-chave não pode estar vazia",
|
||||
"Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
|
||||
"Please log in": "Por favor, inicie sessão",
|
||||
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
|
||||
"channel:`x`": "canal:`x`",
|
||||
@ -181,20 +180,20 @@
|
||||
"Could not fetch comments": "Não foi possível obter os comentários",
|
||||
"`x` ago": "`x` atrás",
|
||||
"Load more": "Carregar mais",
|
||||
"Could not create mix.": "Não foi possível criar o mix.",
|
||||
"Could not create mix.": "Não foi possível criar a mistura.",
|
||||
"Empty playlist": "Lista de reprodução vazia",
|
||||
"Not a playlist.": "Não é uma lista de reprodução.",
|
||||
"Playlist does not exist.": "A lista de reprodução não existe.",
|
||||
"Could not pull trending pages.": "Não foi possível obter a página de tendências.",
|
||||
"Could not pull trending pages.": "Não foi possível obter as páginas de tendências.",
|
||||
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
|
||||
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
|
||||
"Erroneous challenge": "Desafio inválido",
|
||||
"Erroneous token": "Token inválido",
|
||||
"No such user": "Utilizador inválido",
|
||||
"Token is expired, please try again": "Token caducado, tente novamente",
|
||||
"Token is expired, please try again": "Token expirou, tente novamente",
|
||||
"English": "Inglês",
|
||||
"English (auto-generated)": "Inglês (auto-gerado)",
|
||||
"Afrikaans": "Africânder",
|
||||
"Afrikaans": "Africano",
|
||||
"Albanian": "Albanês",
|
||||
"Amharic": "Amárico",
|
||||
"Arabic": "Árabe",
|
||||
@ -210,7 +209,7 @@
|
||||
"Cebuano": "Cebuano",
|
||||
"Chinese (Simplified)": "Chinês (simplificado)",
|
||||
"Chinese (Traditional)": "Chinês (tradicional)",
|
||||
"Corsican": "Córsego",
|
||||
"Corsican": "Corso",
|
||||
"Croatian": "Croata",
|
||||
"Czech": "Checo",
|
||||
"Danish": "Dinamarquês",
|
||||
@ -253,7 +252,7 @@
|
||||
"Macedonian": "Macedónio",
|
||||
"Malagasy": "Malgaxe",
|
||||
"Malay": "Malaio",
|
||||
"Malayalam": "Malaialaio",
|
||||
"Malayalam": "Malaiala",
|
||||
"Maltese": "Maltês",
|
||||
"Maori": "Maori",
|
||||
"Marathi": "Marathi",
|
||||
@ -298,37 +297,30 @@
|
||||
"Yiddish": "Iídiche",
|
||||
"Yoruba": "Ioruba",
|
||||
"Zulu": "Zulu",
|
||||
"generic_count_years_0": "{{count}} ano",
|
||||
"generic_count_years_1": "{{count}} anos",
|
||||
"generic_count_years_2": "{{count}} anos",
|
||||
"generic_count_months_0": "{{count}} mês",
|
||||
"generic_count_months_1": "{{count}} meses",
|
||||
"generic_count_months_2": "{{count}} meses",
|
||||
"generic_count_weeks_0": "{{count}} semana",
|
||||
"generic_count_weeks_1": "{{count}} semanas",
|
||||
"generic_count_weeks_2": "{{count}} semanas",
|
||||
"generic_count_days_0": "{{count}} dia",
|
||||
"generic_count_days_1": "{{count}} dias",
|
||||
"generic_count_days_2": "{{count}} dias",
|
||||
"generic_count_hours_0": "{{count}} hora",
|
||||
"generic_count_hours_1": "{{count}} horas",
|
||||
"generic_count_hours_2": "{{count}} horas",
|
||||
"generic_count_minutes_0": "{{count}} minuto",
|
||||
"generic_count_minutes_1": "{{count}} minutos",
|
||||
"generic_count_minutes_2": "{{count}} minutos",
|
||||
"generic_count_seconds_0": "{{count}} segundo",
|
||||
"generic_count_seconds_1": "{{count}} segundos",
|
||||
"generic_count_seconds_2": "{{count}} segundos",
|
||||
"Fallback comments: ": "Alternativa para comentários: ",
|
||||
"generic_count_years": "{{count}} ano",
|
||||
"generic_count_years_plural": "{{count}} anos",
|
||||
"generic_count_months": "{{count}} mês",
|
||||
"generic_count_months_plural": "{{count}} meses",
|
||||
"generic_count_weeks": "{{count}} seman",
|
||||
"generic_count_weeks_plural": "{{count}} semanas",
|
||||
"generic_count_days": "{{count}} dia",
|
||||
"generic_count_days_plural": "{{count}} dias",
|
||||
"generic_count_hours": "{{count}} hora",
|
||||
"generic_count_hours_plural": "{{count}} horas",
|
||||
"generic_count_minutes": "{{count}} minuto",
|
||||
"generic_count_minutes_plural": "{{count}} minutos",
|
||||
"generic_count_seconds": "{{count}} segundo",
|
||||
"generic_count_seconds_plural": "{{count}} segundos",
|
||||
"Fallback comments: ": "Comentários alternativos: ",
|
||||
"Popular": "Popular",
|
||||
"Search": "Pesquisar",
|
||||
"Top": "Destaques",
|
||||
"About": "Acerca",
|
||||
"About": "Sobre",
|
||||
"Rating: ": "Avaliação: ",
|
||||
"preferences_locale_label": "Idioma: ",
|
||||
"View as playlist": "Ver como lista de reprodução",
|
||||
"Default": "Padrão",
|
||||
"Music": "Músicas",
|
||||
"Default": "Predefinido",
|
||||
"Music": "Música",
|
||||
"Gaming": "Jogos",
|
||||
"News": "Notícias",
|
||||
"Movies": "Filmes",
|
||||
@ -336,9 +328,9 @@
|
||||
"Download as: ": "Descarregar como: ",
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(editado)",
|
||||
"YouTube comment permalink": "Ligação permanente do comentário no YouTube",
|
||||
"permalink": "ligação permanente",
|
||||
"`x` marked it with a ❤": "`x` foi marcado com um ❤",
|
||||
"YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
|
||||
"permalink": "hiperligação permanente",
|
||||
"`x` marked it with a ❤": "`x` foi marcado como ❤",
|
||||
"Audio mode": "Modo de áudio",
|
||||
"Video mode": "Modo de vídeo",
|
||||
"channel_tab_videos_label": "Vídeos",
|
||||
@ -346,7 +338,7 @@
|
||||
"channel_tab_community_label": "Comunidade",
|
||||
"search_filters_sort_option_relevance": "Relevância",
|
||||
"search_filters_sort_option_rating": "Avaliação",
|
||||
"search_filters_sort_option_date": "Data de carregamento",
|
||||
"search_filters_sort_option_date": "Data de envio",
|
||||
"search_filters_sort_option_views": "Visualizações",
|
||||
"search_filters_type_label": "Tipo",
|
||||
"search_filters_duration_label": "Duração",
|
||||
@ -361,44 +353,38 @@
|
||||
"search_filters_type_option_channel": "Canal",
|
||||
"search_filters_type_option_playlist": "Lista de reprodução",
|
||||
"search_filters_type_option_movie": "Filme",
|
||||
"search_filters_type_option_show": "Séries",
|
||||
"search_filters_type_option_show": "Espetáculo",
|
||||
"search_filters_features_option_hd": "HD",
|
||||
"search_filters_features_option_subtitles": "Legendas",
|
||||
"search_filters_features_option_c_commons": "Creative Commons",
|
||||
"search_filters_features_option_three_d": "3D",
|
||||
"search_filters_features_option_live": "Direto",
|
||||
"search_filters_features_option_live": "Em direto",
|
||||
"search_filters_features_option_four_k": "4K",
|
||||
"search_filters_features_option_location": "Localização",
|
||||
"search_filters_features_option_hdr": "HDR",
|
||||
"Current version: ": "Versão atual: ",
|
||||
"next_steps_error_message": "Pode tentar as seguintes opções: ",
|
||||
"next_steps_error_message_refresh": "Recarregar",
|
||||
"next_steps_error_message_go_to_youtube": "Ir para o YouTube",
|
||||
"next_steps_error_message_refresh": "Atualizar",
|
||||
"next_steps_error_message_go_to_youtube": "Ir ao YouTube",
|
||||
"search_filters_title": "Filtro",
|
||||
"generic_videos_count_0": "{{count}} vídeo",
|
||||
"generic_videos_count_1": "{{count}} vídeos",
|
||||
"generic_videos_count_2": "{{count}} vídeos",
|
||||
"generic_playlists_count_0": "{{count}} lista de reprodução",
|
||||
"generic_playlists_count_1": "{{count}} listas de reprodução",
|
||||
"generic_playlists_count_2": "{{count}} listas de reprodução",
|
||||
"generic_subscriptions_count_0": "{{count}} subscrição",
|
||||
"generic_subscriptions_count_1": "{{count}} subscrições",
|
||||
"generic_subscriptions_count_2": "{{count}} subscrições",
|
||||
"generic_views_count_0": "{{count}} visualização",
|
||||
"generic_views_count_1": "{{count}} visualizações",
|
||||
"generic_views_count_2": "{{count}} visualizações",
|
||||
"generic_subscribers_count_0": "{{count}} subscritor",
|
||||
"generic_subscribers_count_1": "{{count}} subscritores",
|
||||
"generic_subscribers_count_2": "{{count}} subscritores",
|
||||
"generic_videos_count": "{{count}} vídeo",
|
||||
"generic_videos_count_plural": "{{count}} vídeos",
|
||||
"generic_playlists_count": "{{count}} lista de reprodução",
|
||||
"generic_playlists_count_plural": "{{count}} listas de reprodução",
|
||||
"generic_subscriptions_count": "{{count}} inscrição",
|
||||
"generic_subscriptions_count_plural": "{{count}} inscrições",
|
||||
"generic_views_count": "{{count}} visualização",
|
||||
"generic_views_count_plural": "{{count}} visualizações",
|
||||
"generic_subscribers_count": "{{count}} inscrito",
|
||||
"generic_subscribers_count_plural": "{{count}} inscritos",
|
||||
"preferences_quality_dash_option_4320p": "4320p",
|
||||
"preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
|
||||
"preferences_quality_dash_option_2160p": "2160p",
|
||||
"subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista",
|
||||
"subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas",
|
||||
"subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas",
|
||||
"subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
|
||||
"subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
|
||||
"Popular enabled: ": "Página \"popular\" ativada: ",
|
||||
"search_message_no_results": "Nenhum resultado encontrado.",
|
||||
"preferences_quality_dash_option_auto": "Automática",
|
||||
"preferences_quality_dash_option_auto": "Automático",
|
||||
"preferences_region_label": "País do conteúdo: ",
|
||||
"preferences_quality_dash_option_1440p": "1440p",
|
||||
"preferences_quality_dash_option_720p": "720p",
|
||||
@ -417,12 +403,10 @@
|
||||
"preferences_quality_dash_option_240p": "240p",
|
||||
"Video unavailable": "Vídeo não disponível",
|
||||
"Russian (auto-generated)": "Russo (gerado automaticamente)",
|
||||
"comments_view_x_replies_0": "Ver {{count}} resposta",
|
||||
"comments_view_x_replies_1": "Ver {{count}} respostas",
|
||||
"comments_view_x_replies_2": "Ver {{count}} respostas",
|
||||
"comments_points_count_0": "{{count}} ponto",
|
||||
"comments_points_count_1": "{{count}} pontos",
|
||||
"comments_points_count_2": "{{count}} pontos",
|
||||
"comments_view_x_replies": "Ver {{count}} resposta",
|
||||
"comments_view_x_replies_plural": "Ver {{count}} respostas",
|
||||
"comments_points_count": "{{count}} ponto",
|
||||
"comments_points_count_plural": "{{count}} pontos",
|
||||
"English (United Kingdom)": "Inglês (Reino Unido)",
|
||||
"Chinese (Hong Kong)": "Chinês (Hong Kong)",
|
||||
"Chinese (Taiwan)": "Chinês (Taiwan)",
|
||||
@ -448,13 +432,13 @@
|
||||
"videoinfo_watch_on_youTube": "Ver no YouTube",
|
||||
"videoinfo_youTube_embed_link": "Incorporar",
|
||||
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
|
||||
"videoinfo_invidious_embed_link": "Incorporar ligação",
|
||||
"videoinfo_invidious_embed_link": "Incorporar hiperligação",
|
||||
"none": "nenhum",
|
||||
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
|
||||
"download_subtitles": "Legendas - `x` (.vtt)",
|
||||
"user_created_playlists": "`x` listas de reprodução criadas",
|
||||
"user_saved_playlists": "`x` listas de reprodução guardadas",
|
||||
"preferences_save_player_pos_label": "Guardar posição de reprodução: ",
|
||||
"preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ",
|
||||
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
|
||||
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
|
||||
"Chinese (China)": "Chinês (China)",
|
||||
@ -474,49 +458,18 @@
|
||||
"search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
|
||||
"crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
|
||||
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
|
||||
"crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
|
||||
"crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
|
||||
"crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>",
|
||||
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO o traduza):",
|
||||
"crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
|
||||
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
|
||||
"crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
|
||||
"crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
|
||||
"error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para voltar à página inicial da lista de reprodução.</a>",
|
||||
"error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>",
|
||||
"Artist: ": "Artista: ",
|
||||
"Album: ": "Álbum: ",
|
||||
"channel_tab_streams_label": "Emissões em direto",
|
||||
"channel_tab_streams_label": "Diretos",
|
||||
"channel_tab_playlists_label": "Listas de reprodução",
|
||||
"channel_tab_channels_label": "Canais",
|
||||
"Music in this video": "Música neste vídeo",
|
||||
"channel_tab_shorts_label": "Curtos",
|
||||
"generic_button_delete": "Eliminar",
|
||||
"generic_button_edit": "Editar",
|
||||
"generic_button_save": "Guardar",
|
||||
"generic_button_cancel": "Cancelar",
|
||||
"Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)",
|
||||
"Song: ": "Canção: ",
|
||||
"Answer": "Responder",
|
||||
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
|
||||
"Channel Sponsor": "Patrocinador do canal",
|
||||
"Download is disabled": "A descarga está desativada",
|
||||
"Add to playlist": "Adicionar à lista de reprodução",
|
||||
"Add to playlist: ": "Adicionar à lista de reprodução: ",
|
||||
"Search for videos": "Procurar vídeos",
|
||||
"generic_channels_count_0": "{{count}} canal",
|
||||
"generic_channels_count_1": "{{count}} canais",
|
||||
"generic_channels_count_2": "{{count}} canais",
|
||||
"generic_button_rss": "RSS",
|
||||
"Import YouTube watch history (.json)": "Importar histórico de reprodução do YouTube (.json)",
|
||||
"preferences_preload_label": "Pré-carregamento dos dados: ",
|
||||
"playlist_button_add_items": "Adicionar vídeos",
|
||||
"channel_tab_podcasts_label": "Podcasts",
|
||||
"channel_tab_releases_label": "Lançamentos",
|
||||
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
||||
"carousel_skip": "Ignorar carrossel",
|
||||
"carousel_go_to": "Ir para o diapositivo`x`",
|
||||
"First page": "Primeira página",
|
||||
"Standard YouTube license": "Licença padrão do YouTube",
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
|
||||
"channel_tab_courses_label": "Cursos",
|
||||
"channel_tab_posts_label": "Publicações",
|
||||
"toggle_theme": "Trocar tema"
|
||||
"channel_tab_shorts_label": "Curtos"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_go_to": "Ir para o diapositivo`x`",
|
||||
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
|
||||
"preferences_preload_label": "Pré-carregamento dos dados: ",
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)",
|
||||
"First page": "Primeira página",
|
||||
"channel_tab_courses_label": "Cursos",
|
||||
"channel_tab_posts_label": "Publicações"
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||
}
|
||||
|
@ -515,7 +515,5 @@
|
||||
"carousel_slide": "Пролистано {{current}} из {{total}}",
|
||||
"carousel_skip": "Пропустить всё",
|
||||
"carousel_go_to": "Перейти к странице `x`",
|
||||
"preferences_preload_label": "Предзагрузка видеоданных: ",
|
||||
"channel_tab_courses_label": "Курсы",
|
||||
"channel_tab_posts_label": "Записи"
|
||||
"preferences_preload_label": "Предзагрузка видеоданных: "
|
||||
}
|
||||
|
@ -494,9 +494,5 @@
|
||||
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
|
||||
"carousel_go_to": "Kalo te diapozitivi `x`",
|
||||
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
|
||||
"preferences_preload_label": "Parangarko të dhëna videoje: ",
|
||||
"toggle_theme": "Ndërroni Temë",
|
||||
"channel_tab_courses_label": "Kurse",
|
||||
"channel_tab_posts_label": "Postime",
|
||||
"First page": "Faqja e parë"
|
||||
"preferences_preload_label": "Parangarko të dhëna videoje: "
|
||||
}
|
||||
|
@ -513,10 +513,7 @@
|
||||
"Answer": "Odgovor",
|
||||
"Search for videos": "Pretražite video snimke",
|
||||
"carousel_skip": "Preskoči karusel",
|
||||
"toggle_theme": "Podesi temu",
|
||||
"toggle_theme": "Подеси тему",
|
||||
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
|
||||
"Filipino (auto-generated)": "Filipinski (automatski generisano)",
|
||||
"channel_tab_posts_label": "Objave",
|
||||
"First page": "Prva stranica",
|
||||
"channel_tab_courses_label": "Kursevi"
|
||||
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
|
||||
"carousel_slide": "Слајд {{current}} од {{total}}",
|
||||
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
|
||||
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)",
|
||||
"channel_tab_courses_label": "Курсеви",
|
||||
"First page": "Прва страница",
|
||||
"channel_tab_posts_label": "Објаве"
|
||||
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
|
||||
}
|
||||
|
@ -498,8 +498,5 @@
|
||||
"carousel_skip": "Hoppa över karusellen",
|
||||
"carousel_go_to": "Gå till bildspel `x`",
|
||||
"preferences_preload_label": "Förladda video data: ",
|
||||
"Filipino (auto-generated)": "Filippinska (auto-genererad)",
|
||||
"First page": "Första sidan",
|
||||
"channel_tab_courses_label": "Kurser",
|
||||
"channel_tab_posts_label": "Inlägg"
|
||||
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
|
||||
}
|
||||
|
@ -497,9 +497,5 @@
|
||||
"carousel_skip": "Kayar menüyü atla",
|
||||
"carousel_go_to": "`x` sunumuna git",
|
||||
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.",
|
||||
"preferences_preload_label": "Video verilerini önceden yükle: ",
|
||||
"First page": "İlk sayfa",
|
||||
"Filipino (auto-generated)": "Filipince (oto-oluşturuldu)",
|
||||
"channel_tab_courses_label": "Kurslar",
|
||||
"channel_tab_posts_label": "Yazılar"
|
||||
"preferences_preload_label": "Video verilerini önceden yükle: "
|
||||
}
|
||||
|
@ -515,8 +515,5 @@
|
||||
"carousel_skip": "Пропустити карусель",
|
||||
"carousel_go_to": "Перейти до слайда `x`",
|
||||
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
|
||||
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)",
|
||||
"First page": "Перша сторінка",
|
||||
"channel_tab_courses_label": "Курси",
|
||||
"channel_tab_posts_label": "Дописи"
|
||||
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
|
||||
}
|
||||
|
@ -314,11 +314,11 @@
|
||||
"search_filters_duration_label": "Thời lượng",
|
||||
"search_filters_features_label": "Đặc điểm",
|
||||
"search_filters_sort_label": "Sắp xếp theo",
|
||||
"search_filters_date_option_hour": "Một giờ trước",
|
||||
"search_filters_date_option_hour": "Một giờ qua",
|
||||
"search_filters_date_option_today": "Hôm nay",
|
||||
"search_filters_date_option_week": "Tuần này",
|
||||
"search_filters_date_option_month": "Tháng này",
|
||||
"search_filters_date_option_year": "Năm nay",
|
||||
"search_filters_date_option_year": "Năm này",
|
||||
"search_filters_type_option_video": "video",
|
||||
"search_filters_type_option_channel": "Kênh",
|
||||
"search_filters_type_option_playlist": "Danh sách phát",
|
||||
@ -479,8 +479,5 @@
|
||||
"carousel_skip": "Bỏ qua Carousel",
|
||||
"carousel_go_to": "Đi tới trang `x`",
|
||||
"Search for videos": "Tìm kiếm video",
|
||||
"The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý.",
|
||||
"preferences_preload_label": "Tải trước dữ liệu video: ",
|
||||
"Filipino (auto-generated)": "Tiếng Philippines (tự động tạo)",
|
||||
"First page": "Trang đầu"
|
||||
"The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý."
|
||||
}
|
||||
|
@ -420,7 +420,7 @@
|
||||
"Chinese": "中文",
|
||||
"Chinese (China)": "中文 (中国)",
|
||||
"Chinese (Hong Kong)": "中文 (中国香港)",
|
||||
"Chinese (Taiwan)": "中文 (台湾)",
|
||||
"Chinese (Taiwan)": "中文 (中国台湾)",
|
||||
"German (auto-generated)": "德语 (自动生成)",
|
||||
"Indonesian (auto-generated)": "印尼语 (自动生成)",
|
||||
"Interlingue": "国际语",
|
||||
@ -481,8 +481,5 @@
|
||||
"carousel_skip": "跳过图集",
|
||||
"carousel_go_to": "转到图 `x`",
|
||||
"preferences_preload_label": "预加载视频数据: ",
|
||||
"Filipino (auto-generated)": "菲律宾语 (自动生成)",
|
||||
"channel_tab_posts_label": "帖子",
|
||||
"First page": "第一页",
|
||||
"channel_tab_courses_label": "课程"
|
||||
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
|
||||
}
|
||||
|
@ -481,8 +481,5 @@
|
||||
"carousel_go_to": "跳到投影片 `x`",
|
||||
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。",
|
||||
"preferences_preload_label": "預先載入影片資訊 ",
|
||||
"Filipino (auto-generated)": "菲律賓語(自動產生)",
|
||||
"channel_tab_courses_label": "課程",
|
||||
"First page": "第一頁",
|
||||
"channel_tab_posts_label": "貼文"
|
||||
"Filipino (auto-generated)": "菲律賓語(自動產生)"
|
||||
}
|
||||
|
@ -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")
|
@ -18,7 +18,7 @@ shards:
|
||||
|
||||
exception_page:
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.4.1
|
||||
version: 0.2.2
|
||||
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
@ -26,7 +26,11 @@ shards:
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 1.6.0
|
||||
version: 1.1.2
|
||||
|
||||
kilt:
|
||||
git: https://github.com/jeromegn/kilt.git
|
||||
version: 0.6.1
|
||||
|
||||
pg:
|
||||
git: https://github.com/will/crystal-pg.git
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: invidious
|
||||
version: 2.20250517.0-dev
|
||||
version: 2.20250314.0
|
||||
|
||||
authors:
|
||||
- Invidious team <contact@invidious.io>
|
||||
@ -17,7 +17,10 @@ dependencies:
|
||||
version: ~> 0.21.0
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
version: ~> 1.6.0
|
||||
version: ~> 1.1.2
|
||||
kilt:
|
||||
github: jeromegn/kilt
|
||||
version: ~> 0.6.1
|
||||
protodec:
|
||||
github: iv-org/protodec
|
||||
version: ~> 0.1.5
|
||||
|
16
src/ext/kemal_content_for.cr
Normal file
16
src/ext/kemal_content_for.cr
Normal 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
|
@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt
|
||||
filesize = data.bytesize
|
||||
attachment(env, filename, disposition)
|
||||
|
||||
Kemal.config.static_headers.try(&.call(env, file_path, filestat))
|
||||
Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
|
||||
|
||||
file = IO::Memory.new(data)
|
||||
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
||||
|
@ -17,8 +17,10 @@
|
||||
require "digest/md5"
|
||||
require "file_utils"
|
||||
|
||||
# Require kemal, then our own overrides
|
||||
# Require kemal, kilt, then our own overrides
|
||||
require "kemal"
|
||||
require "kilt"
|
||||
require "./ext/kemal_content_for.cr"
|
||||
require "./ext/kemal_static_file_handler.cr"
|
||||
|
||||
require "http_proxy"
|
||||
@ -47,8 +49,7 @@ require "./invidious/channels/*"
|
||||
require "./invidious/user/*"
|
||||
require "./invidious/search/*"
|
||||
require "./invidious/routes/**"
|
||||
require "./invidious/jobs/base_job"
|
||||
require "./invidious/jobs/*"
|
||||
require "./invidious/jobs/**"
|
||||
|
||||
# Declare the base namespace for invidious
|
||||
module Invidious
|
||||
@ -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)
|
||||
|
||||
COMPANION_POOL = CompanionConnectionPool.new(
|
||||
capacity: CONFIG.pool_size
|
||||
)
|
||||
|
||||
# CLI
|
||||
Kemal.config.extra_options do |parser|
|
||||
parser.banner = "Usage: invidious [arguments]"
|
||||
@ -170,9 +167,16 @@ DECRYPT_FUNCTION =
|
||||
if sig_helper_address = CONFIG.signature_server.presence
|
||||
IV::DecryptFunction.new(sig_helper_address)
|
||||
else
|
||||
LOGGER.warn("WARNING: inv-sig-helper is required for video playback. For more information see https://docs.invidious.io/installation")
|
||||
nil
|
||||
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
|
||||
|
||||
if CONFIG.channel_threads > 0
|
||||
@ -225,8 +229,8 @@ error 500 do |env, ex|
|
||||
error_template(500, ex)
|
||||
end
|
||||
|
||||
static_headers do |env|
|
||||
env.response.headers.add("Cache-Control", "max-age=2629800")
|
||||
static_headers do |response|
|
||||
response.headers.add("Cache-Control", "max-age=2629800")
|
||||
end
|
||||
|
||||
# Init Kemal
|
||||
|
@ -35,7 +35,7 @@ struct ConfigPreferences
|
||||
property max_results : Int32 = 40
|
||||
property notifications_only : Bool = false
|
||||
property player_style : String = "invidious"
|
||||
property quality : String = "dash"
|
||||
property quality : String = "hd720"
|
||||
property quality_dash : String = "auto"
|
||||
property default_home : String? = "Popular"
|
||||
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
||||
@ -74,16 +74,6 @@ end
|
||||
class Config
|
||||
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)
|
||||
property channel_threads : Int32 = 1
|
||||
# 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
|
||||
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
|
||||
@[YAML::Field(converter: Preferences::StringToCookies)]
|
||||
property cookies : HTTP::Cookies = HTTP::Cookies.new
|
||||
@ -256,27 +240,6 @@ class Config
|
||||
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
|
||||
# See: https://github.com/iv-org/invidious/issues/3854
|
||||
if config.hmac_key.empty?
|
||||
|
@ -91,7 +91,7 @@ module Invidious::Database::Playlists
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Select
|
||||
# Salect
|
||||
# -------------------
|
||||
|
||||
def select(*, id : String) : InvidiousPlaylist?
|
||||
@ -113,7 +113,7 @@ module Invidious::Database::Playlists
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Select (filtered)
|
||||
# Salect (filtered)
|
||||
# -------------------
|
||||
|
||||
def select_like_iv(email : String) : Array(InvidiousPlaylist)
|
||||
@ -213,7 +213,7 @@ module Invidious::Database::PlaylistVideos
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Select
|
||||
# Salect
|
||||
# -------------------
|
||||
|
||||
def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo)
|
||||
|
@ -23,16 +23,10 @@ module Invidious::Frontend::WatchPage
|
||||
return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>"
|
||||
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|
|
||||
str << "<form"
|
||||
str << " class=\"pure-form pure-form-stacked\""
|
||||
str << " action='#{url}'"
|
||||
str << " action='/download'"
|
||||
str << " method='post'"
|
||||
str << " rel='noopener'"
|
||||
str << " target='_blank'>"
|
||||
|
@ -18,7 +18,16 @@ def github_details(summary : String, content : String)
|
||||
return HTML.escape(details)
|
||||
end
|
||||
|
||||
def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String)
|
||||
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
|
||||
if exception.is_a?(InfoException)
|
||||
return error_template_helper(env, status_code, exception.message || "")
|
||||
end
|
||||
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
env.response.content_type = "text/html"
|
||||
env.response.status_code = status_code
|
||||
|
||||
issue_title = "#{exception.message} (#{exception.class})"
|
||||
|
||||
issue_template = <<-TEXT
|
||||
@ -31,24 +40,6 @@ def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tup
|
||||
|
||||
issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
|
||||
|
||||
return issue_title, issue_template
|
||||
end
|
||||
|
||||
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
|
||||
if exception.is_a?(InfoException)
|
||||
return error_template_helper(env, status_code, exception.message || "")
|
||||
end
|
||||
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
env.response.content_type = "text/html"
|
||||
env.response.status_code = status_code
|
||||
|
||||
# Unpacking into issue_title, issue_template directly causes a compiler error
|
||||
# I have no idea why.
|
||||
issue_template_components = get_issue_template(env, exception)
|
||||
issue_title, issue_template = issue_template_components
|
||||
|
||||
# URLs for the error message below
|
||||
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
|
||||
url_search_issues = "https://github.com/iv-org/invidious/issues"
|
||||
@ -78,7 +69,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
|
||||
<p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
|
||||
|
||||
<!-- TODO: Add a "copy to clipboard" button -->
|
||||
<pre class="error-issue-template">#{issue_template}</pre>
|
||||
<pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre>
|
||||
</div>
|
||||
END_HTML
|
||||
|
||||
|
@ -55,11 +55,12 @@ macro templated(_filename, template = "template", navbar_search = true)
|
||||
{{ layout = "src/invidious/views/" + template + ".ecr" }}
|
||||
|
||||
__content_filename__ = {{filename}}
|
||||
render {{filename}}, {{layout}}
|
||||
content = Kilt.render({{filename}})
|
||||
Kilt.render({{layout}})
|
||||
end
|
||||
|
||||
macro rendered(filename)
|
||||
render("src/invidious/views/#{{{filename}}}.ecr")
|
||||
Kilt.render("src/invidious/views/#{{{filename}}}.ecr")
|
||||
end
|
||||
|
||||
# Similar to Kemals halt method but works in a
|
||||
|
@ -291,55 +291,6 @@ struct SearchHashtag
|
||||
end
|
||||
end
|
||||
|
||||
# A `ProblematicTimelineItem` is a `SearchItem` created by Invidious that
|
||||
# represents an item that caused an exception during parsing.
|
||||
#
|
||||
# This is not a parsed object from YouTube but rather an Invidious-only type
|
||||
# created to gracefully communicate parse errors without throwing away
|
||||
# the rest of the (hopefully) successfully parsed item on a page.
|
||||
struct ProblematicTimelineItem
|
||||
property parse_exception : Exception
|
||||
property id : String
|
||||
|
||||
def initialize(@parse_exception)
|
||||
@id = Random.new.hex(8)
|
||||
end
|
||||
|
||||
def to_json(locale : String?, json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "type", "parse-error"
|
||||
json.field "errorMessage", @parse_exception.message
|
||||
json.field "errorBacktrace", @parse_exception.inspect_with_backtrace
|
||||
end
|
||||
end
|
||||
|
||||
# Provides compatibility with PlaylistVideo
|
||||
def to_json(json : JSON::Builder, *args, **kwargs)
|
||||
return to_json("", json)
|
||||
end
|
||||
|
||||
def to_xml(env, locale, xml : XML::Builder)
|
||||
xml.element("entry") do
|
||||
xml.element("id") { xml.text "iv-err-#{@id}" }
|
||||
xml.element("title") { xml.text "Parse Error: This item has failed to parse" }
|
||||
xml.element("updated") { xml.text Time.utc.to_rfc3339 }
|
||||
|
||||
xml.element("content", type: "xhtml") do
|
||||
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
|
||||
xml.element("div") do
|
||||
xml.element("h4") { translate(locale, "timeline_parse_error_placeholder_heading") }
|
||||
xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") }
|
||||
end
|
||||
|
||||
xml.element("pre") do
|
||||
get_issue_template(env, @parse_exception)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Category
|
||||
include DB::Serializable
|
||||
|
||||
@ -382,4 +333,4 @@ struct Continuation
|
||||
end
|
||||
end
|
||||
|
||||
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category | ProblematicTimelineItem
|
||||
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category
|
||||
|
@ -262,7 +262,7 @@ def get_referer(env, fallback = "/", unroll = true)
|
||||
end
|
||||
|
||||
referer = referer.request_target
|
||||
referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z+]/, "").lstrip("/\\")
|
||||
referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z]/, "").lstrip("/\\")
|
||||
|
||||
if referer == env.request.path
|
||||
referer = fallback
|
||||
@ -383,22 +383,3 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
|
||||
end
|
||||
return text
|
||||
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
|
||||
|
@ -432,7 +432,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
|
||||
offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
|
||||
end
|
||||
|
||||
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
||||
videos = [] of PlaylistVideo
|
||||
|
||||
until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
|
||||
# 100 videos per request
|
||||
@ -448,7 +448,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32,
|
||||
end
|
||||
|
||||
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
||||
videos = [] of PlaylistVideo
|
||||
|
||||
if initial_data["contents"]?
|
||||
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
|
||||
@ -500,8 +500,6 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||
index: index,
|
||||
})
|
||||
end
|
||||
rescue ex
|
||||
videos << ProblematicTimelineItem.new(parse_exception: ex)
|
||||
end
|
||||
|
||||
return videos
|
||||
|
@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest
|
||||
id = env.params.url["id"]
|
||||
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,
|
||||
# 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 }
|
||||
|
@ -20,6 +20,14 @@ module Invidious::Routes::BeforeAll
|
||||
env.response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Allow media resources to be loaded from google servers
|
||||
# TODO: check if *.youtube.com can be removed
|
||||
if CONFIG.disabled?("local") || !preferences.local
|
||||
extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
|
||||
else
|
||||
extra_media_csp = ""
|
||||
end
|
||||
|
||||
# Only allow the pages at /embed/* to be embedded
|
||||
if env.request.resource.starts_with?("/embed")
|
||||
frame_ancestors = "'self' file: http: https:"
|
||||
@ -37,7 +45,7 @@ module Invidious::Routes::BeforeAll
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"media-src 'self' blob:" + extra_media_csp,
|
||||
"child-src 'self' blob:",
|
||||
"frame-src 'self'",
|
||||
"frame-ancestors " + frame_ancestors,
|
||||
@ -102,21 +110,6 @@ module Invidious::Routes::BeforeAll
|
||||
preferences.locale = locale
|
||||
env.set "preferences", preferences
|
||||
|
||||
# Allow media resources to be loaded from google servers
|
||||
# TODO: check if *.youtube.com can be removed
|
||||
#
|
||||
# `!preferences.local` has to be checked after setting and
|
||||
# reading `preferences` from the "PREFS" cookie and
|
||||
# saved user preferences from the database, otherwise
|
||||
# `https://*.googlevideo.com:443 https://*.youtube.com:443`
|
||||
# will not be set in the CSP header if
|
||||
# `default_user_preferences.local` is set to true on the
|
||||
# configuration file, causing preference “Proxy Videos”
|
||||
# not to work while having it disabled and using medium quality.
|
||||
if CONFIG.disabled?("local") || !preferences.local
|
||||
env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443")
|
||||
end
|
||||
|
||||
current_page = env.request.path
|
||||
if env.request.query
|
||||
query = HTTP::Params.parse(env.request.query.not_nil!)
|
||||
|
@ -12,15 +12,13 @@ module Invidious::Routes::Embed
|
||||
url = "/playlist?list=#{plid}"
|
||||
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
|
||||
end
|
||||
|
||||
first_playlist_video = videos[0].as(PlaylistVideo)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
||||
url = "/embed/#{first_playlist_video}?#{env.params.query}"
|
||||
url = "/embed/#{videos[0].id}?#{env.params.query}"
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query}"
|
||||
@ -74,15 +72,13 @@ module Invidious::Routes::Embed
|
||||
url = "/playlist?list=#{plid}"
|
||||
raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
|
||||
end
|
||||
|
||||
first_playlist_video = videos[0].as(PlaylistVideo)
|
||||
rescue ex : NotFoundException
|
||||
return error_template(404, ex)
|
||||
rescue ex
|
||||
return error_template(500, ex)
|
||||
end
|
||||
|
||||
url = "/embed/#{first_playlist_video.id}"
|
||||
url = "/embed/#{videos[0].id}"
|
||||
elsif video_series
|
||||
url = "/embed/#{video_series.shift}"
|
||||
env.params.query["playlist"] = video_series.join(",")
|
||||
@ -207,14 +203,6 @@ module Invidious::Routes::Embed
|
||||
return env.redirect url
|
||||
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"
|
||||
end
|
||||
end
|
||||
|
@ -202,7 +202,7 @@ module Invidious::Routes::Feeds
|
||||
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
|
||||
xml.element("id") { xml.text "yt:channel:#{ucid}" }
|
||||
xml.element("yt:channelId") { xml.text ucid }
|
||||
xml.element("title") { xml.text author }
|
||||
xml.element("title") { author }
|
||||
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}")
|
||||
|
||||
xml.element("author") do
|
||||
@ -296,13 +296,7 @@ module Invidious::Routes::Feeds
|
||||
xml.element("name") { xml.text playlist.author }
|
||||
end
|
||||
|
||||
videos.each do |video|
|
||||
if video.is_a? PlaylistVideo
|
||||
video.to_xml(xml)
|
||||
else
|
||||
video.to_xml(env, locale, xml)
|
||||
end
|
||||
end
|
||||
videos.each &.to_xml(xml)
|
||||
end
|
||||
end
|
||||
else
|
||||
|
@ -21,6 +21,9 @@ module Invidious::Routes::Login
|
||||
account_type = env.params.query["type"]?
|
||||
account_type ||= "invidious"
|
||||
|
||||
captcha_type = env.params.query["captcha"]?
|
||||
captcha_type ||= "image"
|
||||
|
||||
templated "user/login"
|
||||
end
|
||||
|
||||
@ -85,14 +88,34 @@ module Invidious::Routes::Login
|
||||
password = password.byte_slice(0, 55)
|
||||
|
||||
if CONFIG.captcha_enabled
|
||||
captcha_type = env.params.body["captcha_type"]?
|
||||
answer = env.params.body["answer"]?
|
||||
change_type = env.params.body["change_type"]?
|
||||
|
||||
if !captcha_type || change_type
|
||||
if change_type
|
||||
captcha_type = change_type
|
||||
end
|
||||
captcha_type ||= "image"
|
||||
|
||||
account_type = "invidious"
|
||||
|
||||
if captcha_type == "image"
|
||||
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
|
||||
else
|
||||
captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
|
||||
end
|
||||
|
||||
return templated "user/login"
|
||||
end
|
||||
|
||||
tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
|
||||
|
||||
if answer
|
||||
answer ||= ""
|
||||
captcha_type ||= "image"
|
||||
|
||||
case captcha_type
|
||||
when "image"
|
||||
answer = answer.lstrip('0')
|
||||
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
|
||||
|
||||
@ -101,8 +124,27 @@ module Invidious::Routes::Login
|
||||
rescue ex
|
||||
return error_template(400, ex)
|
||||
end
|
||||
else
|
||||
return templated "user/login"
|
||||
else # "text"
|
||||
answer = Digest::MD5.hexdigest(answer.downcase.strip)
|
||||
|
||||
if tokens.empty?
|
||||
return error_template(500, "Erroneous CAPTCHA")
|
||||
end
|
||||
|
||||
found_valid_captcha = false
|
||||
error_exception = Exception.new
|
||||
tokens.each do |tok|
|
||||
begin
|
||||
validate_request(tok, answer, env.request, HMAC_KEY, locale)
|
||||
found_valid_captcha = true
|
||||
rescue ex
|
||||
error_exception = ex
|
||||
end
|
||||
end
|
||||
|
||||
if !found_valid_captcha
|
||||
return error_template(500, error_exception)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -58,11 +58,7 @@ module Invidious::Routes::Search
|
||||
end
|
||||
|
||||
begin
|
||||
if user
|
||||
items = query.process(user.as(User))
|
||||
else
|
||||
items = query.process
|
||||
end
|
||||
rescue ex : ChannelSearchException
|
||||
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
|
||||
rescue ex
|
||||
|
@ -21,7 +21,7 @@ module Invidious::Routes::VideoPlayback
|
||||
end
|
||||
|
||||
# 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.")
|
||||
end
|
||||
|
||||
@ -37,8 +37,7 @@ module Invidious::Routes::VideoPlayback
|
||||
|
||||
# See: https://github.com/iv-org/invidious/issues/3302
|
||||
range_header = env.request.headers["Range"]?
|
||||
sq = query_params["sq"]?
|
||||
if range_header.nil? && sq.nil?
|
||||
if range_header.nil?
|
||||
range_for_head = query_params["range"]? || "0-640"
|
||||
headers["Range"] = "bytes=#{range_for_head}"
|
||||
end
|
||||
@ -257,11 +256,6 @@ module Invidious::Routes::VideoPlayback
|
||||
# YouTube /videoplayback links expire after 6 hours,
|
||||
# so we have a mechanism here to redirect to the latest version
|
||||
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"]?
|
||||
itag = env.params.query["itag"]?.try &.to_i?
|
||||
|
||||
|
@ -192,14 +192,6 @@ module Invidious::Routes::Watch
|
||||
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"
|
||||
end
|
||||
|
||||
@ -293,9 +285,6 @@ module Invidious::Routes::Watch
|
||||
if CONFIG.disabled?("downloads")
|
||||
return error_template(403, "Administrator has disabled this endpoint.")
|
||||
end
|
||||
if CONFIG.invidious_companion.present?
|
||||
return error_template(403, "Downloads should be routed through Companion when present")
|
||||
end
|
||||
|
||||
title = env.params.body["title"]? || ""
|
||||
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)
|
||||
|
||||
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
|
||||
env.params.query["id"] = video_id
|
||||
env.params.query["itag"] = itag.to_s
|
||||
env.params.query["title"] = filename
|
||||
env.params.query["local"] = "true"
|
||||
|
||||
|
@ -31,12 +31,12 @@ def fetch_trending(trending_type, region, locale)
|
||||
# See: https://github.com/iv-org/invidious/issues/2989
|
||||
next if (itm.contents.size < 24 && deduplicate)
|
||||
|
||||
extracted.concat itm.contents.select(SearchItem)
|
||||
extracted.concat extract_category(itm)
|
||||
else
|
||||
extracted << itm
|
||||
end
|
||||
end
|
||||
|
||||
# Deduplicate items before returning results
|
||||
return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
|
||||
return extracted.select(SearchVideo).uniq!(&.id), plid
|
||||
end
|
||||
|
@ -4,6 +4,8 @@ struct Invidious::User
|
||||
module Captcha
|
||||
extend self
|
||||
|
||||
private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
|
||||
|
||||
def generate_image(key)
|
||||
second = Random::Secure.rand(12)
|
||||
second_angle = second * 30
|
||||
@ -58,5 +60,19 @@ struct Invidious::User
|
||||
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
|
||||
}
|
||||
end
|
||||
|
||||
def generate_text(key)
|
||||
response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
|
||||
response = JSON.parse(response)
|
||||
|
||||
tokens = response["a"].as_a.map do |answer|
|
||||
generate_response(answer.as_s, {":login"}, key, use_nonce: true)
|
||||
end
|
||||
|
||||
return {
|
||||
question: response["q"].as_s,
|
||||
tokens: tokens,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -15,7 +15,7 @@ struct Video
|
||||
# NOTE: don't forget to bump this number if any change is made to
|
||||
# the `params` structure in videos/parser.cr!!!
|
||||
#
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
property id : String
|
||||
|
||||
|
@ -82,7 +82,7 @@ def extract_video_info(video_id : String)
|
||||
"reason" => JSON::Any.new(reason),
|
||||
}
|
||||
end
|
||||
elsif video_id != player_response.dig?("videoDetails", "videoId")
|
||||
elsif video_id != player_response.dig("videoDetails", "videoId")
|
||||
# YouTube may return a different video player response than expected.
|
||||
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
|
||||
# Line to be reverted if one day we solve the video not available issue.
|
||||
@ -108,36 +108,22 @@ def extract_video_info(video_id : String)
|
||||
params = parse_video_info(video_id, player_response)
|
||||
params["reason"] = JSON::Any.new(reason) if reason
|
||||
|
||||
if !CONFIG.invidious_companion.present?
|
||||
if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
|
||||
if player_response["streamingData"]? && player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
|
||||
LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.")
|
||||
players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile}
|
||||
|
||||
players_fallback = [YoutubeAPI::ClientType::WebMobile, YoutubeAPI::ClientType::TvHtml5]
|
||||
players_fallback.each do |player_fallback|
|
||||
client_config.client_type = player_fallback
|
||||
|
||||
next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config))
|
||||
|
||||
if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url")
|
||||
player_fallback_response = try_fetch_streaming_data(video_id, client_config)
|
||||
if player_fallback_response && player_fallback_response["streamingData"]? &&
|
||||
player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url")
|
||||
streaming_data = player_response["streamingData"].as_h
|
||||
streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"]
|
||||
player_response["streamingData"] = JSON::Any.new(streaming_data)
|
||||
break
|
||||
end
|
||||
rescue InfoException
|
||||
next LOGGER.warn("Failed to fetch streams with #{player_fallback}")
|
||||
end
|
||||
end
|
||||
|
||||
# Seems like video page can still render even without playable streams.
|
||||
# its better than nothing.
|
||||
#
|
||||
# # Were we able to find playable video streams?
|
||||
# if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
|
||||
# # No :(
|
||||
# end
|
||||
end
|
||||
|
||||
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
|
||||
params[f] = player_response[f] if player_response[f]?
|
||||
end
|
||||
@ -166,7 +152,7 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf
|
||||
playability_status = response["playabilityStatus"]["status"]
|
||||
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
|
||||
|
||||
if id != response.dig?("videoDetails", "videoId")
|
||||
if id != response.dig("videoDetails", "videoId")
|
||||
# YouTube may return a different video player response than expected.
|
||||
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
|
||||
raise InfoException.new(
|
||||
|
@ -1,6 +1,6 @@
|
||||
<%-
|
||||
thin_mode = env.get("preferences").as(Preferences).thin_mode
|
||||
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil
|
||||
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
|
||||
author_verified = item.responds_to?(:author_verified) && item.author_verified
|
||||
-%>
|
||||
|
||||
@ -97,18 +97,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<% when Category %>
|
||||
<% when ProblematicTimelineItem %>
|
||||
<div class="error-card">
|
||||
<div class="explanation">
|
||||
<i class="icon ion-ios-alert"></i>
|
||||
<h4><%=translate(locale, "timeline_parse_error_placeholder_heading")%></h4>
|
||||
<p><%=translate(locale, "timeline_parse_error_placeholder_message")%></p>
|
||||
</div>
|
||||
<details>
|
||||
<summary class="pure-button pure-button-secondary"><%=translate(locale, "timeline_parse_error_show_technical_details")%></summary>
|
||||
<pre class="error-issue-template"><%=get_issue_template(env, item.parse_exception)[1]%></pre>
|
||||
</details>
|
||||
</div>
|
||||
<% else %>
|
||||
<%-
|
||||
# `endpoint_params` is used for the "video-context-buttons" component
|
||||
|
@ -22,8 +22,6 @@
|
||||
audio_streams.each_with_index do |fmt, i|
|
||||
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
||||
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"]
|
||||
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
||||
@ -36,12 +34,8 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% if params.quality == "dash"
|
||||
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1"
|
||||
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">
|
||||
<% if params.quality == "dash" %>
|
||||
<source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
|
||||
<% end %>
|
||||
|
||||
<%
|
||||
@ -50,8 +44,6 @@
|
||||
fmt_stream.each_with_index do |fmt, i|
|
||||
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
||||
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"]
|
||||
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
||||
|
@ -9,6 +9,90 @@
|
||||
<body>
|
||||
<h1><%= translate(locale, "JavaScript license information") %></h1>
|
||||
<table id="jslicense-labels1">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>">_helpers.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/handlers.js?v=<%= ASSET_COMMIT %>">handlers.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/handlers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/community.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/embed.js?v=<%= ASSET_COMMIT %>">embed.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/embed.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>">notifications.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/player.js?v=<%= ASSET_COMMIT %>">player.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/player.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>">silvermine-videojs-quality-selector.min.js</a>
|
||||
@ -37,6 +121,34 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>">subscribe_widget.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/themes.js?v=<%= ASSET_COMMIT %>">themes.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/themes.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>">videojs-contrib-quality-levels.js</a>
|
||||
@ -177,9 +289,19 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<%- {% for row in run("../../../scripts/generate_js_licenses.cr").stringify.split('\n') %} %>
|
||||
<%-= {{row.id}} -%>
|
||||
<% {% end %} -%>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/js/watch.js?v=<%= ASSET_COMMIT %>">watch.js</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="/js/watch.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -25,17 +25,44 @@
|
||||
<% end %>
|
||||
|
||||
<% if captcha %>
|
||||
<% case captcha_type when %>
|
||||
<% when "image" %>
|
||||
<% captcha = captcha.not_nil! %>
|
||||
<img style="width:50%" src='<%= captcha[:question] %>'/>
|
||||
<% captcha[:tokens].each_with_index do |token, i| %>
|
||||
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
|
||||
<% end %>
|
||||
<input type="hidden" name="captcha_type" value="image">
|
||||
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
|
||||
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
|
||||
<% else # "text" %>
|
||||
<% captcha = captcha.not_nil! %>
|
||||
<% captcha[:tokens].each_with_index do |token, i| %>
|
||||
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
|
||||
<% end %>
|
||||
<input type="hidden" name="captcha_type" value="text">
|
||||
<label for="answer"><%= captcha[:question] %></label>
|
||||
<input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
|
||||
<% end %>
|
||||
|
||||
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
|
||||
<%= translate(locale, "Register") %>
|
||||
</button>
|
||||
|
||||
<% case captcha_type when %>
|
||||
<% when "image" %>
|
||||
<label>
|
||||
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="text">
|
||||
<%= translate(locale, "Text CAPTCHA") %>
|
||||
</button>
|
||||
</label>
|
||||
<% else # "text" %>
|
||||
<label>
|
||||
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="image">
|
||||
<%= translate(locale, "Image CAPTCHA") %>
|
||||
</button>
|
||||
</label>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
|
||||
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
|
||||
|
@ -46,43 +46,6 @@ struct YoutubeConnectionPool
|
||||
end
|
||||
end
|
||||
|
||||
struct CompanionConnectionPool
|
||||
property pool : DB::Pool(HTTP::Client)
|
||||
|
||||
def initialize(capacity = 5, timeout = 5.0)
|
||||
options = DB::Pool::Options.new(
|
||||
initial_pool_size: 0,
|
||||
max_pool_size: capacity,
|
||||
max_idle_pool_size: capacity,
|
||||
checkout_timeout: timeout
|
||||
)
|
||||
|
||||
@pool = DB::Pool(HTTP::Client).new(options) do
|
||||
companion = CONFIG.invidious_companion.sample
|
||||
next make_client(companion.private_url, use_http_proxy: false)
|
||||
end
|
||||
end
|
||||
|
||||
def client(&)
|
||||
conn = pool.checkout
|
||||
|
||||
begin
|
||||
response = yield conn
|
||||
rescue ex
|
||||
conn.close
|
||||
|
||||
companion = CONFIG.invidious_companion.sample
|
||||
conn = make_client(companion.private_url, use_http_proxy: false)
|
||||
|
||||
response = yield conn
|
||||
ensure
|
||||
pool.release(conn)
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
def add_yt_headers(request)
|
||||
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
|
||||
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
@ -98,9 +61,9 @@ def add_yt_headers(request)
|
||||
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.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
|
||||
if force_resolve
|
||||
@ -115,8 +78,8 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you
|
||||
return client
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
|
||||
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
|
||||
def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
|
||||
client = make_client(url, region, force_resolve: force_resolve)
|
||||
begin
|
||||
yield client
|
||||
ensure
|
||||
|
@ -35,20 +35,6 @@ record AuthorFallback, name : String, id : String
|
||||
# data is passed to the private `#parse()` method which returns a datastruct of the given
|
||||
# type. Otherwise, nil is returned.
|
||||
private module Parsers
|
||||
module BaseParser
|
||||
def parse(*args)
|
||||
begin
|
||||
return parse_internal(*args)
|
||||
rescue ex
|
||||
LOGGER.debug("#{{{@type.name}}}: Failed to render item.")
|
||||
LOGGER.debug("#{{{@type.name}}}: Got exception: #{ex.message}")
|
||||
ProblematicTimelineItem.new(
|
||||
parse_exception: ex
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer
|
||||
#
|
||||
# A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not**
|
||||
@ -59,16 +45,13 @@ private module Parsers
|
||||
# `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
|
||||
#
|
||||
module VideoRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
video_id = item_contents["videoId"].as_s
|
||||
title = extract_text(item_contents["title"]?) || ""
|
||||
|
||||
@ -132,7 +115,7 @@ private module Parsers
|
||||
badges = VideoBadges::None
|
||||
item_contents["badges"]?.try &.as_a.each do |badge|
|
||||
b = badge["metadataBadgeRenderer"]
|
||||
case b["label"]?.try &.as_s
|
||||
case b["label"].as_s
|
||||
when "LIVE"
|
||||
badges |= VideoBadges::LiveNow
|
||||
when "New"
|
||||
@ -187,16 +170,13 @@ private module Parsers
|
||||
# `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
|
||||
#
|
||||
module ChannelRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?)
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
author = extract_text(item_contents["title"]) || author_fallback.name
|
||||
author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id
|
||||
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
|
||||
@ -250,16 +230,13 @@ private module Parsers
|
||||
# A `hashtagTileRenderer` is a kind of search result.
|
||||
# It can be found when searching for any hashtag (e.g "#hi" or "#shorts")
|
||||
module HashtagRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["hashtagTileRenderer"]?
|
||||
return self.parse(item_contents)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents)
|
||||
private def self.parse(item_contents)
|
||||
title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi"
|
||||
|
||||
# E.g "/hashtag/hi"
|
||||
@ -286,6 +263,10 @@ private module Parsers
|
||||
video_count: short_text_to_number(video_count_txt || ""),
|
||||
channel_count: short_text_to_number(channel_count_txt || ""),
|
||||
})
|
||||
rescue ex
|
||||
LOGGER.debug("HashtagRendererParser: Failed to extract renderer.")
|
||||
LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}")
|
||||
return nil
|
||||
end
|
||||
|
||||
def self.parser_name
|
||||
@ -303,16 +284,13 @@ private module Parsers
|
||||
# `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories.
|
||||
#
|
||||
module GridPlaylistRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["gridPlaylistRenderer"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
title = extract_text(item_contents["title"]) || ""
|
||||
plid = item_contents["playlistId"]?.try &.as_s || ""
|
||||
|
||||
@ -347,16 +325,13 @@ private module Parsers
|
||||
# `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc.
|
||||
#
|
||||
module PlaylistRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["playlistRenderer"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
title = extract_text(item_contents["title"]) || ""
|
||||
plid = item_contents["playlistId"]?.try &.as_s || ""
|
||||
|
||||
@ -410,16 +385,13 @@ private module Parsers
|
||||
# `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
|
||||
#
|
||||
module CategoryRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["shelfRenderer"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
title = extract_text(item_contents["title"]?) || ""
|
||||
url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url")
|
||||
.try &.as_s
|
||||
@ -478,16 +450,13 @@ private module Parsers
|
||||
# container.It is very similar to RichItemRendererParser
|
||||
#
|
||||
module ItemSectionRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item.dig?("itemSectionRenderer", "contents", 0)
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
child = VideoRendererParser.process(item_contents, author_fallback)
|
||||
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
|
||||
|
||||
@ -507,16 +476,13 @@ private module Parsers
|
||||
# itself inside a richGridRenderer container.
|
||||
#
|
||||
module RichItemRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item.dig?("richItemRenderer", "content")
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
child = VideoRendererParser.process(item_contents, author_fallback)
|
||||
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
|
||||
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
|
||||
@ -540,16 +506,13 @@ private module Parsers
|
||||
# TODO: Confirm that hypothesis
|
||||
#
|
||||
module ReelItemRendererParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["reelItemRenderer"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
video_id = item_contents["videoId"].as_s
|
||||
|
||||
reel_player_overlay = item_contents.dig(
|
||||
@ -637,16 +600,13 @@ private module Parsers
|
||||
# a richItemRenderer or a richGridRenderer.
|
||||
#
|
||||
module LockupViewModelParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["lockupViewModel"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
playlist_id = item_contents["contentId"].as_s
|
||||
|
||||
thumbnail_view_model = item_contents.dig(
|
||||
@ -715,16 +675,13 @@ private module Parsers
|
||||
# usually (always?) encapsulated in a richItemRenderer.
|
||||
#
|
||||
module ShortsLockupViewModelParser
|
||||
extend self
|
||||
include BaseParser
|
||||
|
||||
def process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||
if item_contents = item["shortsLockupViewModel"]?
|
||||
return self.parse(item_contents, author_fallback)
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_internal(item_contents, author_fallback)
|
||||
private def self.parse(item_contents, author_fallback)
|
||||
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
|
||||
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
|
||||
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
|
||||
|
@ -500,12 +500,8 @@ module YoutubeAPI
|
||||
data["params"] = params
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
####################################################################
|
||||
# resolve_url(url, client_config?)
|
||||
@ -670,49 +666,6 @@ module YoutubeAPI
|
||||
return initial_data
|
||||
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)
|
||||
#
|
||||
|
Loading…
x
Reference in New Issue
Block a user