diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f17bb40..9ca09368 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 74f6302c..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "docker" - directory: "/docker" - schedule: - interval: "weekly" - - package-ecosystem: github-actions - directory: / - schedule: - interval: "weekly" diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml index 4149bd0b..5ff3322f 100644 --- a/.github/workflows/build-nightly-container.yml +++ b/.github/workflows/build-nightly-container.yml @@ -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 diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml index 1a23e68c..25571ed6 100644 --- a/.github/workflows/build-stable-container.yml +++ b/.github/workflows/build-stable-container.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d6a930a..5f859613 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 65340d14..498a2c1b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fbe7f3..c0718686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 97d2109b..b139c5f6 100644 --- a/README.md +++ b/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 diff --git a/assets/css/default.css b/assets/css/default.css index 01d4b736..2cedcf0c 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -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); -} \ No newline at end of file diff --git a/assets/css/search.css b/assets/css/search.css index 833ec7e9..7036fd28 100644 --- a/assets/css/search.css +++ b/assets/css/search.css @@ -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 { diff --git a/config/config.example.yml b/config/config.example.yml index 8d3e6212..b04e0a30 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -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)://:" -## Default: -## -#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: -## -#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. diff --git a/docker/Dockerfile b/docker/Dockerfile index 4cfc3c72..a07bef28 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 && \ diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 758e7950..7fcb176e 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -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 && \ diff --git a/locales/ar.json b/locales/ar.json index 94103c29..a8f5e62d 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -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)": "الفلبينية (المولدة تلقائيًا)" } diff --git a/locales/bg.json b/locales/bg.json index 5c99d98f..baa683c9 100644 --- a/locales/bg.json +++ b/locales/bg.json @@ -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.": "Популярната страница е деактивирана от администратора." } diff --git a/locales/ca.json b/locales/ca.json index 474d6a3c..bbcadf89 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -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 cercar en una altra instància.", + "search_message_use_another_instance": " També es pot buscar en una altra instància.", "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" } diff --git a/locales/cs.json b/locales/cs.json index 41f3db5c..d28f2098 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -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)" } diff --git a/locales/cy.json b/locales/cy.json index eb391572..566e73e1 100644 --- a/locales/cy.json +++ b/locales/cy.json @@ -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" } diff --git a/locales/de.json b/locales/de.json index e51d40b9..ce6fde8b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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)" } diff --git a/locales/el.json b/locales/el.json index e5a89a44..32efaaf8 100644 --- a/locales/el.json +++ b/locales/el.json @@ -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": "Αποφυγή εμφάνισης εικόνων" } diff --git a/locales/en-US.json b/locales/en-US.json index 3f42a509..4f2c2770 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -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`" } diff --git a/locales/es.json b/locales/es.json index 46217943..ad65e07d 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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)" } diff --git a/locales/fr.json b/locales/fr.json index 49aa09df..800c7aaf 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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 : " } diff --git a/locales/is.json b/locales/is.json index 28cacf31..d94357f1 100644 --- a/locales/is.json +++ b/locales/is.json @@ -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ð nota annað tilvik", "crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að opna nýja verkbeiðni (issue) á GitHub (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. Smelltu hér til að fara á heimasíðu spilunarlistans.", @@ -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)" } diff --git a/locales/it.json b/locales/it.json index c7143ef6..3f008ccd 100644 --- a/locales/it.json +++ b/locales/it.json @@ -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)" } diff --git a/locales/ja.json b/locales/ja.json index c4b82486..5e90148d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -25,7 +25,7 @@ "No": "いいえ", "Import and Export Data": "データのインポートとエクスポート", "Import": "インポート", - "Import Invidious data": "Invidious JSON データをインポート", + "Import Invidious data": "Invidious JSONデータをインポート", "Import YouTube subscriptions": "YouTube/OPML 登録チャンネルをインポート", "Import FreeTube subscriptions (.db)": "FreeTube 登録チャンネルをインポート (.db)", "Import NewPipe subscriptions (.json)": "NewPipe 登録チャンネルをインポート (.json)", @@ -68,7 +68,7 @@ "preferences_related_videos_label": "関連動画を表示: ", "preferences_annotations_label": "最初からアノテーションを表示: ", "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ", - "preferences_vr_mode_label": "対話的な 360° 動画 (WebGL が必要): ", + "preferences_vr_mode_label": "対話的な360°動画 (WebGLが必要): ", "preferences_category_visual": "外観設定", "preferences_player_style_label": "プレイヤーのスタイル: ", "Dark mode: ": "ダークモード: ", @@ -77,7 +77,7 @@ "light": "ライト", "preferences_thin_mode_label": "最小モード: ", "preferences_category_misc": "ほかの設定", - "preferences_automatic_instance_redirect_label": "インスタンスの自動転送 (redirect.invidious.io にフォールバック): ", + "preferences_automatic_instance_redirect_label": "インスタンスの自動転送 (redirect.invidious.ioにフォールバック): ", "preferences_category_subscription": "登録チャンネル設定", "preferences_annotations_subscribed_label": "最初から登録チャンネルのアノテーションを表示 ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", @@ -125,7 +125,7 @@ "subscriptions_unseen_notifs_count_0": "{{count}}件の未読通知", "search": "検索", "Log out": "ログアウト", - "Released under the AGPLv3 on Github.": "GitHub 上で AGPLv3 の元で公開", + "Released under the AGPLv3 on Github.": "GitHub上でAGPLv3の元で公開", "Source available here.": "ソースはここで閲覧可能です。", "View JavaScript license information.": "JavaScriptライセンス情報", "View privacy policy.": "個人情報保護方針", @@ -143,8 +143,8 @@ "Editing playlist `x`": "再生リスト `x` を編集中", "Show more": "もっと見る", "Show less": "表示を少なく", - "Watch on YouTube": "YouTube で視聴", - "Switch Invidious Instance": "Invidious インスタンスの変更", + "Watch on YouTube": "YouTubeで視聴", + "Switch Invidious Instance": "Invidiousインスタンスの変更", "Hide annotations": "アノテーションを隠す", "Show annotations": "アノテーションを表示", "Genre: ": "ジャンル: ", @@ -330,7 +330,7 @@ "(edited)": "(編集済み)", "YouTube comment permalink": "YouTube コメントのパーマリンク", "permalink": "パーマリンク", - "`x` marked it with a ❤": "`x` が ❤ を送りました", + "`x` marked it with a ❤": "`x` が❤を送りました", "Audio mode": "音声モード", "Video mode": "動画モード", "channel_tab_videos_label": "動画", @@ -343,7 +343,7 @@ "search_filters_type_label": "種類", "search_filters_duration_label": "再生時間", "search_filters_features_label": "特徴", - "search_filters_sort_label": "並べ替え", + "search_filters_sort_label": "順番", "search_filters_date_option_hour": "1時間以内", "search_filters_date_option_today": "今日", "search_filters_date_option_week": "今週", @@ -365,13 +365,13 @@ "Current version: ": "現在のバージョン: ", "next_steps_error_message": "以下をお試しください: ", "next_steps_error_message_refresh": "再読み込み", - "next_steps_error_message_go_to_youtube": "YouTube を開く", + "next_steps_error_message_go_to_youtube": "YouTubeを開く", "search_filters_duration_option_short": "4分未満", "footer_documentation": "説明書", "footer_source_code": "ソースコード", "footer_original_source_code": "元のソースコード", - "footer_modfied_source_code": "改変し使用中", - "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリの URL", + "footer_modfied_source_code": "改変して使用", + "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL", "search_filters_duration_option_long": "20分以上", "preferences_region_label": "地域: ", "footer_donate_page": "寄付する", @@ -399,7 +399,7 @@ "preferences_quality_dash_option_worst": "最低", "preferences_quality_dash_option_best": "最高", "videoinfo_started_streaming_x_ago": "`x`前に配信を開始", - "videoinfo_watch_on_youTube": "YouTube で視聴", + "videoinfo_watch_on_youTube": "YouTubeで視聴", "user_created_playlists": "`x`個の作成した再生リスト", "Video unavailable": "動画は利用できません", "Chinese": "中国語", @@ -446,7 +446,7 @@ "search_filters_duration_option_medium": "4 ~ 20分", "preferences_save_player_pos_label": "再生位置を保存: ", "crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。", - "crash_page_report_issue": "上記が助けにならない場合、GitHub に新しい issue を作成し (できれば英語で) 、メッセージに次のテキストを含めてください (テキストは翻訳しない) 。", + "crash_page_report_issue": "上記が助けにならないなら、GitHub に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてください(テキストは翻訳しない)。", "crash_page_search_issue": "GitHub の既存の問題 (issue) を検索", "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)": "フィリピノ語 (自動生成)" } diff --git a/locales/ko.json b/locales/ko.json index 0224955f..c2d3f6e2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -419,7 +419,7 @@ "Portuguese (Brazil)": "포르투갈어 (브라질)", "search_message_no_results": "결과가 없습니다.", "search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.", - "search_message_use_another_instance": "다른 인스턴스에서 검색할 수도 있습니다.", + "search_message_use_another_instance": " 다른 인스턴스에서 검색할 수도 있습니다.", "English (United States)": "영어 (미국)", "Chinese": "중국어", "Chinese (China)": "중국어 (중국)", @@ -480,9 +480,5 @@ "Search for videos": "비디오 검색", "toggle_theme": "테마 전환", "carousel_slide": "{{total}}의 슬라이드 {{current}}", - "preferences_preload_label": "비디오 데이터 사전 로드: ", - "First page": "첫 페이지", - "Filipino (auto-generated)": "Filipino (auto-generated)", - "channel_tab_posts_label": "게시글", - "channel_tab_courses_label": "코스" + "preferences_preload_label": "비디오 데이터 사전 로드: " } diff --git a/locales/lv.json b/locales/lv.json deleted file mode 100644 index a867c8f3..00000000 --- a/locales/lv.json +++ /dev/null @@ -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ē" -} diff --git a/locales/nl.json b/locales/nl.json index e9ce7674..a908e26a 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -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)" } diff --git a/locales/pl.json b/locales/pl.json index d78b7a95..b119ab22 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -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)" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 1eb3c989..3d653caf 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -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)" } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 449bde77..f83a80a9 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -1,27 +1,27 @@ { - "LIVE": "Direto", + "LIVE": "Em direto", "Shared `x` ago": "Partilhado `x` atrás", "Unsubscribe": "Anular subscrição", "Subscribe": "Subscrever", "View channel on YouTube": "Ver canal no YouTube", "View playlist on YouTube": "Ver lista de reprodução no YouTube", - "newest": "recentes", - "oldest": "antigos", - "popular": "populares", + "newest": "mais recentes", + "oldest": "mais antigos", + "popular": "popular", "last": "últimos", - "Next page": "Página seguinte", + "Next page": "Próxima página", "Previous page": "Página anterior", "Clear watch history?": "Limpar histórico de reprodução?", - "New password": "Nova palavra-passe", - "New passwords must match": "As novas palavras-passe devem ser iguais", - "Authorize token?": "Autorizar 'token'?", - "Authorize token for `x`?": "Autorizar 'token' para `x`?", + "New password": "Nova palavra-chave", + "New passwords must match": "As novas palavra-chaves devem corresponder", + "Authorize token?": "Autorizar token?", + "Authorize token for `x`?": "Autorizar token para `x`?", "Yes": "Sim", "No": "Não", "Import and Export Data": "Importar e exportar dados", "Import": "Importar", "Import Invidious data": "Importar dados JSON do Invidious", - "Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML", + "Import YouTube subscriptions": "Importar subscrições do YouTube/OPML", "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", @@ -32,38 +32,38 @@ "Delete account?": "Eliminar conta?", "History": "Histórico", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", - "JavaScript license information": "Informação da licença JavaScript", - "source": "fonte", + "JavaScript license information": "Informação de licença do JavaScript", + "source": "código-fonte", "Log in": "Iniciar sessão", "Log in/register": "Iniciar sessão/registar", "User ID": "Utilizador", - "Password": "Palavra-passe", + "Password": "Palavra-chave", "Time (h:mm:ss):": "Tempo (h:mm:ss):", "Text CAPTCHA": "Texto CAPTCHA", "Image CAPTCHA": "Imagem CAPTCHA", - "Sign In": "Entrar", + "Sign In": "Iniciar sessão", "Register": "Registar", "E-mail": "E-mail", "Preferences": "Preferências", "preferences_category_player": "Preferências do reprodutor", "preferences_video_loop_label": "Repetir sempre: ", "preferences_autoplay_label": "Reprodução automática: ", - "preferences_continue_label": "Reproduzir sempre o seguinte: ", + "preferences_continue_label": "Reproduzir sempre o próximo: ", "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", "preferences_listen_label": "Apenas áudio: ", "preferences_local_label": "Usar proxy nos vídeos: ", "preferences_speed_label": "Velocidade preferida: ", "preferences_quality_label": "Qualidade de vídeo preferida: ", - "preferences_volume_label": "Volume de reprodução: ", - "preferences_comments_label": "Comentários padrão: ", + "preferences_volume_label": "Volume da reprodução: ", + "preferences_comments_label": "Preferência dos comentários: ", "youtube": "YouTube", "reddit": "Reddit", - "preferences_captions_label": "Legendas padrão: ", + "preferences_captions_label": "Legendas predefinidas: ", "Fallback captions: ": "Legendas alternativas: ", "preferences_related_videos_label": "Mostrar vídeos relacionados: ", "preferences_annotations_label": "Mostrar anotações sempre: ", - "preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ", - "preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ", + "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ", "preferences_category_visual": "Preferências visuais", "preferences_player_style_label": "Estilo do reprodutor: ", "Dark mode: ": "Modo escuro: ", @@ -74,9 +74,9 @@ "preferences_category_misc": "Preferências diversas", "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", "preferences_category_subscription": "Preferências de subscrições", - "preferences_annotations_subscribed_label": "Mostrar sempre anotações nos canais subscritos: ", + "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", - "preferences_max_results_label": "Número de vídeos nas subscrições: ", + "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", "preferences_sort_label": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - inverso", @@ -88,19 +88,19 @@ "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ", "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", - "Enable web notifications": "Ativar notificações web", - "`x` uploaded a video": "`x` publicou um vídeo", + "Enable web notifications": "Ativar notificações pela web", + "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está em direto", "preferences_category_data": "Preferências de dados", "Clear watch history": "Limpar histórico de reprodução", - "Import/export data": "Importar/exportar dados", - "Change password": "Alterar palavra-passe", - "Manage subscriptions": "Gerir subscrições", + "Import/export data": "Importar / exportar dados", + "Change password": "Alterar palavra-chave", + "Manage subscriptions": "Gerir as subscrições", "Manage tokens": "Gerir tokens", "Watch history": "Histórico de reprodução", "Delete account": "Eliminar conta", "preferences_category_admin": "Preferências de administrador", - "preferences_default_home_label": "Página inicial padrão: ", + "preferences_default_home_label": "Página inicial predefinida: ", "preferences_feed_menu_label": "Menu de subscrições: ", "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", "Top enabled: ": "Destaques ativados: ", @@ -109,29 +109,28 @@ "Registration enabled: ": "Registar ativado: ", "Report statistics: ": "Relatório de estatísticas: ", "Save preferences": "Guardar preferências", - "Subscription manager": "Gestor de subscrições", - "Token manager": "Gestor de tokens", + "Subscription manager": "Gerir subscrições", + "Token manager": "Gerir tokens", "Token": "Token", - "tokens_count_0": "{{count}} token", - "tokens_count_1": "{{count}} tokens", - "tokens_count_2": "{{count}} tokens", - "Import/export": "Importar/exportar", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", + "Import/export": "Importar / exportar", "unsubscribe": "anular subscrição", "revoke": "revogar", "Subscriptions": "Subscrições", "search": "pesquisar", "Log out": "Terminar sessão", - "Released under the AGPLv3 on Github.": "Disponibilizada sob a AGPLv3 no GitHub.", + "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.", "Source available here.": "Código-fonte disponível aqui.", - "View JavaScript license information.": "Ver informações da licença JavaScript.", - "View privacy policy.": "Ver política de privacidade.", + "View JavaScript license information.": "Ver informações da licença do JavaScript.", + "View privacy policy.": "Ver a política de privacidade.", "Trending": "Tendências", "Public": "Público", "Unlisted": "Não listado", "Private": "Privado", "View all playlists": "Ver todas as listas de reprodução", - "Updated `x` ago": "Atualizado 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)", @@ -471,52 +455,21 @@ "search_filters_date_option_none": "Qualquer data", "search_filters_features_option_three_sixty": "360°", "search_filters_features_option_vr180": "VR180", - "search_message_use_another_instance": "Também pode pesquisar noutra instância.", + "search_message_use_another_instance": " Também pode pesquisar noutra instância.", "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 Perguntas frequentes (FAQ)", + "crash_page_read_the_faq": "leia as Perguntas frequentes (FAQ)", "crash_page_search_issue": "procurou se o erro já foi reportado no GitHub", - "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor abra um novo problema no Github (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 abra um novo problema no Github (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 recarregar a página", "crash_page_switch_instance": "tentou usar outra instância", - "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. Clique aqui para voltar à página inicial da lista de reprodução.", + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. Clique aqui para a página inicial da lista de reprodução.", "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" } diff --git a/locales/pt.json b/locales/pt.json index 6438e15b..9c8562f2 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -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)" } diff --git a/locales/ru.json b/locales/ru.json index 906f00fc..b7dc91cf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -475,7 +475,7 @@ "search_filters_date_option_none": "Любая дата", "search_filters_date_label": "Дата загрузки", "search_message_no_results": "Ничего не найдено.", - "search_message_use_another_instance": "Дополнительно вы можете поискать на других зеркалах.", + "search_message_use_another_instance": " Дополнительно вы можете поискать на других зеркалах.", "search_filters_features_option_vr180": "VR180", "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос и/или изменить фильтры.", "search_filters_duration_option_medium": "Средние (4 - 20 минут)", @@ -515,7 +515,5 @@ "carousel_slide": "Пролистано {{current}} из {{total}}", "carousel_skip": "Пропустить всё", "carousel_go_to": "Перейти к странице `x`", - "preferences_preload_label": "Предзагрузка видеоданных: ", - "channel_tab_courses_label": "Курсы", - "channel_tab_posts_label": "Записи" + "preferences_preload_label": "Предзагрузка видеоданных: " } diff --git a/locales/sq.json b/locales/sq.json index cdf4b605..2a404828 100644 --- a/locales/sq.json +++ b/locales/sq.json @@ -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: " } diff --git a/locales/sr.json b/locales/sr.json index c6614ba5..1d54972c 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -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)" } diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index e6ab0f35..e5279c8a 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -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)": "Филипински (аутоматски генерисано)" } diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 8f050d98..614132e0 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -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)" } diff --git a/locales/tr.json b/locales/tr.json index cf3f8987..94c127db 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -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: " } diff --git a/locales/uk.json b/locales/uk.json index b99923e2..2472f247 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -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)": "Філіппінська (згенеровано автоматично)" } diff --git a/locales/vi.json b/locales/vi.json index 9c4a5a15..229f8fa9 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -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ý." } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f3bc660b..2024bdd5 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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)": "菲律宾语 (自动生成)" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 77805349..b3d67130 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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)": "菲律賓語(自動產生)" } diff --git a/scripts/generate_js_licenses.cr b/scripts/generate_js_licenses.cr deleted file mode 100644 index 1f4ffa62..00000000 --- a/scripts/generate_js_licenses.cr +++ /dev/null @@ -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 - " - #{file_name} - #{licence_name} - \#{translate(locale, "source")} - " - 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") diff --git a/shard.lock b/shard.lock index 1265eda6..a097b081 100644 --- a/shard.lock +++ b/shard.lock @@ -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 diff --git a/shard.yml b/shard.yml index 839ebca5..90d68502 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: invidious -version: 2.20250517.0-dev +version: 2.20250314.0 authors: - Invidious team @@ -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 diff --git a/src/ext/kemal_content_for.cr b/src/ext/kemal_content_for.cr new file mode 100644 index 00000000..a4f3fd96 --- /dev/null +++ b/src/ext/kemal_content_for.cr @@ -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 diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index a5f42261..eb068aeb 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -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") diff --git a/src/invidious.cr b/src/invidious.cr index 69f8a26c..7b76c886 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -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 diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 4d69854c..453256b5 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -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? diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 6dbcaa05..08aa719a 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -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) diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index 15d925e3..2e2f6ad0 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -23,16 +23,10 @@ module Invidious::Frontend::WatchPage return "

#{translate(locale, "Download is disabled")}

" 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 << "" diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index e2c4b650..900cb0c6 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -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

#{translate(locale, "crash_page_report_issue", url_new_issue)}

-
#{issue_template}
+
#{issue_template}
END_HTML diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 84847321..43e7171b 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -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 diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 2796a8dc..f8e8f187 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -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 diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 5637e533..4d9bb28d 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -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 diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7c584d15..b670c009 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -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 diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index c27caad7..6c4225e5 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -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 } diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index b5269668..5695dee9 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -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!) diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 930e4915..00f24159 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -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 diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb..7f9a0edb 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -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 diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7de5018..d0f7ac22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -21,6 +21,9 @@ module Invidious::Routes::Login account_type = env.params.query["type"]? account_type ||= "invidious" + captcha_type = env.params.query["captcha"]? + captcha_type ||= "image" + templated "user/login" end @@ -85,14 +88,34 @@ module Invidious::Routes::Login password = password.byte_slice(0, 55) if CONFIG.captcha_enabled + captcha_type = env.params.body["captcha_type"]? answer = env.params.body["answer"]? + change_type = env.params.body["change_type"]? - account_type = "invidious" - captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + if !captcha_type || change_type + if change_type + captcha_type = change_type + end + captcha_type ||= "image" + + account_type = "invidious" + + if captcha_type == "image" + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + else + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) + end + + return templated "user/login" + end tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } - if answer + answer ||= "" + captcha_type ||= "image" + + case captcha_type + when "image" answer = answer.lstrip('0') answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) @@ -101,8 +124,27 @@ module Invidious::Routes::Login rescue ex return error_template(400, ex) end - else - return templated "user/login" + else # "text" + answer = Digest::MD5.hexdigest(answer.downcase.strip) + + if tokens.empty? + return error_template(500, "Erroneous CAPTCHA") + end + + found_valid_captcha = false + error_exception = Exception.new + tokens.each do |tok| + begin + validate_request(tok, answer, env.request, HMAC_KEY, locale) + found_valid_captcha = true + rescue ex + error_exception = ex + end + end + + if !found_valid_captcha + return error_template(500, error_exception) + end end end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b3..44970922 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -58,11 +58,7 @@ module Invidious::Routes::Search end begin - if user - items = query.process(user.as(User)) - else - items = query.process - end + items = query.process rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 083087a9..a8f9f665 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -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? diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index e777b3f1..1f384546 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -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" diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index d14cde5d..107d148d 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -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 diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr index b175c3b9..8a0f67e5 100644 --- a/src/invidious/user/captcha.cr +++ b/src/invidious/user/captcha.cr @@ -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 diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 348a0a66..962f87bd 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -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 diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index feb58440..c63623ee 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -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,34 +108,20 @@ 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? - LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") - players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile} - - players_fallback.each do |player_fallback| - client_config.client_type = player_fallback - - next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) - - if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") - 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}") + 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::WebMobile, YoutubeAPI::ClientType::TvHtml5] + players_fallback.each do |player_fallback| + client_config.client_type = player_fallback + 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 end - - # Seems like video page can still render even without playable streams. - # its better than nothing. - # - # # Were we able to find playable video streams? - # if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - # # No :( - # end end {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| @@ -166,7 +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( diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index a24423df..c966a926 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -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 @@ <% when Category %> - <% when ProblematicTimelineItem %> -
-
- -

<%=translate(locale, "timeline_parse_error_placeholder_heading")%>

-

<%=translate(locale, "timeline_parse_error_placeholder_message")%>

-
-
- <%=translate(locale, "timeline_parse_error_show_technical_details")%> -
<%=get_issue_template(env, item.parse_exception)[1]%>
-
-
<% else %> <%- # `endpoint_params` is used for the "video-context-buttons" component diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index af352102..523f6bbf 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -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) - %> - + <% if params.quality == "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) diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 3037f3d7..667cfa37 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -9,6 +9,90 @@

<%= translate(locale, "JavaScript license information") %>

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - <%- {% for row in run("../../../scripts/generate_js_licenses.cr").stringify.split('\n') %} %> - <%-= {{row.id}} -%> - <% {% end %} -%> + + + + + + +
+ _helpers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ handlers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ community.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ embed.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ notifications.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ player.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
silvermine-videojs-quality-selector.min.js @@ -37,6 +121,34 @@
+ subscribe_widget.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
+ themes.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
videojs-contrib-quality-levels.js @@ -177,9 +289,19 @@
+ watch.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 7ac96bc6..2b03d280 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -25,17 +25,44 @@ <% end %> <% if captcha %> - <% captcha = captcha.not_nil! %> - - <% captcha[:tokens].each_with_index do |token, i| %> - + <% case captcha_type when %> + <% when "image" %> + <% captcha = captcha.not_nil! %> + + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + + <% else # "text" %> + <% captcha = captcha.not_nil! %> + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + "> <% end %> - - + + <% case captcha_type when %> + <% when "image" %> + + <% else # "text" %> + + <% end %> <% else %>