summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.ameba.yml92
-rw-r--r--.github/CODEOWNERS18
-rw-r--r--.github/FUNDING.yml1
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md13
-rw-r--r--.github/workflows/ci.yml3
-rw-r--r--.github/workflows/container-release.yml15
-rw-r--r--.github/workflows/lock.yml22
-rw-r--r--Makefile118
-rw-r--r--README.md38
-rw-r--r--assets/css/default.css43
-rw-r--r--assets/css/player.css4
-rw-r--r--assets/js/handlers.js12
-rw-r--r--assets/js/player.js97
-rw-r--r--assets/js/themes.js21
-rw-r--r--assets/js/watch.js2
-rw-r--r--assets/robots.txt4
-rw-r--r--config/config.example.yml34
-rwxr-xr-xconfig/migrate-scripts/migrate-db-17cf077.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-1c8075c.sh11
-rwxr-xr-xconfig/migrate-scripts/migrate-db-1eca969.sh37
-rwxr-xr-xconfig/migrate-scripts/migrate-db-30e6d29.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-3646395.sh9
-rwxr-xr-xconfig/migrate-scripts/migrate-db-3bcb98e.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-52cb239.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-6e51189.sh7
-rwxr-xr-xconfig/migrate-scripts/migrate-db-701b5ea.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-88b7097.sh5
-rwxr-xr-xconfig/migrate-scripts/migrate-db-8e884fe.sh9
-rw-r--r--config/sql/annotations.sql4
-rw-r--r--config/sql/channel_videos.sql6
-rw-r--r--config/sql/channels.sql6
-rw-r--r--config/sql/nonces.sql6
-rw-r--r--config/sql/playlist_videos.sql4
-rw-r--r--config/sql/playlists.sql4
-rw-r--r--config/sql/session_ids.sql6
-rw-r--r--config/sql/users.sql6
-rw-r--r--config/sql/videos.sql6
-rw-r--r--docker-compose.yml7
-rw-r--r--docker/Dockerfile6
-rw-r--r--docker/Dockerfile.arm648
-rwxr-xr-xdocker/init-invidious-db.sh22
-rw-r--r--locales/ar.json179
-rw-r--r--locales/bn_BD.json386
-rw-r--r--locales/ca.json103
-rw-r--r--locales/cs.json201
-rw-r--r--locales/da.json539
-rw-r--r--locales/de.json199
-rw-r--r--locales/el.json165
-rw-r--r--locales/en-US.json214
-rw-r--r--locales/eo.json144
-rw-r--r--locales/es.json212
-rw-r--r--locales/eu.json387
-rw-r--r--locales/fa.json323
-rw-r--r--locales/fi.json133
-rw-r--r--locales/fr.json214
-rw-r--r--locales/he.json176
-rw-r--r--locales/hr.json255
-rw-r--r--locales/hu-HU.json548
-rw-r--r--locales/id.json291
-rw-r--r--locales/is.json165
-rw-r--r--locales/it.json183
-rw-r--r--locales/ja.json192
-rw-r--r--locales/ko.json159
-rw-r--r--locales/lt.json146
-rw-r--r--locales/nb-NO.json207
-rw-r--r--locales/nl.json185
-rw-r--r--locales/pl.json232
-rw-r--r--locales/pt-BR.json214
-rw-r--r--locales/pt-PT.json276
-rw-r--r--locales/pt.json414
-rw-r--r--locales/ro.json165
-rw-r--r--locales/ru.json140
-rw-r--r--locales/si.json427
-rw-r--r--locales/sk.json387
-rw-r--r--locales/sq.json1
-rw-r--r--locales/sr.json792
-rw-r--r--locales/sr_Cyrl.json722
-rw-r--r--locales/sv-SE.json140
-rw-r--r--locales/tr.json216
-rw-r--r--locales/uk.json165
-rw-r--r--locales/vi.json155
-rw-r--r--locales/zh-CN.json196
-rw-r--r--locales/zh-TW.json198
-rw-r--r--shard.lock7
-rw-r--r--shard.yml8
-rw-r--r--spec/helpers_spec.cr141
-rw-r--r--spec/i18next_plurals_spec.cr214
-rw-r--r--spec/invidious/helpers_spec.cr100
-rw-r--r--spec/invidious/user/imports_spec.cr51
-rw-r--r--spec/spec_helper.cr18
-rw-r--r--src/invidious.cr954
-rw-r--r--src/invidious/channels/about.cr149
-rw-r--r--src/invidious/channels/channels.cr44
-rw-r--r--src/invidious/channels/community.cr8
-rw-r--r--src/invidious/channels/playlists.cr10
-rw-r--r--src/invidious/channels/videos.cr2
-rw-r--r--src/invidious/comments.cr108
-rw-r--r--src/invidious/config.cr192
-rw-r--r--src/invidious/database/annotations.cr24
-rw-r--r--src/invidious/database/base.cr110
-rw-r--r--src/invidious/database/channels.cr149
-rw-r--r--src/invidious/database/nonces.cr46
-rw-r--r--src/invidious/database/playlists.cr265
-rw-r--r--src/invidious/database/sessions.cr74
-rw-r--r--src/invidious/database/statistics.cr49
-rw-r--r--src/invidious/database/users.cr218
-rw-r--r--src/invidious/database/videos.cr43
-rw-r--r--src/invidious/helpers/errors.cr63
-rw-r--r--src/invidious/helpers/handlers.cr10
-rw-r--r--src/invidious/helpers/helpers.cr476
-rw-r--r--src/invidious/helpers/i18n.cr203
-rw-r--r--src/invidious/helpers/i18next.cr511
-rw-r--r--src/invidious/helpers/logger.cr14
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr263
-rw-r--r--src/invidious/helpers/signatures.cr2
-rw-r--r--src/invidious/helpers/static_file_handler.cr2
-rw-r--r--src/invidious/helpers/tokens.cr22
-rw-r--r--src/invidious/helpers/utils.cr142
-rw-r--r--src/invidious/jobs/pull_popular_videos_job.cr13
-rw-r--r--src/invidious/jobs/refresh_channels_job.cr15
-rw-r--r--src/invidious/jobs/refresh_feeds_job.cr2
-rw-r--r--src/invidious/jobs/statistics_refresh_job.cr10
-rw-r--r--src/invidious/mixes.cr4
-rw-r--r--src/invidious/playlists.cr136
-rw-r--r--src/invidious/routes/api/manifest.cr6
-rw-r--r--src/invidious/routes/api/v1/authenticated.cr96
-rw-r--r--src/invidious/routes/api/v1/channels.cr14
-rw-r--r--src/invidious/routes/api/v1/feeds.cr4
-rw-r--r--src/invidious/routes/api/v1/misc.cr38
-rw-r--r--src/invidious/routes/api/v1/search.cr6
-rw-r--r--src/invidious/routes/api/v1/videos.cr40
-rw-r--r--src/invidious/routes/channels.cr14
-rw-r--r--src/invidious/routes/embed.cr24
-rw-r--r--src/invidious/routes/feeds.cr59
-rw-r--r--src/invidious/routes/images.cr309
-rw-r--r--src/invidious/routes/login.cr55
-rw-r--r--src/invidious/routes/misc.cr13
-rw-r--r--src/invidious/routes/notifications.cr78
-rw-r--r--src/invidious/routes/playlists.cr88
-rw-r--r--src/invidious/routes/preferences.cr225
-rw-r--r--src/invidious/routes/search.cr10
-rw-r--r--src/invidious/routes/subscriptions.cr168
-rw-r--r--src/invidious/routes/video_playback.cr8
-rw-r--r--src/invidious/routes/watch.cr82
-rw-r--r--src/invidious/routing.cr2
-rw-r--r--src/invidious/search.cr262
-rw-r--r--src/invidious/trending.cr10
-rw-r--r--src/invidious/user/converters.cr12
-rw-r--r--src/invidious/user/imports.cr27
-rw-r--r--src/invidious/user/preferences.cr259
-rw-r--r--src/invidious/users.cr339
-rw-r--r--src/invidious/videos.cr185
-rw-r--r--src/invidious/views/add_playlist_items.ecr4
-rw-r--r--src/invidious/views/channel.ecr10
-rw-r--r--src/invidious/views/community.ecr6
-rw-r--r--src/invidious/views/components/item.ecr41
-rw-r--r--src/invidious/views/components/player.ecr6
-rw-r--r--src/invidious/views/components/search_box.ecr9
-rw-r--r--src/invidious/views/components/video-context-buttons.ecr21
-rw-r--r--src/invidious/views/edit_playlist.ecr2
-rw-r--r--src/invidious/views/feeds/history.ecr4
-rw-r--r--src/invidious/views/feeds/playlists.ecr4
-rw-r--r--src/invidious/views/feeds/subscriptions.ecr2
-rw-r--r--src/invidious/views/login.ecr15
-rw-r--r--src/invidious/views/playlist.ecr23
-rw-r--r--src/invidious/views/playlists.ecr8
-rw-r--r--src/invidious/views/preferences.ecr101
-rw-r--r--src/invidious/views/search.ecr14
-rw-r--r--src/invidious/views/search_homepage.ecr6
-rw-r--r--src/invidious/views/subscription_manager.ecr2
-rw-r--r--src/invidious/views/template.ecr69
-rw-r--r--src/invidious/views/token_manager.ecr2
-rw-r--r--src/invidious/views/watch.ecr41
-rw-r--r--src/invidious/yt_backend/connection_pool.cr113
-rw-r--r--src/invidious/yt_backend/extractors.cr604
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr67
-rw-r--r--src/invidious/yt_backend/proxy.cr (renamed from src/invidious/helpers/proxy.cr)4
-rw-r--r--src/invidious/yt_backend/youtube_api.cr (renamed from src/invidious/helpers/youtube_api.cr)57
178 files changed, 10383 insertions, 9638 deletions
diff --git a/.ameba.yml b/.ameba.yml
new file mode 100644
index 00000000..247705e8
--- /dev/null
+++ b/.ameba.yml
@@ -0,0 +1,92 @@
+#
+# Lint
+#
+
+# Exclude assigns for ECR files
+Lint/UselessAssign:
+ Excluded:
+ - src/invidious.cr
+ - src/invidious/helpers/errors.cr
+ - src/invidious/routes/**/*.cr
+
+# Ignore false negative (if !db.query_one?...)
+Lint/UnreachableCode:
+ Excluded:
+ - src/invidious/database/base.cr
+
+# Ignore shadowed variable `key` (it works for now, and that's
+# a sensitive part of the code)
+Lint/ShadowingOuterLocalVar:
+ Excluded:
+ - src/invidious/helpers/tokens.cr
+
+
+#
+# Style
+#
+
+Style/RedundantBegin:
+ Enabled: false
+
+Style/RedundantReturn:
+ Enabled: false
+
+
+#
+# Metrics
+#
+
+# Ignore function complexity (number of if/else & case/when branches)
+# For some functions that can hardly be simplified for now
+Metrics/CyclomaticComplexity:
+ Excluded:
+ # get_about_info(ucid, locale) => [17/10]
+ - src/invidious/channels/about.cr
+
+ # fetch_channel_community(ucid, continuation, ...) => [34/10]
+ - src/invidious/channels/community.cr
+
+ # create_notification_stream(env, topics, connection_channel) => [14/10]
+ - src/invidious/helpers/helpers.cr:84:5
+
+ # get_index(plural_form, count) => [25/10]
+ - src/invidious/helpers/i18next.cr
+
+ # call(context) => [18/10]
+ - src/invidious/helpers/static_file_handler.cr
+
+ # show(env) => [38/10]
+ - src/invidious/routes/embed.cr
+
+ # get_video_playback(env) => [45/10]
+ - src/invidious/routes/video_playback.cr
+
+ # handle(env) => [40/10]
+ - src/invidious/routes/watch.cr
+
+ # playlist_ajax(env) => [24/10]
+ - src/invidious/routes/playlists.cr
+
+ # fetch_youtube_comments(id, cursor, ....) => [40/10]
+ # template_youtube_comments(comments, locale, ...) => [16/10]
+ # content_to_comment_html(content) => [14/10]
+ - src/invidious/comments.cr
+
+ # to_json(locale, json) => [21/10]
+ # extract_video_info(video_id, ...) => [44/10]
+ # process_video_params(query, preferences) => [20/10]
+ - src/invidious/videos.cr
+
+ # produce_search_params(page, sort, ...) => [29/10]
+ # process_search_query(query, page, ...) => [14/10]
+ - src/invidious/search.cr
+
+
+
+#src/invidious/playlists.cr:327:5
+#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [19/10]
+# fetch_playlist(plid : String)
+
+#src/invidious/playlists.cr:436:5
+#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [11/10]
+# extract_playlist_videos(initial_data : Hash(String, JSON::Any))
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..7a2c3760
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,18 @@
+# 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
+
+README.md @thefrenchghosty
+config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
+
+scripts/ @syeopite
+shards.lock @syeopite
+shards.yml @syeopite
+
+locales/ @SamantazFox
+src/invidious/helpers/i18n.cr @SamantazFox
+
+src/invidious/helpers/youtube_api.cr @SamantazFox
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..3f28c2b7
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+custom: https://invidious.io/donate/
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index c0485266..4c1a6330 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -7,9 +7,16 @@ assignees: ''
---
-<!-- Please use the search function to check if the bug you found has already been reported by someone else -->
-<!-- If you want to suggest a new feature please use "Feature request" instead -->
-<!-- If you want to suggest an enhancement to an existing feature please use "Enhancement" instead -->
+<!--
+ BEFORE TRYING TO REPORT A BUG:
+
+ * Read the FAQ!
+ * Use the search function to check if there is already an issue open for your problem!
+
+ If you want to suggest a new feature please use "Feature request" instead
+ If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
+-->
+
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3bb4c491..db0987cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,7 @@ jobs:
crystal:
- 1.0.0
- 1.1.1
+ - 1.2.2
include:
- crystal: nightly
stable: false
@@ -48,7 +49,7 @@ jobs:
- uses: actions/checkout@v2
- name: Install Crystal
- uses: oprypin/install-crystal@v1.2.4
+ uses: crystal-lang/install-crystal@v1.5.3
with:
crystal: ${{ matrix.crystal }}
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml
index c60d08fe..36fb566e 100644
--- a/.github/workflows/container-release.yml
+++ b/.github/workflows/container-release.yml
@@ -23,6 +23,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
+
+ - name: Install Crystal
+ uses: oprypin/install-crystal@v1.2.4
+ with:
+ crystal: 1.2.2
+
+ - name: Run lint
+ run: |
+ if ! crystal tool format --check; then
+ crystal tool format
+ git diff
+ exit 1
+ fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
@@ -61,4 +74,4 @@ jobs:
labels: quay.expires-after=12w
push: true
tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64
- build-args: release=1 \ No newline at end of file
+ build-args: release=1
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
deleted file mode 100644
index aa9e2b31..00000000
--- a/.github/workflows/lock.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-# Documentation: https://github.com/marketplace/actions/lock-threads
-
-name: 'Lock Threads'
-on:
- workflow_dispatch:
- schedule:
- - cron: "0 */12 * * *"
-
-jobs:
- lock:
- runs-on: ubuntu-latest
- steps:
- - uses: dessant/lock-threads@v2
- with:
- github-token: ${{ github.token }}
- issue-lock-inactive-days: '240'
- pr-lock-inactive-days: '240'
- issue-lock-reason: 'resolved'
- pr-lock-reason: 'resolved'
-
- # issue-lock-comment: 'This issue has been automatically locked since there has not been any activity in it in the last 30 days. If this is still applicable to the current version of Invidious feel free to open a new issue.'
- # pr-lock-comment: 'This pull request has been automatically locked since there has not been any activity in it in the last 30 days. If you want to tell us about needed or wanted changes or if problems related to this code are discovered, feel free to open an issue or a new pull request.'
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..ef6c4e16
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,118 @@
+# -----------------------
+# Compilation options
+# -----------------------
+
+RELEASE := 1
+STATIC := 0
+
+DISABLE_QUIC := 0
+NO_DBG_SYMBOLS := 0
+
+
+FLAGS ?=
+
+
+ifeq ($(RELEASE), 1)
+ FLAGS += --release
+endif
+
+ifeq ($(STATIC), 1)
+ FLAGS += --static
+endif
+
+
+ifeq ($(NO_DBG_SYMBOLS), 1)
+ FLAGS += --no-debug
+else
+ FLAGS += --debug
+endif
+
+ifeq ($(DISABLE_QUIC), 1)
+ FLAGS += -Ddisable_quic
+endif
+
+
+# -----------------------
+# Main
+# -----------------------
+
+all: invidious
+
+get-libs:
+ shards install --production
+
+# TODO: add support for ARM64 via cross-compilation
+invidious: get-libs
+ crystal build src/invidious.cr $(FLAGS) --progress --stats --error-trace
+
+
+run: invidious
+ ./invidious
+
+
+# -----------------------
+# Development
+# -----------------------
+
+
+format:
+ crystal tool format
+
+test:
+ crystal spec
+
+verify:
+ crystal build src/invidious.cr --no-codegen --progress --stats --error-trace
+
+
+# -----------------------
+# (Un)Install
+# -----------------------
+
+# TODO
+
+
+# -----------------------
+# Cleaning
+# -----------------------
+
+clean:
+ rm invidious
+
+distclean: clean
+ rm -rf libs
+
+
+# -----------------------
+# Help page
+# -----------------------
+
+help:
+ echo "Targets available in this Makefile:"
+ echo ""
+ echo "get-libs Fetch Crystal libraries"
+ echo "invidious Build Invidious"
+ echo "run Launch Invidious"
+ echo ""
+ echo "format Run the Crystal formatter"
+ echo "test Run tests"
+ echo "verify Just make sure that the code compiles, but without"
+ echo " generating any binaries. Useful to search for errors"
+ echo ""
+ echo "clean Remove build artifacts"
+ echo "distclean Remove build artifacts and libraries"
+ echo ""
+ echo ""
+ echo "Build options available for this Makefile:"
+ echo ""
+ echo "RELEASE Make a release build (Default: 1)"
+ echo "STATIC Link libraries statically (Default: 0)"
+ echo ""
+ echo "DISABLE_QUIC Disable support for QUIC (Default: 0)"
+ echo "NO_DBG_SYMBOLS Strip debug symbols (Default: 0)"
+
+
+
+# No targets generates an output named after themselves
+.PHONY: all get-libs build amd64 run
+.PHONY: format test verify clean distclean help
diff --git a/README.md b/README.md
index 3f7fa8a7..4e3c7a77 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,14 @@
<h1>Invidious</h1>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
- <img alt="License: AGPLv3+" src="https://shields.io/badge/License-AGPL%20v3+-blue.svg">
+ <img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
</a>
<a href="https://github.com/iv-org/invidious/actions">
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
</a>
+ <a href="https://github.com/iv-org/invidious/commits/master">
+ <img alt="GitHub commits" src="https://img.shields.io/github/commit-activity/y/iv-org/invidious?color=red&label=commits">
+ </a>
<a href="https://github.com/iv-org/invidious/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/iv-org/invidious?color=important">
</a>
@@ -17,19 +20,24 @@
<a href="https://hosted.weblate.org/engage/invidious/">
<img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg">
</a>
+
<a href="https://github.com/humanetech-community/awesome-humane-tech">
<img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true">
</a>
<h3>An open source alternative front-end to YouTube</h3>
+ <a href="https://invidious.io/">Website</a>
+ &nbsp;•&nbsp;
<a href="https://instances.invidious.io/">Instances list</a>
+ &nbsp;•&nbsp;
+ <a href="https://docs.invidious.io/FAQ.md">FAQ</a>
&nbsp;•&nbsp;
- <a href="#documentation">Documentation</a>
+ <a href="https://docs.invidious.io/">Documentation</a>
&nbsp;•&nbsp;
<a href="#contribute">Contribute</a>
&nbsp;•&nbsp;
- <a href="#donate">Donate</a>
+ <a href="https://invidious.io/donate/">Donate</a>
<h5>Chat with us:</h5>
<a href="https://matrix.to/#/#invidious:matrix.org">
@@ -38,6 +46,14 @@
<a href="https://web.libera.chat/?channel=#invidious">
<img alt="Libera.chat (IRC)" src="https://img.shields.io/badge/IRC%20%28Libera.chat%29-%23invidious-darkgreen">
</a>
+ <br>
+ <a rel="me" href="https://social.tchncs.de/@invidious">
+ <img alt="Mastodon: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Mastodon-%40invidious%40social.tchncs.de-darkgreen">
+ </a>
+ <br>
+ <a href="#contact-the-team-directly">
+ <img alt="Contact the team directly" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
+ </a>
</div>
@@ -58,7 +74,7 @@
- No JavaScript required
- Light/Dark themes
- Customizable homepage
-- Subscriptions independant from Google
+- Subscriptions independent from Google
- Notifications for all subscribed channels
- Audio-only mode (with background play on mobile)
- Support for Reddit comments
@@ -133,18 +149,20 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab,
- [CloudTube](https://sr.ht/~cadence/tube/): A JavaScript-rich alternate YouTube player.
- [PeerTubeify](https://gitlab.com/Cha_deL/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists.
- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A material design music player that streams music from YouTube.
-- [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favoris.
+- [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favorites.
+
+## Contact the team directly
-## Donate
+Every team member is available through GitHub or through the Matrix room (bridged to IRC), however, if you need/have to, you can contact the team directly via e-mail (remove `+SPAMGUARD` from the addresses):
-Bitcoin (BTC): [bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr](bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr)
+- General Inquiries (forwarded to all team members): `contact +SPAMGUARD [at] invidious [dot] io`
-Monero (XMR): [41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR](monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR)
+Note: before sending a bug report please check that it hasn't already be reported on GitHub - bug reports sent to this address will be copied to GitHub
-Ethereum (ETH): [0xD1F7E3Bfb19Ee5a52baED396Ad34717aF18d995B](ethereum:0xD1F7E3Bfb19Ee5a52baED396Ad34717aF18d995B)
+- Security issues (forwarded to the two project owners, <a href="https://github.com/TheFrenchGhosty">@TheFrenchGhosty</a> and <a href="https://github.com/Perflyst">@Perflyst</a>): `security +SPAMGUARD [at] invidious [dot] io`
-Litecoin (LTC): [ltc1q8787aq2xrseq5yx52axx8c4fqks88zj5vr0zx9](litecoin:ltc1q8787aq2xrseq5yx52axx8c4fqks88zj5vr0zx9)
+Note: the creation of a PGP key for this address is planned
## Liability
diff --git a/assets/css/default.css b/assets/css/default.css
index ce6c30c9..8b2b3578 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -19,6 +19,7 @@ body {
font-size: 1.17em;
font-weight: bold;
vertical-align: middle;
+ border-radius: 50%;
}
.channel-profile > img {
@@ -191,20 +192,24 @@ img.thumbnail {
display: inline;
}
-.searchbar .pure-form input[type="search"] {
- margin-bottom: 1px;
+.searchbar .pure-form fieldset { padding: 0; }
- border-top: 0;
- border-left: 0;
- border-right: 0;
- border-bottom: 1px solid #ccc;
- border-radius: 0;
+.searchbar input[type="search"] {
+ width: 100%;
+ margin: 1px;
- padding: initial 0;
+ border: 1px solid;
+ border-color: #0000 #0000 #CCC #0000;
+ border-radius: 0;
- box-shadow: none;
+ box-shadow: none;
+ -webkit-appearance: none;
+}
- -webkit-appearance: none;
+.searchbar input[type="search"]:focus {
+ margin: 0 0 0.5px 0;
+ border: 2px solid;
+ border-color: #0000 #0000 #FED #0000;
}
/* https://stackoverflow.com/a/55170420 */
@@ -216,16 +221,6 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 14px;
}
-.searchbar .pure-form fieldset {
- padding: 0;
-}
-
-/* attract focus to the searchbar by adding a subtle transition */
-.searchbar .pure-form input[type="search"]:focus {
- margin-bottom: 0px;
- border-bottom: 2px solid #aaa;
-}
-
.user-field {
display: flex;
flex-direction: row;
@@ -314,6 +309,11 @@ footer a {
text-decoration: underline;
}
+footer span {
+ margin: 4px 0;
+ display: block;
+}
+
/* keyframes */
@keyframes spin {
@@ -540,7 +540,8 @@ p,
}
#descriptionWrapper {
- max-width: 600px;
+ max-width: 600px;
+ white-space: pre-wrap;
}
/* Center the "invidious" logo on the search page */
diff --git a/assets/css/player.css b/assets/css/player.css
index 656fb48c..120fd2f8 100644
--- a/assets/css/player.css
+++ b/assets/css/player.css
@@ -218,6 +218,10 @@ video.video-js {
#player-container {
position: relative;
+ padding-left: 0;
+ padding-right: 0;
+ margin-left: 1em;
+ margin-right: 1em;
padding-bottom: 82vh;
height: 0;
}
diff --git a/assets/js/handlers.js b/assets/js/handlers.js
index 1498f39a..a417fcca 100644
--- a/assets/js/handlers.js
+++ b/assets/js/handlers.js
@@ -142,4 +142,16 @@
var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value;
xhr.send('csrf_token=' + csrf_token);
}
+
+ // Handle keypresses
+ window.addEventListener('keydown', (event) => {
+ // Ignore modifier keys
+ if (event.ctrlKey || event.metaKey) { return; }
+
+ // Focus search bar on '/'
+ if (event.key == "/") {
+ document.getElementById('searchbox').focus();
+ event.preventDefault();
+ }
+ });
})();
diff --git a/assets/js/player.js b/assets/js/player.js
index a461c53d..5ff55eb3 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -38,6 +38,8 @@ embed_url.searchParams.delete('v');
short_url = location.origin + '/' + video_data.id + embed_url.search;
embed_url = location.origin + '/embed/' + video_data.id + embed_url.search;
+var save_player_pos_key = "save_player_pos";
+
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
@@ -57,6 +59,16 @@ videojs.Hls.xhr.beforeRequest = function(options) {
var player = videojs('player', options);
+const storage = (() => {
+ try {
+ if (localStorage.length !== -1) {
+ return localStorage;
+ }
+ } catch (e) {
+ console.info('No storage available: ' + e);
+ }
+ return undefined;
+})();
if (location.pathname.startsWith('/embed/')) {
player.overlay({
@@ -199,6 +211,32 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data.
player.getChild('bigPlayButton').hide();
}
+if (video_data.params.save_player_pos) {
+ const url = new URL(location);
+ const hasTimeParam = url.searchParams.has("t");
+ const remeberedTime = get_video_time();
+ let lastUpdated = 0;
+
+ if(!hasTimeParam) {
+ set_seconds_after_start(remeberedTime);
+ }
+
+ const updateTime = () => {
+ const raw = player.currentTime();
+ const time = Math.floor(raw);
+
+ if(lastUpdated !== time && raw <= video_data.length_seconds - 15) {
+ save_video_time(time);
+ lastUpdated = time;
+ }
+ };
+
+ player.on("timeupdate", updateTime);
+}
+else {
+ remove_all_video_times();
+}
+
if (video_data.params.autoplay) {
var bpb = player.getChild('bigPlayButton');
bpb.hide();
@@ -330,6 +368,65 @@ function skip_seconds(delta) {
player.currentTime(newTime);
}
+function set_seconds_after_start(delta) {
+ const start = video_data.params.video_start;
+ player.currentTime(start + delta);
+}
+
+function save_video_time(seconds) {
+ const videoId = video_data.id;
+ const all_video_times = get_all_video_times();
+
+ all_video_times[videoId] = seconds;
+
+ set_all_video_times(all_video_times);
+}
+
+function get_video_time() {
+ try {
+ const videoId = video_data.id;
+ const all_video_times = get_all_video_times();
+ const timestamp = all_video_times[videoId];
+
+ return timestamp || 0;
+ }
+ catch {
+ return 0;
+ }
+}
+
+function set_all_video_times(times) {
+ if (storage) {
+ if (times) {
+ try {
+ storage.setItem(save_player_pos_key, JSON.stringify(times));
+ } catch (e) {
+ console.debug('set_all_video_times: ' + e);
+ }
+ } else {
+ storage.removeItem(save_player_pos_key);
+ }
+ }
+}
+
+function get_all_video_times() {
+ if (storage) {
+ const raw = storage.getItem(save_player_pos_key);
+ if (raw !== null) {
+ try {
+ return JSON.parse(raw);
+ } catch (e) {
+ console.debug('get_all_video_times: ' + e);
+ }
+ }
+ }
+ return {};
+}
+
+function remove_all_video_times() {
+ set_all_video_times(null);
+}
+
function set_time_percent(percent) {
const duration = player.duration();
const newTime = duration * (percent / 100);
diff --git a/assets/js/themes.js b/assets/js/themes.js
index 543b849e..470f10bf 100644
--- a/assets/js/themes.js
+++ b/assets/js/themes.js
@@ -11,7 +11,9 @@ toggle_theme.addEventListener('click', function () {
xhr.open('GET', url, true);
set_mode(dark_mode);
- window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light');
+ try {
+ window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light');
+ } catch {}
xhr.send();
});
@@ -23,9 +25,12 @@ window.addEventListener('storage', function (e) {
});
window.addEventListener('DOMContentLoaded', function () {
- window.localStorage.setItem('dark_mode', document.getElementById('dark_mode_pref').textContent);
- // Update localStorage if dark mode preference changed on preferences page
- update_mode(window.localStorage.dark_mode);
+ const dark_mode = document.getElementById('dark_mode_pref').textContent;
+ try {
+ // Update localStorage if dark mode preference changed on preferences page
+ window.localStorage.setItem('dark_mode', dark_mode);
+ } catch {}
+ update_mode(dark_mode);
});
@@ -37,9 +42,11 @@ lightScheme.addListener(scheme_switch);
function scheme_switch (e) {
// ignore this method if we have a preference set
- if (localStorage.getItem('dark_mode')) {
- return;
- }
+ try {
+ if (localStorage.getItem('dark_mode')) {
+ return;
+ }
+ } catch {}
if (e.matches) {
if (e.media.includes("dark")) {
set_mode(true);
diff --git a/assets/js/watch.js b/assets/js/watch.js
index 3909edd4..1579abf4 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -149,6 +149,8 @@ function get_playlist(plid, retries) {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
playlist.innerHTML = xhr.response.playlistHtml;
+ var nextVideo = document.getElementById(xhr.response.nextVideo);
+ nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop;
if (xhr.response.nextVideo) {
player.on('ended', function () {
diff --git a/assets/robots.txt b/assets/robots.txt
index b7e8f5a9..1f53798b 100644
--- a/assets/robots.txt
+++ b/assets/robots.txt
@@ -1,4 +1,2 @@
User-agent: *
-Disallow: /search
-Disallow: /login
-Disallow: /watch \ No newline at end of file
+Disallow: /
diff --git a/config/config.example.yml b/config/config.example.yml
index d2346719..d1c1f300 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -158,9 +158,9 @@ https_only: false
## See: https://github.com/iv-org/invidious/issues/957#issuecomment-576424042
##
## Accepted values: true, false
-## Default: true
+## Default: false
##
-#use_quic: true
+#use_quic: false
##
## Additionnal cookies to be sent when requesting the youtube API.
@@ -432,6 +432,15 @@ feed_threads: 1
##
#cache_annotations: false
+##
+## Source code URL. If your instance is running a modfied source
+## code, you MUST publish it somewhere and set this option.
+##
+## Accepted values: a string
+## Default: <none>
+##
+#modified_source_code_url: ""
+
#########################################
@@ -457,9 +466,9 @@ default_user_preferences:
##
## Default user interface language (locale).
##
- ## Note: overridin the default (no preferred caption language)
- ## is not recommended, in order to not penalize people using
- ## other languages.
+ ## Note: When hosting a public instance, overriding the
+ ## default (english) is not recommended, as it may
+ ## people using other languages.
##
## Accepted values:
## ar (Arabic)
@@ -496,6 +505,21 @@ default_user_preferences:
#locale: en-US
##
+ ## Default geographical location for content.
+ ##
+ ## Accepted values:
+ ## AE, AR, AT, AU, AZ, BA, BD, BE, BG, BH, BO, BR, BY, CA, CH, CL, CO, CR,
+ ## CY, CZ, DE, DK, DO, DZ, EC, EE, EG, ES, FI, FR, GB, GE, GH, GR, GT, HK,
+ ## HN, HR, HU, ID, IE, IL, IN, IQ, IS, IT, JM, JO, JP, KE, KR, KW, KZ, LB,
+ ## LI, LK, LT, LU, LV, LY, MA, ME, MK, MT, MX, MY, NG, NI, NL, NO, NP, NZ,
+ ## OM, PA, PE, PG, PH, PK, PL, PR, PT, PY, QA, RO, RS, RU, SA, SE, SG, SI,
+ ## SK, SN, SV, TH, TN, TR, TW, TZ, UA, UG, US, UY, VE, VN, YE, ZA, ZW
+ ##
+ ## Default: US
+ ##
+ #region: US
+
+ ##
## Top 3 prefered languages for video captions.
##
## Note: overridin the default (no preferred
diff --git a/config/migrate-scripts/migrate-db-17cf077.sh b/config/migrate-scripts/migrate-db-17cf077.sh
index 5e5bb214..1597311d 100755
--- a/config/migrate-scripts/migrate-db-17cf077.sh
+++ b/config/migrate-scripts/migrate-db-17cf077.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed bool;"
-psql invidious kemal -c "UPDATE channels SET subscribed = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = false;"
diff --git a/config/migrate-scripts/migrate-db-1c8075c.sh b/config/migrate-scripts/migrate-db-1c8075c.sh
index 63954397..b6f7b89c 100755
--- a/config/migrate-scripts/migrate-db-1c8075c.sh
+++ b/config/migrate-scripts/migrate-db-1c8075c.sh
@@ -1,7 +1,10 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE"
-psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool"
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE"
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz"
diff --git a/config/migrate-scripts/migrate-db-1eca969.sh b/config/migrate-scripts/migrate-db-1eca969.sh
index f840d924..770a76d3 100755
--- a/config/migrate-scripts/migrate-db-1eca969.sh
+++ b/config/migrate-scripts/migrate-db-1eca969.sh
@@ -1,19 +1,22 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN title CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN views CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN likes CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN published CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN description CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN language CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN ucid CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN license CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE"
-psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN title CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN views CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN likes CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN published CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN description CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN language CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN ucid CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN license CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE"
diff --git a/config/migrate-scripts/migrate-db-30e6d29.sh b/config/migrate-scripts/migrate-db-30e6d29.sh
index 3a377461..9d0b2d30 100755
--- a/config/migrate-scripts/migrate-db-30e6d29.sh
+++ b/config/migrate-scripts/migrate-db-30e6d29.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN deleted bool;"
-psql invidious kemal -c "UPDATE channels SET deleted = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN deleted bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET deleted = false;"
diff --git a/config/migrate-scripts/migrate-db-3646395.sh b/config/migrate-scripts/migrate-db-3646395.sh
index 830b85f2..b6efe239 100755
--- a/config/migrate-scripts/migrate-db-3646395.sh
+++ b/config/migrate-scripts/migrate-db-3646395.sh
@@ -1,5 +1,8 @@
#!/bin/sh
-psql invidious kemal < config/sql/session_ids.sql
-psql invidious kemal -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING"
-psql invidious kemal -c "ALTER TABLE users DROP COLUMN id"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/session_ids.sql
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users DROP COLUMN id"
diff --git a/config/migrate-scripts/migrate-db-3bcb98e.sh b/config/migrate-scripts/migrate-db-3bcb98e.sh
index cb9fa6ab..444f65ed 100755
--- a/config/migrate-scripts/migrate-db-3bcb98e.sh
+++ b/config/migrate-scripts/migrate-db-3bcb98e.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal < config/sql/annotations.sql
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/annotations.sql
diff --git a/config/migrate-scripts/migrate-db-52cb239.sh b/config/migrate-scripts/migrate-db-52cb239.sh
index db8efeab..da977d97 100755
--- a/config/migrate-scripts/migrate-db-52cb239.sh
+++ b/config/migrate-scripts/migrate-db-52cb239.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN views bigint;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN views bigint;"
diff --git a/config/migrate-scripts/migrate-db-6e51189.sh b/config/migrate-scripts/migrate-db-6e51189.sh
index ce728118..9132d3d7 100755
--- a/config/migrate-scripts/migrate-db-6e51189.sh
+++ b/config/migrate-scripts/migrate-db-6e51189.sh
@@ -1,4 +1,7 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;"
-psql invidious kemal -c "UPDATE channel_videos SET live_now = false;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channel_videos SET live_now = false;"
diff --git a/config/migrate-scripts/migrate-db-701b5ea.sh b/config/migrate-scripts/migrate-db-701b5ea.sh
index 429531a2..46d60c00 100755
--- a/config/migrate-scripts/migrate-db-701b5ea.sh
+++ b/config/migrate-scripts/migrate-db-701b5ea.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"
diff --git a/config/migrate-scripts/migrate-db-88b7097.sh b/config/migrate-scripts/migrate-db-88b7097.sh
index 6bde8399..146ee92d 100755
--- a/config/migrate-scripts/migrate-db-88b7097.sh
+++ b/config/migrate-scripts/migrate-db-88b7097.sh
@@ -1,3 +1,6 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;"
diff --git a/config/migrate-scripts/migrate-db-8e884fe.sh b/config/migrate-scripts/migrate-db-8e884fe.sh
index 1c8dafd1..0d5de828 100755
--- a/config/migrate-scripts/migrate-db-8e884fe.sh
+++ b/config/migrate-scripts/migrate-db-8e884fe.sh
@@ -1,5 +1,8 @@
#!/bin/sh
-psql invidious kemal -c "ALTER TABLE channels DROP COLUMN subscribed"
-psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz"
-psql invidious kemal -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'"
+[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal
+[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious
+
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels DROP COLUMN subscribed"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz"
+psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'"
diff --git a/config/sql/annotations.sql b/config/sql/annotations.sql
index 4ea077e7..3705829d 100644
--- a/config/sql/annotations.sql
+++ b/config/sql/annotations.sql
@@ -2,11 +2,11 @@
-- DROP TABLE public.annotations;
-CREATE TABLE public.annotations
+CREATE TABLE IF NOT EXISTS public.annotations
(
id text NOT NULL,
annotations xml,
CONSTRAINT annotations_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.annotations TO kemal;
+GRANT ALL ON TABLE public.annotations TO current_user;
diff --git a/config/sql/channel_videos.sql b/config/sql/channel_videos.sql
index cec57cd4..cd4e0ffd 100644
--- a/config/sql/channel_videos.sql
+++ b/config/sql/channel_videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.channel_videos;
-CREATE TABLE public.channel_videos
+CREATE TABLE IF NOT EXISTS public.channel_videos
(
id text NOT NULL,
title text,
@@ -17,13 +17,13 @@ CREATE TABLE public.channel_videos
CONSTRAINT channel_videos_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.channel_videos TO kemal;
+GRANT ALL ON TABLE public.channel_videos TO current_user;
-- Index: public.channel_videos_ucid_idx
-- DROP INDEX public.channel_videos_ucid_idx;
-CREATE INDEX channel_videos_ucid_idx
+CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx
ON public.channel_videos
USING btree
(ucid COLLATE pg_catalog."default");
diff --git a/config/sql/channels.sql b/config/sql/channels.sql
index b5a29b8f..55772da6 100644
--- a/config/sql/channels.sql
+++ b/config/sql/channels.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.channels;
-CREATE TABLE public.channels
+CREATE TABLE IF NOT EXISTS public.channels
(
id text NOT NULL,
author text,
@@ -12,13 +12,13 @@ CREATE TABLE public.channels
CONSTRAINT channels_id_key UNIQUE (id)
);
-GRANT ALL ON TABLE public.channels TO kemal;
+GRANT ALL ON TABLE public.channels TO current_user;
-- Index: public.channels_id_idx
-- DROP INDEX public.channels_id_idx;
-CREATE INDEX channels_id_idx
+CREATE INDEX IF NOT EXISTS channels_id_idx
ON public.channels
USING btree
(id COLLATE pg_catalog."default");
diff --git a/config/sql/nonces.sql b/config/sql/nonces.sql
index 7b8ce9f2..644ac32a 100644
--- a/config/sql/nonces.sql
+++ b/config/sql/nonces.sql
@@ -2,20 +2,20 @@
-- DROP TABLE public.nonces;
-CREATE TABLE public.nonces
+CREATE TABLE IF NOT EXISTS public.nonces
(
nonce text,
expire timestamp with time zone,
CONSTRAINT nonces_id_key UNIQUE (nonce)
);
-GRANT ALL ON TABLE public.nonces TO kemal;
+GRANT ALL ON TABLE public.nonces TO current_user;
-- Index: public.nonces_nonce_idx
-- DROP INDEX public.nonces_nonce_idx;
-CREATE INDEX nonces_nonce_idx
+CREATE INDEX IF NOT EXISTS nonces_nonce_idx
ON public.nonces
USING btree
(nonce COLLATE pg_catalog."default");
diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql
index b2b8d5c4..4b48b46a 100644
--- a/config/sql/playlist_videos.sql
+++ b/config/sql/playlist_videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.playlist_videos;
-CREATE TABLE playlist_videos
+CREATE TABLE IF NOT EXISTS public.playlist_videos
(
title text,
id text,
@@ -16,4 +16,4 @@ CREATE TABLE playlist_videos
PRIMARY KEY (index,plid)
);
-GRANT ALL ON TABLE public.playlist_videos TO kemal;
+GRANT ALL ON TABLE public.playlist_videos TO current_user;
diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql
index 468496cb..83efce48 100644
--- a/config/sql/playlists.sql
+++ b/config/sql/playlists.sql
@@ -13,7 +13,7 @@ CREATE TYPE public.privacy AS ENUM
-- DROP TABLE public.playlists;
-CREATE TABLE public.playlists
+CREATE TABLE IF NOT EXISTS public.playlists
(
title text,
id text primary key,
@@ -26,4 +26,4 @@ CREATE TABLE public.playlists
index int8[]
);
-GRANT ALL ON public.playlists TO kemal;
+GRANT ALL ON public.playlists TO current_user;
diff --git a/config/sql/session_ids.sql b/config/sql/session_ids.sql
index afbabb67..c493769a 100644
--- a/config/sql/session_ids.sql
+++ b/config/sql/session_ids.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.session_ids;
-CREATE TABLE public.session_ids
+CREATE TABLE IF NOT EXISTS public.session_ids
(
id text NOT NULL,
email text,
@@ -10,13 +10,13 @@ CREATE TABLE public.session_ids
CONSTRAINT session_ids_pkey PRIMARY KEY (id)
);
-GRANT ALL ON TABLE public.session_ids TO kemal;
+GRANT ALL ON TABLE public.session_ids TO current_user;
-- Index: public.session_ids_id_idx
-- DROP INDEX public.session_ids_id_idx;
-CREATE INDEX session_ids_id_idx
+CREATE INDEX IF NOT EXISTS session_ids_id_idx
ON public.session_ids
USING btree
(id COLLATE pg_catalog."default");
diff --git a/config/sql/users.sql b/config/sql/users.sql
index 0f2cdba2..ad002ec2 100644
--- a/config/sql/users.sql
+++ b/config/sql/users.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.users;
-CREATE TABLE public.users
+CREATE TABLE IF NOT EXISTS public.users
(
updated timestamp with time zone,
notifications text[],
@@ -16,13 +16,13 @@ CREATE TABLE public.users
CONSTRAINT users_email_key UNIQUE (email)
);
-GRANT ALL ON TABLE public.users TO kemal;
+GRANT ALL ON TABLE public.users TO current_user;
-- Index: public.email_unique_idx
-- DROP INDEX public.email_unique_idx;
-CREATE UNIQUE INDEX email_unique_idx
+CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx
ON public.users
USING btree
(lower(email) COLLATE pg_catalog."default");
diff --git a/config/sql/videos.sql b/config/sql/videos.sql
index 8def2f83..55da3967 100644
--- a/config/sql/videos.sql
+++ b/config/sql/videos.sql
@@ -2,7 +2,7 @@
-- DROP TABLE public.videos;
-CREATE TABLE public.videos
+CREATE UNLOGGED TABLE IF NOT EXISTS public.videos
(
id text NOT NULL,
info text,
@@ -10,13 +10,13 @@ CREATE TABLE public.videos
CONSTRAINT videos_pkey PRIMARY KEY (id)
);
-GRANT ALL ON TABLE public.videos TO kemal;
+GRANT ALL ON TABLE public.videos TO current_user;
-- Index: public.id_idx
-- DROP INDEX public.id_idx;
-CREATE UNIQUE INDEX id_idx
+CREATE UNIQUE INDEX IF NOT EXISTS id_idx
ON public.videos
USING btree
(id COLLATE pg_catalog."default");
diff --git a/docker-compose.yml b/docker-compose.yml
index b94f9813..c76c314c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,7 +12,7 @@ services:
POSTGRES_PASSWORD: kemal
POSTGRES_USER: kemal
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
invidious:
build:
context: .
@@ -35,6 +35,11 @@ services:
full_refresh: false
https_only: false
domain:
+ healthcheck:
+ test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
+ interval: 30s
+ timeout: 5s
+ retries: 2
depends_on:
- postgres
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 87449453..f336abcd 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,4 @@
-FROM crystallang/crystal:1.1.1-alpine AS builder
+FROM crystallang/crystal:1.2.2-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static
ARG release
@@ -6,7 +6,7 @@ ARG release
WORKDIR /invidious
COPY ./shard.yml ./shard.yml
COPY ./shard.lock ./shard.lock
-RUN shards install
+RUN shards install --production
COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a
@@ -23,7 +23,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma"
-RUN if [ ${release} == 1 ] ; then \
+RUN if [ "${release}" == 1 ] ; then \
crystal build ./src/invidious.cr \
--release \
--static --warnings all \
diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64
index 465c67e6..686a9278 100644
--- a/docker/Dockerfile.arm64
+++ b/docker/Dockerfile.arm64
@@ -1,12 +1,12 @@
-FROM alpine:edge AS builder
-RUN apk add --no-cache 'crystal=1.1.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
+FROM alpine:3.15 AS builder
+RUN apk add --no-cache 'crystal=1.2.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
ARG release
WORKDIR /invidious
COPY ./shard.yml ./shard.yml
COPY ./shard.lock ./shard.lock
-RUN shards install
+RUN shards install --production
COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a
@@ -34,7 +34,7 @@ RUN if [ ${release} == 1 ] ; then \
--link-flags "-lxml2 -llzma"; \
fi
-FROM alpine:edge
+FROM alpine:3.15
RUN apk add --no-cache librsvg ttf-opensans
WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \
diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh
index 3808e673..22b4cc5f 100755
--- a/docker/init-invidious-db.sh
+++ b/docker/init-invidious-db.sh
@@ -1,16 +1,12 @@
#!/bin/bash
set -eou pipefail
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
- CREATE USER postgres;
-EOSQL
-
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
diff --git a/locales/ar.json b/locales/ar.json
index 9488e309..b2845acf 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` المشتركين",
- "": "`x` المشتركين"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` المقاطع المرئيَّة",
- "": "`x` المقاطع المرئيَّة"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` قوائم التشغيل",
- "": "`x` قوائم التشغيل"
- },
"LIVE": "مُباشِر",
"Shared `x` ago": "تمَّ رفع المقطع المرئيّ مُنذ `x`",
"Unsubscribe": "إلغاء الإشتراك",
@@ -28,7 +16,7 @@
"New passwords must match": "يَجبُ أن تكون كلمتي المرور متطابقتان",
"Cannot change password for Google accounts": "لا يُمكن تغيير كلمة المرور لِحسابات جوجل",
"Authorize token?": "رمز التفويض؟",
- "Authorize token for `x`?": "رمز التفويض لـ `x` ؟",
+ "Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟",
"Yes": "نعم",
"No": "لا",
"Import and Export Data": "اِستيراد البيانات وتصديرها",
@@ -60,39 +48,39 @@
"E-mail": "البريد الإلكتروني",
"Google verification code": "رمز تحقق جوجل",
"Preferences": "التفضيلات",
- "Player preferences": "التفضيلات المُشغِّل",
- "Always loop: ": "كرر المقطع المرئيّ دائما: ",
- "Autoplay: ": "تشغيل تلقائي: ",
- "Play next by default: ": "شغل المقطع التالي تلقائيًا: ",
- "Autoplay next video: ": "شغل المقطع التالي تلقائيًا: ",
- "Listen by default: ": "تشغيل النسخة السمعية تلقائيًا: ",
- "Proxy videos: ": "بروكسي المقاطع المرئيّة؟ ",
- "Default speed: ": "السرعة الإفتراضية: ",
- "Preferred video quality: ": "الجودة المفضلة للمقاطع: ",
- "Player volume: ": "صوت المشغل: ",
- "Default comments: ": "التعليقات الإفتراضية: ",
+ "preferences_category_player": "التفضيلات المُشغِّل",
+ "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ",
+ "preferences_autoplay_label": "تشغيل تلقائي: ",
+ "preferences_continue_label": "شغل المقطع التالي تلقائيًا: ",
+ "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ",
+ "preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ",
+ "preferences_local_label": "بروكسي المقاطع المرئيّة؟ ",
+ "preferences_speed_label": "السرعة الإفتراضية: ",
+ "preferences_quality_label": "الجودة المفضلة للمقاطع: ",
+ "preferences_volume_label": "صوت المشغل: ",
+ "preferences_comments_label": "التعليقات الإفتراضية: ",
"youtube": "يوتيوب",
"reddit": "ريديت",
- "Default captions: ": "التسميات التوضيحية الإفتراضية: ",
+ "preferences_captions_label": "التسميات التوضيحية الإفتراضية: ",
"Fallback captions: ": "التسميات التوضيحية الاحتياطيَّة: ",
- "Show related videos: ": "اعرض الفيديوهات ذات الصلة: ",
- "Show annotations by default: ": "اعرض الملاحظات في الفيديو تلقائيا: ",
- "Automatically extend video description: ": "توسيع وصف الفيديو تلقائيا: ",
- "Interactive 360 degree videos: ": "مقاطع فيديو تفاعلية ب درجة 360: ",
- "Visual preferences": "التفضيلات المرئية",
- "Player style: ": "شكل مشغل الفيديوهات: ",
+ "preferences_related_videos_label": "اعرض الفيديوهات ذات الصلة: ",
+ "preferences_annotations_label": "اعرض الملاحظات في الفيديو تلقائيا: ",
+ "preferences_extend_desc_label": "توسيع وصف الفيديو تلقائيا: ",
+ "preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ",
+ "preferences_category_visual": "التفضيلات المرئية",
+ "preferences_player_style_label": "شكل مشغل الفيديوهات: ",
"Dark mode: ": "الوضع الليلى: ",
- "Theme: ": "المظهر: ",
+ "preferences_dark_mode_label": "المظهر: ",
"dark": "غامق (اسود)",
"light": "فاتح (ابيض)",
- "Thin mode: ": "الوضع الخفيف: ",
- "Miscellaneous preferences": "تفضيلات متنوعة",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ",
- "Subscription preferences": "تفضيلات الإشتراك",
- "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
+ "preferences_thin_mode_label": "الوضع الخفيف: ",
+ "preferences_category_misc": "تفضيلات متنوعة",
+ "preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ",
+ "preferences_category_subscription": "تفضيلات الإشتراك",
+ "preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
- "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
- "Sort videos by: ": "ترتيب الفيديو ب: ",
+ "preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
+ "preferences_sort_label": "ترتيب الفيديو ب: ",
"published": "احدث فيديو",
"published - reverse": "احدث فيديو - عكسى",
"alphabetically": "ترتيب ابجدى",
@@ -101,12 +89,12 @@
"channel name - reverse": "بإسم القناة - عكسى",
"Only show latest video from channel: ": "فقط إظهر اخر فيديو من القناة: ",
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
- "Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ",
- "Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
+ "preferences_unseen_only_label": "فقط اظهر الذى لم يتم رؤيتة: ",
+ "preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
"Enable web notifications": "تفعيل إشعارات المتصفح",
"`x` uploaded a video": "`x` رفع فيديو",
"`x` is live": "`x` فى بث مباشر",
- "Data preferences": "إعدادات التفضيلات",
+ "preferences_category_data": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة",
"Import/export data": "إضافة\\إستخراج البيانات",
"Change password": "غير الرقم السرى",
@@ -114,10 +102,10 @@
"Manage tokens": "إدارة الرموز",
"Watch history": "سجل المشاهدة",
"Delete account": "حذف الحساب",
- "Administrator preferences": "إعدادات المدير",
- "Default homepage: ": "الصفحة الرئيسية الافتراضية ",
- "Feed menu: ": "قائمة التدفقات: ",
- "Show nickname on top: ": "إظهار اللقب في الأعلى: ",
+ "preferences_category_admin": "إعدادات المدير",
+ "preferences_default_home_label": "الصفحة الرئيسية الافتراضية ",
+ "preferences_feed_menu_label": "قائمة التدفقات: ",
+ "preferences_show_nick_label": "إظهار اللقب في الأعلى: ",
"Top enabled: ": "تفعيل 'الأفضل' ؟ ",
"CAPTCHA enabled: ": "تفعيل الكابتشا: ",
"Login enabled: ": "تفعيل الولوج: ",
@@ -127,25 +115,13 @@
"Subscription manager": "مدير الإشتراكات",
"Token manager": "إداره الرمز",
"Token": "الرمز",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشتركين",
- "": "`x` مشتركين"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` رموز",
- "": "`x` رموز"
- },
"Import/export": "إضافة\\إستخراج",
"unsubscribe": "إلغاء الإشتراك",
"revoke": "مسح",
"Subscriptions": "الإشتراكات",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` إشعارات لم تشاهدها بعد",
- "": "`x` إشعارات لم تشاهدها بعد"
- },
"search": "بحث",
"Log out": "تسجيل الخروج",
- "Released under the AGPLv3 on Github.": "تم إصداره بموجب AGPLv3 على Github.",
+ "Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.",
"Source available here.": "الأكواد متوفرة هنا.",
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
"View privacy policy.": "عرض سياسة الخصوصية.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ",
"Blacklisted regions: ": "الدول الحظور فيها هذا الفيديو: ",
"Shared `x`": "شارك منذ `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشاهدات",
- "": "`x` مشاهدات"
- },
"Premieres in `x`": "يعرض فى `x`",
"Premieres `x`": "يعرض `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "القناة غير موجودة.",
"Could not get channel info.": "لم يستطع الحصول على معلومات القناة.",
"Could not fetch comments": "لم يتمكن من إحضار التعليقات",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` ردود",
- "": "عرض `x` ردود"
- },
"`x` ago": "`x` منذ",
"Load more": "عرض المزيد",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقاط",
- "": "`x` نقاط"
- },
"Could not create mix.": "لم يستطع عمل خلط.",
"Empty playlist": "قائمة التشغيل فارغة",
"Not a playlist.": "قائمة التشغيل غير صالحة.",
@@ -340,41 +304,13 @@
"Yiddish": "اليديشية",
"Yoruba": "اليوروبا",
"Zulu": "الزولو",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سنوات",
- "": "`x` سنوات"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` شهور",
- "": "`x` شهور"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اسابيع",
- "": "`x` اسابيع"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ايام",
- "": "`x` ايام"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعات",
- "": "`x` ساعات"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقائق",
- "": "`x` دقائق"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثوانى",
- "": "`x` ثوانى"
- },
"Fallback comments: ": "التعليقات البديلة: ",
"Popular": "الأكثر شعبية",
"Search": "بحث",
"Top": "الأفضل",
"About": "حول",
"Rating: ": "التقييم: ",
- "Language: ": "اللغة: ",
+ "preferences_locale_label": "اللغة: ",
"View as playlist": "عرض كا قائمة التشغيل",
"Default": "الكل",
"Music": "الاغانى",
@@ -382,7 +318,7 @@
"News": "الأخبار",
"Movies": "الأفلام",
"Download": "نزّل",
- "Download as: ": "نزله كـ:. ",
+ "Download as: ": "نزله ك:. ",
"%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(تم تعديلة)",
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
@@ -423,5 +359,44 @@
"Current version: ": "الإصدار الحالي: ",
"next_steps_error_message": "بعد ذلك يجب أن تحاول: ",
"next_steps_error_message_refresh": "تحديث",
- "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب"
+ "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب",
+ "short": "قصير (< 4 دقائق)",
+ "long": "طويل (> 20 دقيقة)",
+ "footer_source_code": "شفرة المصدر",
+ "footer_original_source_code": "شفرة المصدر الأصلية",
+ "footer_modfied_source_code": "شفرة المصدر المعدلة",
+ "adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة",
+ "footer_documentation": "التوثيق",
+ "footer_donate_page": "تبرّع",
+ "preferences_region_label": "بلد المحتوى:. ",
+ "preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ",
+ "preferences_quality_option_dash": "DASH (جودة تكييفية)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "متوسطة",
+ "preferences_quality_option_small": "صغيرة",
+ "preferences_quality_dash_option_auto": "تلقائي",
+ "preferences_quality_dash_option_best": "الأفضل",
+ "preferences_quality_dash_option_worst": "أسوأ",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "purchased": "تم شراؤها",
+ "none": "لاشيء",
+ "videoinfo_started_streaming_x_ago": "بدأ البث منذ `x`",
+ "videoinfo_watch_on_youTube": "مشاهدة على يوتيوب",
+ "videoinfo_youTube_embed_link": "مضمن",
+ "videoinfo_invidious_embed_link": "رابط مضمن",
+ "user_created_playlists": "'x' إنشاء قوائم التشغيل",
+ "user_saved_playlists": "قوائم التشغيل المحفوظة 'x'",
+ "Video unavailable": "الفيديو غير متوفر",
+ "360": "360°",
+ "download_subtitles": "ترجمات - 'x' (.vtt)",
+ "invidious": "الخيالي",
+ "preferences_save_player_pos_label": "احفظ وقت الفيديو الحالي: "
}
diff --git a/locales/bn_BD.json b/locales/bn_BD.json
index c9e1150b..53cb79ae 100644
--- a/locales/bn_BD.json
+++ b/locales/bn_BD.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার",
- "": "`x` সাবস্ক্রাইবার"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও",
- "": "`x` ভিডিও"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট",
- "": "`x` প্লেলিস্ট"
- },
"LIVE": "লাইভ",
"Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে",
"Unsubscribe": "আনসাবস্ক্রাইব",
@@ -60,368 +48,14 @@
"E-mail": "ই-মেইল",
"Google verification code": "গুগল যাচাইকরণ কোড",
"Preferences": "পছন্দসমূহ",
- "Player preferences": "প্লেয়ারের পছন্দসমূহ",
- "Always loop: ": "সর্বদা লুপ: ",
- "Autoplay: ": "স্বয়ংক্রিয় চালু: ",
- "Play next by default: ": "ডিফল্টভাবে পরবর্তী চালাও: ",
- "Autoplay next video: ": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ",
- "Listen by default: ": "সহজাতভাবে শোনো: ",
- "Proxy videos: ": "ভিডিও প্রক্সি করো: ",
- "Default speed: ": "সহজাত গতি: ",
- "Preferred video quality: ": "পছন্দের ভিডিও মান: ",
- "Player volume: ": "প্লেয়ার শব্দের মাত্রা: ",
- "Default comments: ": "",
- "youtube": "",
- "reddit": "",
- "Default captions: ": "",
- "Fallback captions: ": "",
- "Show related videos: ": "",
- "Show annotations by default: ": "",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "",
- "Player style: ": "",
- "Dark mode: ": "",
- "Theme: ": "",
- "dark": "",
- "light": "",
- "Thin mode: ": "",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "",
- "Show annotations by default for subscribed channels: ": "",
- "Redirect homepage to feed: ": "",
- "Number of videos shown in feed: ": "",
- "Sort videos by: ": "",
- "published": "",
- "published - reverse": "",
- "alphabetically": "",
- "alphabetically - reverse": "",
- "channel name": "",
- "channel name - reverse": "",
- "Only show latest video from channel: ": "",
- "Only show latest unwatched video from channel: ": "",
- "Only show unwatched: ": "",
- "Only show notifications (if there are any): ": "",
- "Enable web notifications": "",
- "`x` uploaded a video": "",
- "`x` is live": "",
- "Data preferences": "",
- "Clear watch history": "",
- "Import/export data": "",
- "Change password": "",
- "Manage subscriptions": "",
- "Manage tokens": "",
- "Watch history": "",
- "Delete account": "",
- "Administrator preferences": "",
- "Default homepage: ": "",
- "Feed menu: ": "",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
- "Save preferences": "",
- "Subscription manager": "",
- "Token manager": "",
- "Token": "",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "search": "",
- "Log out": "",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "",
- "View JavaScript license information.": "",
- "View privacy policy.": "",
- "Trending": "",
- "Public": "",
- "Unlisted": "",
- "Private": "",
- "View all playlists": "",
- "Updated `x` ago": "",
- "Delete playlist `x`?": "",
- "Delete playlist": "",
- "Create playlist": "",
- "Title": "",
- "Playlist privacy": "",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
- "Genre: ": "",
- "License: ": "",
- "Family friendly? ": "",
- "Wilson score: ": "",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "preferences_category_player": "প্লেয়ারের পছন্দসমূহ",
+ "preferences_video_loop_label": "সর্বদা লুপ: ",
+ "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ",
+ "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ",
+ "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ",
+ "preferences_listen_label": "সহজাতভাবে শোনো: ",
+ "preferences_local_label": "ভিডিও প্রক্সি করো: ",
+ "preferences_speed_label": "সহজাত গতি: ",
+ "preferences_quality_label": "পছন্দের ভিডিও মান: ",
+ "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: "
}
diff --git a/locales/ca.json b/locales/ca.json
new file mode 100644
index 00000000..1fa7cc1f
--- /dev/null
+++ b/locales/ca.json
@@ -0,0 +1,103 @@
+{
+ "oldest": "més antic",
+ "Yes": "Sí",
+ "preferences_quality_label": "Qualitat de vídeo preferida: ",
+ "newest": "més nou",
+ "No": "No",
+ "Google verification code": "Codi de verificació de Google",
+ "User ID": "ID d'usuari",
+ "Preferences": "Preferències",
+ "Dark mode: ": "Mode fosc: ",
+ "dark": "fosc",
+ "light": "clar",
+ "published": "publicat",
+ "published - reverse": "publicat - invers",
+ "alphabetically": "alfabèticament",
+ "alphabetically - reverse": "alfabèticament - invers",
+ "channel name - reverse": "nom del canal - invers",
+ "preferences_category_data": "Preferències de dades",
+ "Delete account": "Elimina compte",
+ "Save preferences": "Guarda preferències",
+ "Private": "Privat",
+ "Show more": "Mostra'n més",
+ "Show less": "Mostra'n menys",
+ "Hide replies": "Amaga respostes",
+ "Arabic": "Àrab",
+ "Armenian": "Armeni",
+ "Basque": "Basc",
+ "Filipino": "Filipí",
+ "Finnish": "Finès",
+ "German": "Alemany",
+ "Greek": "Grec",
+ "Hungarian": "Hongarès",
+ "Icelandic": "Islandès",
+ "Italian": "Italià",
+ "Japanese": "Japonès",
+ "Korean": "Coreà",
+ "Kurdish": "Kurd",
+ "Lithuanian": "Lituà",
+ "Luxembourgish": "Luxemburguès",
+ "Macedonian": "Macedoni",
+ "Polish": "Polonès",
+ "Portuguese": "Portuguès",
+ "Romanian": "Romanès",
+ "Russian": "Rus",
+ "Serbian": "Serbi",
+ "Spanish (Latin America)": "Castellà (Amèrica llatina)",
+ "Turkish": "Turc",
+ "Ukrainian": "Ucraïnès",
+ "preferences_locale_label": "Idioma: ",
+ "Gaming": "Jocs",
+ "Movies": "Películes",
+ "Download": "Descarrega",
+ "Download as: ": "Descarrega com: ",
+ "Videos": "Vídeos",
+ "content_type": "Tipus",
+ "duration": "Duració",
+ "sort": "Ordena per",
+ "week": "Aquesta setmana",
+ "month": "Aquest mes",
+ "year": "Aquest any",
+ "video": "Vídeo",
+ "channel": "Canal",
+ "short": "Curt (< 4 minuts)",
+ "long": "Llarg (> 20 minuts)",
+ "Current version: ": "Versió actual: ",
+ "Malay": "Malai",
+ "Persian": "Persa",
+ "Slovak": "Eslovac",
+ "Search": "Busca",
+ "Show annotations": "Mostra anotacions",
+ "preferences_region_label": "País del contingut: ",
+ "preferences_sort_label": "Ordena vídeos per: ",
+ "Import/export": "Importa/exporta",
+ "channel name": "nom del canal",
+ "Title": "Títol",
+ "Belarusian": "Bielorús",
+ "Enable web notifications": "Activa notificacions web",
+ "search": "busca",
+ "Catalan": "Català",
+ "Croatian": "Croat",
+ "preferences_category_admin": "Preferències d'administrador",
+ "Hide annotations": "Amaga anotacions",
+ "Show replies": "Mostra respostes",
+ "Bulgarian": "Búlgar",
+ "Albanian": "Albanès",
+ "French": "Francès",
+ "Irish": "Irlandès",
+ "Maltese": "Maltès",
+ "Danish": "Danès",
+ "Galician": "Gallec",
+ "Hebrew": "Hebreu",
+ "Indonesian": "Indonesi",
+ "Spanish": "Castellà",
+ "Vietnamese": "Vietnamita",
+ "News": "Notícies",
+ "show": "Mostra",
+ "footer_documentation": "Documentació",
+ "Thai": "Tailandès",
+ "Music": "Música",
+ "relevance": "Rellevància",
+ "hour": "Última hora",
+ "today": "Avui"
+}
diff --git a/locales/cs.json b/locales/cs.json
index 094ad09c..7dc24cbc 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` odběratelů",
- "": "`x` odběratelů"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videí",
- "": "`x` videí"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
- "": "`x` playlisty"
- },
"LIVE": "ŽIVĚ",
"Shared `x` ago": "Sdíleno před `x`",
"Unsubscribe": "Odhlásit odběr",
@@ -60,39 +48,36 @@
"E-mail": "E-mail",
"Google verification code": "Verifikační číslo Google",
"Preferences": "Nastavení",
- "Player preferences": "Nastavení přehravače",
- "Always loop: ": "Vždy opakovat: ",
- "Autoplay: ": "Automatické přehrávání: ",
- "Play next by default: ": "Přehrát další ve výchozím stavu: ",
- "Autoplay next video: ": "Automaticky přehrát další video: ",
- "Listen by default: ": "Poslouchat ve výchozím nastavení: ",
- "Proxy videos: ": "Video přes proxy: ",
- "Default speed: ": "Základní Rychlost: ",
- "Preferred video quality: ": "Preferovaná kvalita videa: ",
- "Player volume: ": "Hlasitost přehrávače: ",
- "Default comments: ": "Předpřipravené komentáře: ",
+ "preferences_category_player": "Nastavení přehravače",
+ "preferences_video_loop_label": "Vždy opakovat: ",
+ "preferences_autoplay_label": "Automatické přehrávání: ",
+ "preferences_continue_label": "Přehrát další ve výchozím stavu: ",
+ "preferences_continue_autoplay_label": "Automaticky přehrát další video: ",
+ "preferences_listen_label": "Poslouchat ve výchozím nastavení: ",
+ "preferences_local_label": "Video přes proxy: ",
+ "preferences_speed_label": "Základní Rychlost: ",
+ "preferences_quality_label": "Preferovaná kvalita videa: ",
+ "preferences_volume_label": "Hlasitost přehrávače: ",
+ "preferences_comments_label": "Předpřipravené komentáře: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Standartní Titulky: ",
+ "preferences_captions_label": "Standartní Titulky: ",
"Fallback captions: ": "Záložní titulky: ",
- "Show related videos: ": "Zobrazit podobné videa: ",
- "Show annotations by default: ": "Zobrazovat poznámky ve výchozím nastavení: ",
- "Automatically extend video description: ": "Rozšířit automaticky popis u videa: ",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Nastavení vzhledu",
- "Player style: ": "Styl přehrávače ",
+ "preferences_related_videos_label": "Zobrazit podobné videa: ",
+ "preferences_annotations_label": "Zobrazovat poznámky ve výchozím nastavení: ",
+ "preferences_extend_desc_label": "Rozšířit automaticky popis u videa: ",
+ "preferences_category_visual": "Nastavení vzhledu",
+ "preferences_player_style_label": "Styl přehrávače ",
"Dark mode: ": "Tmavý režim ",
- "Theme: ": "Vzhled: ",
+ "preferences_dark_mode_label": "Vzhled: ",
"dark": "tmavý",
"light": "světlý",
- "Thin mode: ": "Kompaktní režim: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Nastavení předplatných",
- "Show annotations by default for subscribed channels: ": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ",
+ "preferences_thin_mode_label": "Kompaktní režim: ",
+ "preferences_category_subscription": "Nastavení předplatných",
+ "preferences_annotations_subscribed_label": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ",
"Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ",
- "Number of videos shown in feed: ": "Počet videí zobrazovaných v informačním kanále: ",
- "Sort videos by: ": "Roztřídit videa podle: ",
+ "preferences_max_results_label": "Počet videí zobrazovaných v informačním kanále: ",
+ "preferences_sort_label": "Roztřídit videa podle: ",
"published": "publikováno",
"published - reverse": "podle publikování - obrátit",
"alphabetically": "podle abecedy",
@@ -101,12 +86,12 @@
"channel name - reverse": "podle jména kanálu - převrátit",
"Only show latest video from channel: ": "Jenom zobrazit nejnovjejší video z kanálu: ",
"Only show latest unwatched video from channel: ": "Zobrazit jen nejnovější nezhlédnuté video z daného kanálu: ",
- "Only show unwatched: ": "Zobrazit jen již nezhlédnuté: ",
- "Only show notifications (if there are any): ": "Zobrazit pouze upozornění (pokud nějaká jsou): ",
+ "preferences_unseen_only_label": "Zobrazit jen již nezhlédnuté: ",
+ "preferences_notifications_only_label": "Zobrazit pouze upozornění (pokud nějaká jsou): ",
"Enable web notifications": "Povolit webové upozornění",
"`x` uploaded a video": "`x` nahrál(a) video",
"`x` is live": "`x` je živě",
- "Data preferences": "Nastavení dat",
+ "preferences_category_data": "Nastavení dat",
"Clear watch history": "Smazat historii",
"Import/export data": "importovat/exportovat data",
"Change password": "Změnit heslo",
@@ -114,11 +99,9 @@
"Manage tokens": "Spravovat klíče",
"Watch history": "Historie Sledování",
"Delete account": "Smazat Účet",
- "Administrator preferences": "Administrátorská nastavení",
- "Default homepage: ": "Základní domovská stránka: ",
- "Feed menu: ": "Menu doporučených: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
+ "preferences_category_admin": "Administrátorská nastavení",
+ "preferences_default_home_label": "Základní domovská stránka: ",
+ "preferences_feed_menu_label": "Menu doporučených: ",
"CAPTCHA enabled: ": "CAPTCHA povolen: ",
"Login enabled: ": "Přihlášení povoleno: ",
"Registration enabled: ": "Registrace povolena ",
@@ -127,25 +110,12 @@
"Subscription manager": "Správa Odběrů",
"Token manager": "Správa klíčů",
"Token": "Klíč",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Odběry",
- "": "`x` Odebíraných kanálů"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Klíčů",
- "": "`x` klíčů"
- },
"Import/export": "Importovat/exportovat",
"unsubscribe": "odhlásit odběr",
"revoke": "vrátit zpět",
"Subscriptions": "Odběry",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nezhlédnutých oznámení",
- "": "`x` nezhlédnutých oznámení"
- },
"search": "hledat",
"Log out": "Odhlásit se",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Zdrojový kód dostupný zde.",
"View JavaScript license information.": "Zobrazit informace o licenci JavaScript .",
"View privacy policy.": "Zobrazit Zásady ochrany osobních údajů.",
@@ -159,81 +129,16 @@
"Delete playlist": "Smazat playlist",
"Create playlist": "Vytvořit playlist",
"Title": "Název",
- "Playlist privacy": "",
"Editing playlist `x`": "Upravování playlistu `x`",
"Show more": "Zobrazit více",
"Show less": "Zobrazit méně",
"Watch on YouTube": "Sledovat na YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Skrýt vysvětlivky",
"Show annotations": "Zobrazit vysvětlivky",
"Genre: ": "Žánr: ",
"License: ": "Licence: ",
"Family friendly? ": "Vhodné pro děti? ",
- "Wilson score: ": "",
"Engagement: ": "Závaznost: ",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
"English": "Angličtina",
"English (auto-generated)": "Angličtina (automaticky generováno)",
"Afrikaans": "Afrikánština",
@@ -340,42 +245,10 @@
"Yiddish": "Jidiš",
"Yoruba": "Jorubština",
"Zulu": "Zuluština",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
"Popular": "Populární",
- "Search": "",
- "Top": "",
"About": "Informace",
"Rating: ": "Hodnocení: ",
- "Language: ": "Jazyk: ",
- "View as playlist": "",
+ "preferences_locale_label": "Jazyk: ",
"Default": "Výchozí",
"Music": "Hudba",
"Gaming": "Hry",
@@ -383,24 +256,16 @@
"Movies": "Filmy",
"Download": "Stáhnout",
"Download as: ": "Stáhnout jako: ",
- "%A %B %-d, %Y": "",
"(edited)": "(upraveno)",
- "YouTube comment permalink": "",
- "permalink": "",
"`x` marked it with a ❤": "`x` to označil(a) se ❤",
"Audio mode": "Audiový režim",
"Video mode": "Videový režim",
"Videos": "Videa",
- "Playlists": "",
"Community": "Komunita",
- "relevance": "",
"rating": "hodnocení",
"date": "datum",
"views": "zhlédnutí",
- "content_type": "",
"duration": "délka",
- "features": "",
- "sort": "",
"hour": "hodina",
"today": "dnes",
"week": "týden",
@@ -419,9 +284,5 @@
"4k": "4k",
"location": "umístění",
"hdr": "HDR",
- "filter": "filtr",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "filter": "filtr"
}
diff --git a/locales/da.json b/locales/da.json
index 5919283d..92e4e9f9 100644
--- a/locales/da.json
+++ b/locales/da.json
@@ -1,17 +1,5 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnenter",
- "": "`x` abonnenter"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer",
- "": "`x` videoer"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspilningslister",
- "": "`x` afspilningslister"
- },
- "LIVE": "DIREKTE",
+ "LIVE": "LIVE",
"Shared `x` ago": "Delt for `x` siden",
"Unsubscribe": "Opsig abonnement",
"Subscribe": "Abonner",
@@ -44,7 +32,7 @@
"Export data as JSON": "Exporter data som JSON",
"Delete account?": "Slet konto?",
"History": "Historik",
- "An alternative front-end to YouTube": "En alternativ forside til YouTube",
+ "An alternative front-end to YouTube": "Et alternativt front-end til YouTube",
"JavaScript license information": "JavaScript licens information",
"source": "kilde",
"Log in": "Log på",
@@ -60,39 +48,37 @@
"E-mail": "E-mail",
"Google verification code": "Google-verifikationskode",
"Preferences": "Præferencer",
- "Player preferences": "Afspillerindstillinger",
- "Always loop: ": "Altid gentag: ",
- "Autoplay: ": "Auto afspil: ",
- "Play next by default: ": "Afspil næste som standard: ",
- "Autoplay next video: ": "Auto afspil næste video: ",
- "Listen by default: ": "Lyt som standard: ",
- "Proxy videos: ": "Proxy videoer: ",
- "Default speed: ": "Standard hastighed: ",
- "Preferred video quality: ": "Foretrukken video kvalitet: ",
- "Player volume: ": "Lydstyrke: ",
- "Default comments: ": "Standard kommentarer: ",
+ "preferences_category_player": "Afspillerindstillinger",
+ "preferences_video_loop_label": "Altid gentag: ",
+ "preferences_autoplay_label": "Auto afspil: ",
+ "preferences_continue_label": "Afspil næste som standard: ",
+ "preferences_continue_autoplay_label": "Auto afspil næste video: ",
+ "preferences_listen_label": "Lyt som standard: ",
+ "preferences_local_label": "Proxy videoer: ",
+ "preferences_speed_label": "Standard hastighed: ",
+ "preferences_quality_label": "Foretrukken video kvalitet: ",
+ "preferences_volume_label": "Lydstyrke: ",
+ "preferences_comments_label": "Standard kommentarer: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Standard undertekster: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Standard undertekster: ",
"Fallback captions: ": "Alternative undertekster: ",
- "Show related videos: ": "Vis relaterede videoer: ",
- "Show annotations by default: ": "Vis annotationer som standard: ",
- "Automatically extend video description: ": "Automatisk udvid videoens beskrivelse: ",
- "Interactive 360 degree videos: ": "Interaktiv 360 graders videoer: ",
- "Visual preferences": "Visuelle præferencer",
- "Player style: ": "Afspiller stil: ",
+ "preferences_related_videos_label": "Vis relaterede videoer: ",
+ "preferences_annotations_label": "Vis annotationer som standard: ",
+ "preferences_extend_desc_label": "Automatisk udvid videoens beskrivelse: ",
+ "preferences_vr_mode_label": "Interaktiv 360 graders videoer: ",
+ "preferences_category_visual": "Visuelle præferencer",
+ "preferences_player_style_label": "Afspiller stil: ",
"Dark mode: ": "Mørk tilstand: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "mørk",
"light": "lys",
- "Thin mode: ": "Tynd tilstand: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Abonnements præferencer",
- "Show annotations by default for subscribed channels: ": "Vis annotationer som standard for abonnerede kanaler: ",
+ "preferences_thin_mode_label": "Tynd tilstand: ",
+ "preferences_category_subscription": "Abonnements præferencer",
+ "preferences_annotations_subscribed_label": "Vis annotationer som standard for abonnerede kanaler: ",
"Redirect homepage to feed: ": "Omdiriger startside til feed: ",
- "Number of videos shown in feed: ": "Antal videoer vist i feed: ",
- "Sort videos by: ": "Sorter videoer efter: ",
+ "preferences_max_results_label": "Antal videoer vist i feed: ",
+ "preferences_sort_label": "Sorter videoer efter: ",
"published": "offentliggjort",
"published - reverse": "offentliggjort - omvendt",
"alphabetically": "alfabetisk",
@@ -101,12 +87,12 @@
"channel name - reverse": "kanalnavn - omvendt",
"Only show latest video from channel: ": "Vis kun seneste video fra kanal: ",
"Only show latest unwatched video from channel: ": "Vis kun seneste usete video fra kanal: ",
- "Only show unwatched: ": "Vis kun usete: ",
- "Only show notifications (if there are any): ": "Vis kun notifikationer (hvis der er nogle): ",
+ "preferences_unseen_only_label": "Vis kun usete: ",
+ "preferences_notifications_only_label": "Vis kun notifikationer (hvis der er nogle): ",
"Enable web notifications": "Aktiver webnotifikationer",
"`x` uploaded a video": "`x` uploadede en video",
"`x` is live": "`x` er live",
- "Data preferences": "Data præferencer",
+ "preferences_category_data": "Data præferencer",
"Clear watch history": "Ryd afspilningshistorik",
"Import/export data": "Importer/exporter data",
"Change password": "Skift adgangskode",
@@ -114,10 +100,9 @@
"Manage tokens": "Administrer tokens",
"Watch history": "Afspilningshistorik",
"Delete account": "Slet konto",
- "Administrator preferences": "Administrator præferencer",
- "Default homepage: ": "Standard startside: ",
- "Feed menu: ": "Feed menu: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Administrator præferencer",
+ "preferences_default_home_label": "Standard startside: ",
+ "preferences_feed_menu_label": "Feed menu: ",
"Top enabled: ": "Top aktiveret: ",
"CAPTCHA enabled: ": "CAPTCHA aktiveret: ",
"Login enabled: ": "Login aktiveret: ",
@@ -127,25 +112,12 @@
"Subscription manager": "Abonnementsmanager",
"Token manager": "Tokenmanager",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementer",
- "": "`x`"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
- "": "`x` tokens"
- },
"Import/export": "Importer/eksporter",
"unsubscribe": "opsig abonnement",
"revoke": "tilbagekald",
"Subscriptions": "Abonnementer",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` usete notifikationer",
- "": "`x` usete notifikationer"
- },
"search": "søg",
"Log out": "Log ud",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Kilde tilgængelig her.",
"View JavaScript license information.": "Vis JavaScriptlicensinformation.",
"View privacy policy.": "Vis privatpolitik.",
@@ -154,8 +126,8 @@
"Unlisted": "Skjult",
"Private": "Privat",
"View all playlists": "Vis alle afspilningslister",
- "Updated `x` ago": "Opdateret for 'x' siden",
- "Delete playlist `x`?": "Opdateret `x` siden",
+ "Updated `x` ago": "Opdateret for `x` siden",
+ "Delete playlist `x`?": "Fjern spilleliste `x`?",
"Delete playlist": "Slet afspilningsliste",
"Create playlist": "Opret afspilningsliste",
"Title": "Titel",
@@ -164,8 +136,6 @@
"Show more": "Vis mere",
"Show less": "Vis mindre",
"Watch on YouTube": "Se på YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Skjul annotationer",
"Show annotations": "Vis annotationer",
"Genre: ": "Genre: ",
@@ -176,13 +146,9 @@
"Whitelisted regions: ": "Whitelistede regioner: ",
"Blacklisted regions: ": "Blacklistede regioner: ",
"Shared `x`": "Delt `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` visninger"
- },
"Premieres in `x`": "Har premiere om `x`",
"Premieres `x`": "Har premiere om `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.": "Hej! Det ser ud til at du har JavaScript slået fra. Klik her for at se kommentarer, vær opmærksom på at de kan tage længere om at loade.",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej! Det ser ud til at du har JavaScript slået fra. Klik her for at se kommentarer, vær opmærksom på at de kan tage længere om at indlæse.",
"View YouTube comments": "Vis YouTube kommentarer",
"View more comments on Reddit": "Se flere kommentarer på Reddit",
"View `x` comments": {
@@ -196,232 +162,241 @@
"Quota exceeded, try again in a few hours": "Kvota overskredet, prøv igen om et par timer",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Login fejlet, tjek at totrinsbekræftelse (Authenticator eller SMS) er slået til.",
"Invalid TFA code": "Ugyldig TFA kode",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fejlede. Det er måske på grund af to-faktor-autentisering ikk er slået til for din konto.",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fejlede. Dette kan skyldes, at to-faktor autentificering ikke er aktiveret for din konto.",
"Wrong answer": "Forkert svar",
"Erroneous CAPTCHA": "Fejlagtig CAPTCHA",
- "CAPTCHA is a required field": "CAPTCHA er et krævet felt",
+ "CAPTCHA is a required field": "CAPTCHA er et obligatorisk felt",
"User ID is a required field": "Bruger ID er et krævet felt",
- "Password is a required field": "Adgangskode er et krævet felt",
+ "Password is a required field": "Adgangskode er et obligatorisk felt",
"Wrong username or password": "Forkert brugernavn eller adgangskode",
- "Please sign in using 'Log in with Google'": "Venligst tjek in via 'Log in med Google'",
- "Password cannot be empty": "Adgangskode kan ikke være tom",
+ "Please sign in using 'Log in with Google'": "Log ind via 'Log ind med Google'",
+ "Password cannot be empty": "Adgangskoden må ikke være tom",
"Password cannot be longer than 55 characters": "Adgangskoden må ikke være længere end 55 tegn",
- "Please log in": "Venligst log in",
- "Invidious Private Feed for `x`": "",
+ "Please log in": "Venligst log ind",
"channel:`x`": "kanal: 'x'",
"Deleted or invalid channel": "Slettet eller invalid kanal",
"This channel does not exist.": "Denne kanal eksisterer ikke.",
"Could not get channel info.": "Kunne ikke hente kanal info.",
"Could not fetch comments": "Kunne ikke hente kommentarer",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` besvarelser",
- "": "Vis 'x' besvarelser"
- },
"`x` ago": "'x' siden",
"Load more": "Hent flere",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point",
- "": "'x' point"
- },
"Could not create mix.": "Kunne ikke skabe blanding.",
"Empty playlist": "Tom playliste",
"Not a playlist.": "Ikke en playliste.",
"Playlist does not exist.": "Playlist eksisterer ikke.",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Esperanto": "Esperanto",
+ "Czech": "Tjekkisk",
+ "Danish": "Dansk",
+ "Community": "Samfund",
+ "Afrikaans": "Afrikansk",
+ "Portuguese": "Portugisisk",
+ "Ukrainian": "Ukrainsk",
+ "Fallback comments: ": "Fallback kommentarer: ",
+ "Popular": "Populær",
+ "footer_donate_page": "Doner",
+ "Gujarati": "Gujarati",
+ "Punjabi": "Punjabi",
+ "Sundanese": "Sundanesisk",
+ "Urdu": "Urdu",
+ "preferences_region_label": "Indhold land: ",
+ "Hidden field \"challenge\" is a required field": "Det skjulte felt \"challenge\" er et påkrævet felt",
+ "Albanian": "Albansk",
+ "preferences_quality_dash_label": "Fortrukket DASH video kvalitet: ",
+ "live": "Direkte",
+ "Lao": "Lao-tse",
+ "Filipino": "Filippinsk",
+ "Greek": "Græsk",
+ "Kurdish": "Kurdisk",
+ "Malay": "Malaysisk",
+ "Romanian": "Rumænsk",
+ "Somali": "Somalisk",
+ "preferences_locale_label": "Sprog: ",
+ "News": "Nyheder",
+ "permalink": "permalink",
+ "date": "Upload dato",
+ "features": "Funktioner",
+ "filter": "Filter",
+ "Khmer": "Khmer",
+ "Finnish": "Finsk",
+ "week": "Denne uge",
+ "Korean": "Koreansk",
+ "Telugu": "Telugu",
+ "Malayalam": "Malayalam",
+ "View as playlist": "Se som spilleliste",
+ "Hungarian": "Ungarsk",
+ "Welsh": "Walisisk",
+ "subtitles": "Undertekster/CC",
+ "Bosnian": "Bosnisk",
+ "Yiddish": "Jiddisch",
+ "Belarusian": "Belarussisk",
+ "today": "I dag",
+ "Shona": "Shona",
+ "Slovenian": "Slovensk",
+ "Gaming": "Gaming",
+ "Bangla": "Bengali",
+ "Swahili": "Swahili",
+ "`x` marked it with a ❤": "`x`markeret med et ❤",
+ "Kyrgyz": "Kirgisisk",
+ "Turkish": "Tyrkisk",
+ "adminprefs_modified_source_code_url_label": "URL-adresse til modificeret kildekodelager",
+ "Switch Invidious Instance": "Skift Invidious instans",
+ "Samoan": "Samoansk",
+ "Spanish": "Spansk",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "footer_documentation": "Dokumentation",
+ "Pashto": "Pashto",
+ "footer_modfied_source_code": "Modificeret Kildekode",
+ "Released under the AGPLv3 on Github.": "Udgivet under AGPLv3 på Github.",
+ "Tajik": "Tadsjikisk",
+ "month": "Denne måned",
+ "Hebrew": "Hebraisk",
+ "Kannada": "Kannada",
+ "Current version: ": "Nuværende version: ",
+ "Amharic": "Amharisk",
+ "Swedish": "Svensk",
+ "Corsican": "Korsikansk",
+ "movie": "Film",
+ "Could not pull trending pages.": "Kunne ikke hente trending sider.",
+ "English": "Engelsk",
+ "hd": "HD",
+ "Hausa": "Islandsk",
+ "year": "Dette år",
+ "Japanese": "Japansk",
+ "content_type": "Type",
+ "Icelandic": "Islandsk",
+ "Basque": "Baskisk",
+ "rating": "Bedømmelse",
+ "Yoruba": "Yoruba",
+ "Erroneous token": "Fejlagtig token",
+ "Videos": "Videoer",
+ "show": "Vis",
+ "Luxembourgish": "Luxemboursk",
+ "Vietnamese": "Vietnamesisk",
+ "Latvian": "Lettisk",
+ "Indonesian": "Indonesisk",
+ "duration": "Varighed",
+ "footer_original_source_code": "Original kildekode",
+ "Search": "Søg",
+ "Serbian": "Serbisk",
+ "Armenian": "Armensk",
+ "Bulgarian": "Bulgarsk",
+ "French": "Fransk",
+ "Burmese": "Burmesisk",
+ "Macedonian": "Makedonsk",
+ "Southern Sotho": "Sydlige Sotho",
+ "About": "Omkring",
+ "Malagasy": "Madagaskiske",
+ "Rating: ": "Bedømmelse: ",
+ "Movies": "Film",
+ "YouTube comment permalink": "Youtube kommentarer permalink",
+ "location": "Lokation",
+ "hdr": "HDR",
+ "Cebuano": "Cebuano (Sugbuanon)",
+ "Nyanja": "Nyanja",
+ "Chinese (Simplified)": "Kinesisk (forenklet)",
+ "Chinese (Traditional)": "Kinesisk (traditionelt)",
+ "Dutch": "Hollandsk",
+ "Estonian": "Estisk",
+ "preferences_automatic_instance_redirect_label": "Automatisk eksempel omdirigering (Fallback til redirect.invidious.io): ",
+ "Nepali": "Nepalesisk",
+ "Norwegian Bokmål": "Norsk Bokmål",
+ "(edited)": "(ændret)",
+ "preferences_show_nick_label": "Vis kælenavn på toppen: ",
+ "Galician": "Galisisk",
+ "German": "Tysk",
+ "Maori": "Maori",
+ "Slovak": "Slovakisk",
+ "relevance": "Relevans",
+ "hour": "Sidste time",
+ "playlist": "Spilleliste",
+ "long": "Lang (> 20 minutter)",
+ "creative_commons": "Creative Commons",
+ "Marathi": "Marathi",
+ "Sindhi": "Sindhi",
+ "preferences_category_misc": "Diverse indstillinger",
+ "Erroneous challenge": "Fejlagtig udfordring",
+ "Hindi": "Hindi",
+ "Igbo": "Igbo",
+ "Javanese": "Javanesisk",
+ "Kazakh": "Kasabhisk",
+ "Latin": "Latinsk",
+ "Lithuanian": "Lituaisk",
+ "Mongolian": "Mongolsk",
+ "Spanish (Latin America)": "Spansk (Latinamerika)",
+ "Uzbek": "Usbekisk",
+ "Western Frisian": "Vestfrisisk",
+ "Top": "Top",
+ "Music": "Musik",
+ "views": "Antal visninger",
+ "sort": "Sorter efter",
+ "Zulu": "Zulu",
+ "Invidious Private Feed for `x`": "Invidious Privat Feed til `x`",
+ "English (auto-generated)": "Engelsk (autogenereret)",
+ "Arabic": "Arabisk",
+ "Croatian": "Kroatisk",
+ "Hawaiian": "Hawaiiansk",
+ "Maltese": "Maltesisk",
+ "Polish": "Polsk",
+ "Russian": "Russisk",
+ "Download": "Hent",
+ "Download as: ": "Hent som: ",
+ "Playlists": "Spillelister",
+ "next_steps_error_message_refresh": "Opdater",
+ "next_steps_error_message_go_to_youtube": "Gå til Youtube",
+ "footer_source_code": "Kildekode",
+ "Tamil": "Tamil",
+ "Xhosa": "Xhosa",
+ "next_steps_error_message": "Efter det burde du prøve at: ",
+ "Sinhala": "Singalesisk (Sinhala)",
+ "Thai": "Thai",
+ "Broken? Try another Invidious Instance": "I stykker? Prøv en anden Invidious instans",
+ "No such user": "Brugeren findes ikke",
+ "Token is expired, please try again": "Token er udløbet, prøv igen",
+ "Catalan": "Catalansk",
+ "Haitian Creole": "Haitiansk",
+ "Irish": "Irsk",
+ "Persian": "Persisk",
+ "Scottish Gaelic": "Skotsk Gælisk",
+ "Default": "Standard",
+ "Video mode": "Videotilstand",
+ "short": "Kort (< 4 minutter)",
+ "Hidden field \"token\" is a required field": "Det skjulte felt \"token\" er et påkrævet felt",
+ "Azerbaijani": "Aserbajdsjansk",
+ "Georgian": "Georgisk",
+ "Italian": "Italiensk",
+ "Audio mode": "Lydtilstand",
+ "video": "Video",
+ "channel": "Kanal",
+ "3d": "3D",
+ "4k": "4K",
+ "Hmong": "Hmong",
+ "preferences_quality_option_medium": "Medium",
+ "preferences_quality_option_small": "Lille",
+ "preferences_quality_dash_option_best": "Bedste",
+ "preferences_quality_dash_option_worst": "Værste",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "purchased": "Købt",
+ "360": "360°",
+ "none": "ingen",
+ "videoinfo_started_streaming_x_ago": "Streamen blev startet for `x`siden",
+ "videoinfo_watch_on_youTube": "Se på YouTube",
+ "videoinfo_youTube_embed_link": "Integrer",
+ "videoinfo_invidious_embed_link": "Integrer Link",
+ "download_subtitles": "Undertekster - `x`(.vtt)",
+ "user_created_playlists": "`x`opretede spillelister",
+ "user_saved_playlists": "´x`gemte spillelister",
+ "Video unavailable": "Video ikke tilgængelig",
+ "preferences_save_player_pos_label": "Gem den nuværende videotid: ",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_option_dash": "DASH (adaptiv kvalitet)",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_240p": "240p"
}
diff --git a/locales/de.json b/locales/de.json
index 44725cbc..8381016b 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Abonnenten",
- "": "`x` Abonnenten"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Videos",
- "": "`x` Videos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Wiedergabelisten",
- "": "`x` Wiedergabelisten"
- },
"LIVE": "LIVE",
"Shared `x` ago": "Vor `x` geteilt",
"Unsubscribe": "Abo beenden",
@@ -19,8 +7,8 @@
"View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen",
"newest": "neueste",
"oldest": "älteste",
- "popular": "beliebt",
- "last": "letzte",
+ "popular": "beliebteste",
+ "last": "neueste",
"Next page": "Nächste Seite",
"Previous page": "Vorherige Seite",
"Clear watch history?": "Verlauf löschen?",
@@ -60,39 +48,39 @@
"E-mail": "E-Mail",
"Google verification code": "Google-Bestätigungscode",
"Preferences": "Einstellungen",
- "Player preferences": "Wiedergabeeinstellungen",
- "Always loop: ": "Immer wiederholen: ",
- "Autoplay: ": "Automatisch abspielen: ",
- "Play next by default: ": "Immer automatisch nächstes Video spielen: ",
- "Autoplay next video: ": "nächstes Video automatisch abspielen: ",
- "Listen by default: ": "Nur Ton als Standard: ",
- "Proxy videos: ": "Proxy-Videos: ",
- "Default speed: ": "Standardgeschwindigkeit: ",
- "Preferred video quality: ": "Bevorzugte Videoqualität: ",
- "Player volume: ": "Wiedergabelautstärke: ",
- "Default comments: ": "Standardkommentare: ",
+ "preferences_category_player": "Wiedergabeeinstellungen",
+ "preferences_video_loop_label": "Immer wiederholen: ",
+ "preferences_autoplay_label": "Automatisch abspielen: ",
+ "preferences_continue_label": "Immer automatisch nächstes Video spielen: ",
+ "preferences_continue_autoplay_label": "nächstes Video automatisch abspielen: ",
+ "preferences_listen_label": "Nur Ton als Standard: ",
+ "preferences_local_label": "Proxy-Videos: ",
+ "preferences_speed_label": "Standardgeschwindigkeit: ",
+ "preferences_quality_label": "Bevorzugte Videoqualität: ",
+ "preferences_volume_label": "Wiedergabelautstärke: ",
+ "preferences_comments_label": "Standardkommentare: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Standarduntertitel: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Standarduntertitel: ",
"Fallback captions: ": "Ersatzuntertitel: ",
- "Show related videos: ": "Ähnliche Videos anzeigen? ",
- "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Anzeigeeinstellungen",
- "Player style: ": "Abspielgeräterstil: ",
+ "preferences_related_videos_label": "Ähnliche Videos anzeigen? ",
+ "preferences_annotations_label": "Standardmäßig Anmerkungen anzeigen? ",
+ "preferences_extend_desc_label": "Videobeschreibung automatisch erweitern: ",
+ "preferences_vr_mode_label": "Interaktive 360 Grad Videos: ",
+ "preferences_category_visual": "Anzeigeeinstellungen",
+ "preferences_player_style_label": "Abspielgeräterstil: ",
"Dark mode: ": "Nachtmodus: ",
- "Theme: ": "Modus: ",
+ "preferences_dark_mode_label": "Modus: ",
"dark": "Nachtmodus",
- "light": "heller Modus",
- "Thin mode: ": "Schlanker Modus: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Abonnementeinstellungen",
- "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
+ "light": "hell",
+ "preferences_thin_mode_label": "Schlanker Modus: ",
+ "preferences_category_misc": "Sonstige Einstellungen",
+ "preferences_automatic_instance_redirect_label": "Automatische Instanzweiterleitung (über redirect.invidious.io): ",
+ "preferences_category_subscription": "Abonnementeinstellungen",
+ "preferences_annotations_subscribed_label": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
"Redirect homepage to feed: ": "Startseite zu Feed umleiten: ",
- "Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ",
- "Sort videos by: ": "Videos sortieren nach: ",
+ "preferences_max_results_label": "Anzahl von Videos die im Feed angezeigt werden: ",
+ "preferences_sort_label": "Videos sortieren nach: ",
"published": "veröffentlicht",
"published - reverse": "veröffentlicht - invertiert",
"alphabetically": "alphabetisch",
@@ -101,12 +89,12 @@
"channel name - reverse": "Kanalname - invertiert",
"Only show latest video from channel: ": "Nur neueste Videos des Kanals anzeigen: ",
"Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ",
- "Only show unwatched: ": "Nur ungesehene anzeigen: ",
- "Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
+ "preferences_unseen_only_label": "Nur ungesehene anzeigen: ",
+ "preferences_notifications_only_label": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
"Enable web notifications": "Webbenachrichtigungen aktivieren",
"`x` uploaded a video": "`x` hat ein Video hochgeladen",
"`x` is live": "`x` ist live",
- "Data preferences": "Dateneinstellungen",
+ "preferences_category_data": "Dateneinstellungen",
"Clear watch history": "Verlauf löschen",
"Import/export data": "Daten importieren/exportieren",
"Change password": "Passwort ändern",
@@ -114,10 +102,10 @@
"Manage tokens": "Tokens verwalten",
"Watch history": "Verlauf",
"Delete account": "Account löschen",
- "Administrator preferences": "Administrator-Einstellungen",
- "Default homepage: ": "Standard-Startseite: ",
- "Feed menu: ": "Feed-Menü: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Administrator-Einstellungen",
+ "preferences_default_home_label": "Standard-Startseite: ",
+ "preferences_feed_menu_label": "Feed-Menü: ",
+ "preferences_show_nick_label": "Nutzernamen oben anzeigen: ",
"Top enabled: ": "Top aktiviert? ",
"CAPTCHA enabled: ": "CAPTCHA aktiviert? ",
"Login enabled: ": "Anmeldung aktiviert: ",
@@ -127,29 +115,17 @@
"Subscription manager": "Abonnementverwaltung",
"Token manager": "Tokenverwalter",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Abonnements",
- "": "`x` Abonnements"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Tokens",
- "": "`x` Tokens"
- },
"Import/export": "Importieren/Exportieren",
"unsubscribe": "abbestellen",
"revoke": "widerrufen",
"Subscriptions": "Abonnements",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ungesehene Benachrichtigungen",
- "": "`x` ungesehene Benachrichtigungen"
- },
"search": "Suchen",
"Log out": "Abmelden",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Auf Github unter der AGPLv3 Lizenz veröffentlicht.",
"Source available here.": "Quellcode verfügbar hier.",
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "Datenschutzerklärung einsehen.",
- "Trending": "Trending",
+ "Trending": "Angesagt",
"Public": "Öffentlich",
"Unlisted": "Nicht aufgeführt",
"Private": "Privat",
@@ -161,11 +137,11 @@
"Title": "Titel",
"Playlist privacy": "Vertrauliche Wiedergabeliste",
"Editing playlist `x`": "Wiedergabeliste bearbeiten `x`",
- "Show more": "",
- "Show less": "",
+ "Show more": "Mehr anzeigen",
+ "Show less": "Weniger anzeigen",
"Watch on YouTube": "Video auf YouTube ansehen",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Invidious Instanz wechseln",
+ "Broken? Try another Invidious Instance": "Funktioniert nicht? Probiere eine andere Invidious Instanz aus",
"Hide annotations": "Anmerkungen ausblenden",
"Show annotations": "Anmerkungen anzeigen",
"Genre: ": "Genre: ",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Erlaubte Regionen: ",
"Blacklisted regions: ": "Unerlaubte Regionen: ",
"Shared `x`": "Geteilt `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Aufrufe",
- "": "`x` Aufrufe"
- },
"Premieres in `x`": "Zuerst gesehen in `x`",
"Premieres `x`": "Erster Start `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.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Dieser Kanal existiert nicht.",
"Could not get channel info.": "Kanalinformationen konnten nicht geladen werden.",
"Could not fetch comments": "Kommentare konnten nicht geladen werden",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Zeige `x` Antworten",
- "": "Zeige `x` Antworten"
- },
"`x` ago": "vor `x`",
"Load more": "Mehr laden",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Punkte",
- "": "`x` Punkte"
- },
"Could not create mix.": "Mix konnte nicht erstellt werden.",
"Empty playlist": "Wiedergabeliste ist leer",
"Not a playlist.": "Ungültige Wiedergabeliste.",
@@ -340,41 +304,13 @@
"Yiddish": "Jiddisch",
"Yoruba": "Joruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Jahre",
- "": "`x` Jahre"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Monate",
- "": "`x` Monate"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Wochen",
- "": "`x` Wochen"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Tage",
- "": "`x` Tage"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Stunden",
- "": "`x` Stunden"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Minuten",
- "": "`x` Minuten"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Sekunden",
- "": "`x` Sekunden"
- },
"Fallback comments: ": "Alternative Kommentare: ",
"Popular": "Populär",
"Search": "Suchen",
"Top": "Top",
"About": "Über",
"Rating: ": "Bewertung: ",
- "Language: ": "Sprache: ",
+ "preferences_locale_label": "Sprache: ",
"View as playlist": "Als Wiedergabeliste anzeigen",
"Default": "Standard",
"Music": "Musik",
@@ -410,7 +346,7 @@
"channel": "Kanal",
"playlist": "Wiedergabeliste",
"movie": "Film",
- "show": "",
+ "show": "Anzeigen",
"hd": "HD",
"subtitles": "Untertitel / CC",
"creative_commons": "Creative Commons",
@@ -421,7 +357,46 @@
"hdr": "HDR",
"filter": "Filtern",
"Current version: ": "Aktuelle Version: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Danach folgendes versuchen: ",
+ "next_steps_error_message_refresh": "Aktualisieren",
+ "next_steps_error_message_go_to_youtube": "Zu YouTube gehen",
+ "footer_donate_page": "Spende",
+ "long": "Lang (> 20 Minuten)",
+ "footer_original_source_code": "Original Quellcode",
+ "footer_modfied_source_code": "Modifizierter Quellcode",
+ "footer_documentation": "Dokumentation",
+ "footer_source_code": "Quellcode",
+ "adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes",
+ "short": "Kurz (< 4 Minuten)",
+ "preferences_region_label": "Land der Inhalte: ",
+ "preferences_quality_option_dash": "DASH (automatische Qualität)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Mittel",
+ "preferences_quality_option_small": "Niedrig",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "videoinfo_invidious_embed_link": "Link zum Einbetten",
+ "download_subtitles": "Untertitel - `x` (.vtt)",
+ "Video unavailable": "Video nicht verfügbar",
+ "user_created_playlists": "`x` Wiedergabelisten erstellt",
+ "user_saved_playlists": "`x` Wiedergabelisten gespeichert",
+ "preferences_save_player_pos_label": "Aktuelle Position speichern: ",
+ "360": "360°",
+ "preferences_quality_dash_option_best": "Höchste",
+ "preferences_quality_dash_option_worst": "Niedrigste",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "videoinfo_youTube_embed_link": "Eingebettet",
+ "purchased": "Gekauft",
+ "none": "keine",
+ "videoinfo_started_streaming_x_ago": "Stream begann vor `x`",
+ "videoinfo_watch_on_youTube": "Auf YouTube ansehen",
+ "preferences_quality_dash_label": "Bevorzugte DASH-Videoqualität: "
}
diff --git a/locales/el.json b/locales/el.json
index 6ad0c47f..8800941a 100644
--- a/locales/el.json
+++ b/locales/el.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομητές",
- "": "`x` συνδρομητές"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βίντεο",
- "": "`x` βίντεο"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` λίστες αναπαραγωγής",
- "": "`x` λίστες αναπαραγωγής"
- },
"LIVE": "ΖΩΝΤΑΝΑ",
"Shared `x` ago": "Μοιράστηκε πριν από `x`",
"Unsubscribe": "Απεγγραφή",
@@ -60,39 +48,35 @@
"E-mail": "Ηλεκτρονικό ταχυδρομείο",
"Google verification code": "Κωδικός επαλήθευσης Google",
"Preferences": "Προτιμήσεις",
- "Player preferences": "Προτιμήσεις αναπαραγωγής",
- "Always loop: ": "Αυτόματη επανάληψη: ",
- "Autoplay: ": "Αυτόματη αναπαραγωγή: ",
- "Play next by default: ": "Αναπαραγωγή επόμενου: ",
- "Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ",
- "Listen by default: ": "Φόρτωση μόνο ήχου: ",
- "Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
- "Default speed: ": "Προεπιλεγμένη ταχύτητα: ",
- "Preferred video quality: ": "Προτιμώμενη ανάλυση: ",
- "Player volume: ": "Ένταση αναπαραγωγής: ",
- "Default comments: ": "Προεπιλεγμένα σχόλια: ",
+ "preferences_category_player": "Προτιμήσεις αναπαραγωγής",
+ "preferences_video_loop_label": "Αυτόματη επανάληψη: ",
+ "preferences_autoplay_label": "Αυτόματη αναπαραγωγή: ",
+ "preferences_continue_label": "Αναπαραγωγή επόμενου: ",
+ "preferences_continue_autoplay_label": "Αυτόματη αναπαραγωγή επόμενου: ",
+ "preferences_listen_label": "Φόρτωση μόνο ήχου: ",
+ "preferences_local_label": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
+ "preferences_speed_label": "Προεπιλεγμένη ταχύτητα: ",
+ "preferences_quality_label": "Προτιμώμενη ανάλυση: ",
+ "preferences_volume_label": "Ένταση αναπαραγωγής: ",
+ "preferences_comments_label": "Προεπιλεγμένα σχόλια: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ",
+ "preferences_captions_label": "Προεπιλεγμένοι υπότιτλοι: ",
"Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ",
- "Show related videos: ": "Προβολή σχετικών βίντεο; ",
- "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Προτιμήσεις εμφάνισης",
- "Player style: ": "Τεχνοτροπία της συσκευής αναπαραγωγης: ",
+ "preferences_related_videos_label": "Προβολή σχετικών βίντεο; ",
+ "preferences_annotations_label": "Αυτόματη προβολή σημειώσεων: ",
+ "preferences_category_visual": "Προτιμήσεις εμφάνισης",
+ "preferences_player_style_label": "Τεχνοτροπία της συσκευής αναπαραγωγης: ",
"Dark mode: ": "Σκοτεινή λειτουργία: ",
- "Theme: ": "Θέμα: ",
+ "preferences_dark_mode_label": "Θέμα: ",
"dark": "σκοτεινό",
"light": "φωτεινό",
- "Thin mode: ": "Ελαφριά λειτουργία: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Προτιμήσεις συνδρομών",
- "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
+ "preferences_thin_mode_label": "Ελαφριά λειτουργία: ",
+ "preferences_category_subscription": "Προτιμήσεις συνδρομών",
+ "preferences_annotations_subscribed_label": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
"Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ",
- "Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
- "Sort videos by: ": "Ταξινόμηση ανά: ",
+ "preferences_max_results_label": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
+ "preferences_sort_label": "Ταξινόμηση ανά: ",
"published": "ημερομηνία δημοσίευσης",
"published - reverse": "ημερομηνία δημοσίευσης - ανάποδα",
"alphabetically": "αλφαβητικά",
@@ -101,12 +85,12 @@
"channel name - reverse": "όνομα καναλιού - ανάποδα",
"Only show latest video from channel: ": "Προβολή μόνο του τελευταίου βίντεο του καναλιού: ",
"Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ",
- "Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ",
- "Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
+ "preferences_unseen_only_label": "Προβολή μόνο μη-προβεβλημένων: ",
+ "preferences_notifications_only_label": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
"Enable web notifications": "Ενεργοποίηση ειδοποιήσεων δικτύου",
"`x` uploaded a video": "`x` κοινοποίησε ένα βίντεο",
"`x` is live": "`x` κάνει live",
- "Data preferences": "Προτιμήσεις δεδομένων",
+ "preferences_category_data": "Προτιμήσεις δεδομένων",
"Clear watch history": "Εκκαθάριση ιστορικού προβολής",
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",
"Change password": "Αλλαγή κωδικού πρόσβασης",
@@ -114,10 +98,9 @@
"Manage tokens": "Διαχείριση διασυνδέσεων",
"Watch history": "Ιστορικό προβολής",
"Delete account": "Διαγραφή λογαριασμού",
- "Administrator preferences": "Προτιμήσεις διαχειριστή",
- "Default homepage: ": "Προεπιλεγμένη αρχική: ",
- "Feed menu: ": "Μενού ροής συνδρομών: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Προτιμήσεις διαχειριστή",
+ "preferences_default_home_label": "Προεπιλεγμένη αρχική: ",
+ "preferences_feed_menu_label": "Μενού ροής συνδρομών: ",
"Top enabled: ": "Ενεργοποίηση κορυφαίων; ",
"CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ",
"Login enabled: ": "Ενεργοποίηση σύνδεσης; ",
@@ -127,25 +110,12 @@
"Subscription manager": "Διαχειριστής συνδρομών",
"Token manager": "Διαχειριστής διασυνδέσεων",
"Token": "Διασύνδεση",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομή",
- "": "`x` συνδρομές"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` διασύνδεση",
- "": "`x` διασυνδέσεις"
- },
"Import/export": "Εισαγωγή/εξαγωγή",
"unsubscribe": "κατάργηση συνδρομής",
"revoke": "ανάκληση",
"Subscriptions": "Συνδρομές",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` καινούρια ειδοποίηση",
- "": "`x` καινούριες ειδοποιήσεις"
- },
"search": "αναζήτηση",
"Log out": "Αποσύνδεση",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Προβολή πηγαίου κώδικα εδώ.",
"View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.",
"View privacy policy.": "Προβολή πολιτικής απορρήτου.",
@@ -161,11 +131,7 @@
"Title": "Τίτλος",
"Playlist privacy": "Ιδιωτικότητα καταλόγων αναπαραγωγής",
"Editing playlist `x`": "Επεξεργασία `x` καταλόγου αναπαραγωγής",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "Προβολή στο YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Απόκρυψη σημειώσεων",
"Show annotations": "Προβολή σημειώσεων",
"Genre: ": "Είδος: ",
@@ -176,10 +142,6 @@
"Whitelisted regions: ": "Επιτρεπτές περιοχές: ",
"Blacklisted regions: ": "Μη-επιτρεπτές περιοχές: ",
"Shared `x`": "Μοιράστηκε το `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` προβολή",
- "": "`x` προβολές"
- },
"Premieres in `x`": "Πρώτη προβολή σε `x`",
"Premieres `x`": "Επίσημη πρώτη παράσταση του `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά.",
@@ -213,16 +175,8 @@
"This channel does not exist.": "Αυτό το κανάλι δεν υπάρχει.",
"Could not get channel info.": "Αδύναμια εύρεσης πληροφοριών καναλιού.",
"Could not fetch comments": "Αδυναμία λήψης σχολίων",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` απάντησης",
- "": "Προβολή `x` απαντήσεων"
- },
"`x` ago": "Πριν `x`",
"Load more": "Φόρτωση περισσότερων",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βαθμός",
- "": "`x` βαθμοί"
- },
"Could not create mix.": "Αδυναμία δημιουργίας μίξης.",
"Empty playlist": "Κενή λίστα αναπαραγωγής",
"Not a playlist.": "Μη έγκυρη λίστα αναπαραγωγής.",
@@ -340,41 +294,12 @@
"Yiddish": "Γίντις",
"Yoruba": "Γιορούμπα",
"Zulu": "Ζουλού",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` χρόνο",
- "": "`x` χρόνια"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` μήνα",
- "": "`x` μήνες"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` εβδομάδα",
- "": "`x` εβδομάδες"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ημέρα",
- "": "`x` ημέρες"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ώρα",
- "": "`x` ώρες"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` λεπτό",
- "": "`x` λεπτά"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` δευτερόλεπτο",
- "": "`x` δευτερόλεπτα"
- },
"Fallback comments: ": "Εναλλακτικά σχόλια: ",
"Popular": "Δημοφιλή",
- "Search": "",
"Top": "Κορυφαία",
"About": "Σχετικά",
"Rating: ": "Aξιολόγηση: ",
- "Language: ": "Γλώσσα: ",
+ "preferences_locale_label": "Γλώσσα: ",
"View as playlist": "Προβολή ως λίστα αναπαραγωγής",
"Default": "Προεπιλογή",
"Music": "Μουσική",
@@ -393,35 +318,5 @@
"Videos": "Βίντεο",
"Playlists": "Λίστες Αναπαραγωγής",
"Community": "Κοινότητα",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "Τρέχουσα έκδοση: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "Τρέχουσα έκδοση: "
}
diff --git a/locales/en-US.json b/locales/en-US.json
index a1e39777..f733f7db 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -1,16 +1,14 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscriber",
- "": "`x` subscribers"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` videos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
- "": "`x` playlists"
- },
+ "generic_views_count": "{{count}} view",
+ "generic_views_count_plural": "{{count}} views",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} videos",
+ "generic_playlists_count": "{{count}} playlist",
+ "generic_playlists_count_plural": "{{count}} playlists",
+ "generic_subscribers_count": "{{count}} subscriber",
+ "generic_subscribers_count_plural": "{{count}} subscribers",
+ "generic_subscriptions_count": "{{count}} subscription",
+ "generic_subscriptions_count_plural": "{{count}} subscriptions",
"LIVE": "LIVE",
"Shared `x` ago": "Shared `x` ago",
"Unsubscribe": "Unsubscribe",
@@ -60,39 +58,58 @@
"E-mail": "E-mail",
"Google verification code": "Google verification code",
"Preferences": "Preferences",
- "Player preferences": "Player preferences",
- "Always loop: ": "Always loop: ",
- "Autoplay: ": "Autoplay: ",
- "Play next by default: ": "Play next by default: ",
- "Autoplay next video: ": "Autoplay next video: ",
- "Listen by default: ": "Listen by default: ",
- "Proxy videos: ": "Proxy videos: ",
- "Default speed: ": "Default speed: ",
- "Preferred video quality: ": "Preferred video quality: ",
- "Player volume: ": "Player volume: ",
- "Default comments: ": "Default comments: ",
+ "preferences_category_player": "Player preferences",
+ "preferences_video_loop_label": "Always loop: ",
+ "preferences_autoplay_label": "Autoplay: ",
+ "preferences_continue_label": "Play next by default: ",
+ "preferences_continue_autoplay_label": "Autoplay next video: ",
+ "preferences_listen_label": "Listen by default: ",
+ "preferences_local_label": "Proxy videos: ",
+ "preferences_speed_label": "Default speed: ",
+ "preferences_quality_label": "Preferred video quality: ",
+ "preferences_quality_option_dash": "DASH (adaptative quality)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Medium",
+ "preferences_quality_option_small": "Small",
+ "preferences_quality_dash_label": "Preferred DASH video quality: ",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "Best",
+ "preferences_quality_dash_option_worst": "Worst",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_volume_label": "Player volume: ",
+ "preferences_comments_label": "Default comments: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Default captions: ",
+ "reddit": "Reddit",
+ "invidious": "Invidious",
+ "preferences_captions_label": "Default captions: ",
"Fallback captions: ": "Fallback captions: ",
- "Show related videos: ": "Show related videos: ",
- "Show annotations by default: ": "Show annotations by default: ",
- "Automatically extend video description: ": "Automatically extend video description: ",
- "Interactive 360 degree videos: ": "Interactive 360 degree videos: ",
- "Visual preferences": "Visual preferences",
- "Player style: ": "Player style: ",
+ "preferences_related_videos_label": "Show related videos: ",
+ "preferences_annotations_label": "Show annotations by default: ",
+ "preferences_extend_desc_label": "Automatically extend video description: ",
+ "preferences_vr_mode_label": "Interactive 360 degree videos: ",
+ "preferences_category_visual": "Visual preferences",
+ "preferences_region_label": "Content country: ",
+ "preferences_player_style_label": "Player style: ",
"Dark mode: ": "Dark mode: ",
- "Theme: ": "Theme: ",
+ "preferences_dark_mode_label": "Theme: ",
"dark": "dark",
"light": "light",
- "Thin mode: ": "Thin mode: ",
- "Miscellaneous preferences": "Miscellaneous preferences",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaticatic instance redirection (fallback to redirect.invidious.io): ",
- "Subscription preferences": "Subscription preferences",
- "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",
+ "preferences_thin_mode_label": "Thin mode: ",
+ "preferences_category_misc": "Miscellaneous preferences",
+ "preferences_automatic_instance_redirect_label": "Automatic instance redirection (fallback to redirect.invidious.io): ",
+ "preferences_category_subscription": "Subscription preferences",
+ "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ",
"Redirect homepage to feed: ": "Redirect homepage to feed: ",
- "Number of videos shown in feed: ": "Number of videos shown in feed: ",
- "Sort videos by: ": "Sort videos by: ",
+ "preferences_max_results_label": "Number of videos shown in feed: ",
+ "preferences_sort_label": "Sort videos by: ",
"published": "published",
"published - reverse": "published - reverse",
"alphabetically": "alphabetically",
@@ -101,12 +118,12 @@
"channel name - reverse": "channel name - reverse",
"Only show latest video from channel: ": "Only show latest video from channel: ",
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
- "Only show unwatched: ": "Only show unwatched: ",
- "Only show notifications (if there are any): ": "Only show notifications (if there are any): ",
+ "preferences_unseen_only_label": "Only show unwatched: ",
+ "preferences_notifications_only_label": "Only show notifications (if there are any): ",
"Enable web notifications": "Enable web notifications",
"`x` uploaded a video": "`x` uploaded a video",
"`x` is live": "`x` is live",
- "Data preferences": "Data preferences",
+ "preferences_category_data": "Data preferences",
"Clear watch history": "Clear watch history",
"Import/export data": "Import/export data",
"Change password": "Change password",
@@ -114,10 +131,10 @@
"Manage tokens": "Manage tokens",
"Watch history": "Watch history",
"Delete account": "Delete account",
- "Administrator preferences": "Administrator preferences",
- "Default homepage: ": "Default homepage: ",
- "Feed menu: ": "Feed menu: ",
- "Show nickname on top: ": "Show nickname on top: ",
+ "preferences_category_admin": "Administrator preferences",
+ "preferences_default_home_label": "Default homepage: ",
+ "preferences_feed_menu_label": "Feed menu: ",
+ "preferences_show_nick_label": "Show nickname on top: ",
"Top enabled: ": "Top enabled: ",
"CAPTCHA enabled: ": "CAPTCHA enabled: ",
"Login enabled: ": "Login enabled: ",
@@ -127,22 +144,14 @@
"Subscription manager": "Subscription manager",
"Token manager": "Token manager",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscription",
- "": "`x` subscriptions"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` tokens"
- },
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} tokens",
"Import/export": "Import/export",
"unsubscribe": "unsubscribe",
"revoke": "revoke",
"Subscriptions": "Subscriptions",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` unseen notification",
- "": "`x` unseen notifications"
- },
+ "subscriptions_unseen_notifs_count": "{{count}} unseen notification",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} unseen notifications",
"search": "search",
"Log out": "Log out",
"Released under the AGPLv3 on Github.": "Released under the AGPLv3 on Github.",
@@ -176,10 +185,6 @@
"Whitelisted regions: ": "Whitelisted regions: ",
"Blacklisted regions: ": "Blacklisted regions: ",
"Shared `x`": "Shared `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` view",
- "": "`x` views"
- },
"Premieres in `x`": "Premieres in `x`",
"Premieres `x`": "Premieres `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.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.",
@@ -213,16 +218,12 @@
"This channel does not exist.": "This channel does not exist.",
"Could not get channel info.": "Could not get channel info.",
"Could not fetch comments": "Could not fetch comments",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "View `x` reply",
- "": "View `x` replies"
- },
+ "comments_view_x_replies": "View {{count}} reply",
+ "comments_view_x_replies_plural": "View {{count}} replies",
"`x` ago": "`x` ago",
"Load more": "Load more",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point",
- "": "`x` points"
- },
+ "comments_points_count": "{{count}} point",
+ "comments_points_count_plural": "{{count}} points",
"Could not create mix.": "Could not create mix.",
"Empty playlist": "Empty playlist",
"Not a playlist.": "Not a playlist.",
@@ -340,41 +341,27 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` year",
- "": "`x` years"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` month",
- "": "`x` months"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` week",
- "": "`x` weeks"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` day",
- "": "`x` days"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hour",
- "": "`x` hours"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute",
- "": "`x` minutes"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` second",
- "": "`x` seconds"
- },
+ "generic_count_years": "{{count}} year",
+ "generic_count_years_plural": "{{count}} years",
+ "generic_count_months": "{{count}} month",
+ "generic_count_months_plural": "{{count}} months",
+ "generic_count_weeks": "{{count}} week",
+ "generic_count_weeks_plural": "{{count}} weeks",
+ "generic_count_days": "{{count}} day",
+ "generic_count_days_plural": "{{count}} days",
+ "generic_count_hours": "{{count}} hour",
+ "generic_count_hours_plural": "{{count}} hours",
+ "generic_count_minutes": "{{count}} minute",
+ "generic_count_minutes_plural": "{{count}} minutes",
+ "generic_count_seconds": "{{count}} second",
+ "generic_count_seconds_plural": "{{count}} seconds",
"Fallback comments: ": "Fallback comments: ",
"Popular": "Popular",
"Search": "Search",
"Top": "Top",
"About": "About",
"Rating: ": "Rating: ",
- "Language: ": "Language: ",
+ "preferences_locale_label": "Language: ",
"View as playlist": "View as playlist",
"Default": "Default",
"Music": "Music",
@@ -411,6 +398,8 @@
"playlist": "Playlist",
"movie": "Movie",
"show": "Show",
+ "short": "Short (< 4 minutes)",
+ "long": "Long (> 20 minutes)",
"hd": "HD",
"subtitles": "Subtitles/CC",
"creative_commons": "Creative Commons",
@@ -419,9 +408,34 @@
"4k": "4K",
"location": "Location",
"hdr": "HDR",
+ "purchased": "Purchased",
+ "360": "360°",
"filter": "Filter",
"Current version: ": "Current version: ",
"next_steps_error_message": "After which you should try to: ",
"next_steps_error_message_refresh": "Refresh",
- "next_steps_error_message_go_to_youtube": "Go to YouTube"
+ "next_steps_error_message_go_to_youtube": "Go to YouTube",
+ "footer_donate_page": "Donate",
+ "footer_documentation": "Documentation",
+ "footer_source_code": "Source code",
+ "footer_original_source_code": "Original source code",
+ "footer_modfied_source_code": "Modified Source code",
+ "adminprefs_modified_source_code_url_label": "URL to modified source code repository",
+ "none": "none",
+ "videoinfo_started_streaming_x_ago": "Started streaming `x` ago",
+ "videoinfo_watch_on_youTube": "Watch on YouTube",
+ "videoinfo_youTube_embed_link": "Embed",
+ "videoinfo_invidious_embed_link": "Embed Link",
+ "download_subtitles": "Subtitles - `x` (.vtt)",
+ "user_created_playlists": "`x` created playlists",
+ "user_saved_playlists": "`x` saved playlists",
+ "Video unavailable": "Video unavailable",
+ "preferences_save_player_pos_label": "Save playback position: ",
+ "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!",
+ "crash_page_before_reporting": "Before reporting a bug, make sure that you have:",
+ "crash_page_refresh": "tried to <a href=\"`x`\">refresh the page</a>",
+ "crash_page_switch_instance": "tried to <a href=\"`x`\">use another instance</a>",
+ "crash_page_read_the_faq": "read the <a href=\"`x`\">Frenquently Asked Questions (FAQ)</a>",
+ "crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on Github</a>",
+ "crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):"
}
diff --git a/locales/eo.json b/locales/eo.json
index 7c2c7482..e7a8453e 100644
--- a/locales/eo.json
+++ b/locales/eo.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonantoj",
- "": "`x` abonantoj"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` filmetoj",
- "": "`x` filmetoj"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ludlistoj",
- "": "`x` ludlistoj"
- },
"LIVE": "NUNA",
"Shared `x` ago": "Konigita antaŭ `x`",
"Unsubscribe": "Malabonu",
@@ -60,39 +48,39 @@
"E-mail": "Retpoŝto",
"Google verification code": "Kontrolkodo de Google",
"Preferences": "Agordoj",
- "Player preferences": "Spektilaj agordoj",
- "Always loop: ": "Ĉiam ripeti: ",
- "Autoplay: ": "Aŭtomate ludi: ",
- "Play next by default: ": "Ludi sekvan defaŭlte: ",
- "Autoplay next video: ": "Aŭtomate ludi sekvan filmeton: ",
- "Listen by default: ": "Aŭskulti defaŭlte: ",
- "Proxy videos: ": "Ĉu uzi prokuran servilon por filmetojn? ",
- "Default speed: ": "Defaŭlta rapido: ",
- "Preferred video quality: ": "Preferita filmetkvalito: ",
- "Player volume: ": "Ludila sonforteco: ",
- "Default comments: ": "Defaŭltaj komentoj: ",
+ "preferences_category_player": "Spektilaj agordoj",
+ "preferences_video_loop_label": "Ĉiam ripeti: ",
+ "preferences_autoplay_label": "Aŭtomate ludi: ",
+ "preferences_continue_label": "Ludi sekvan defaŭlte: ",
+ "preferences_continue_autoplay_label": "Aŭtomate ludi sekvan filmeton: ",
+ "preferences_listen_label": "Aŭskulti defaŭlte: ",
+ "preferences_local_label": "Ĉu uzi prokuran servilon por filmetojn? ",
+ "preferences_speed_label": "Defaŭlta rapido: ",
+ "preferences_quality_label": "Preferita filmetkvalito: ",
+ "preferences_volume_label": "Ludila sonforteco: ",
+ "preferences_comments_label": "Defaŭltaj komentoj: ",
"youtube": "JuTubo",
"reddit": "Reddit",
- "Default captions: ": "Defaŭltaj subtekstoj: ",
+ "preferences_captions_label": "Defaŭltaj subtekstoj: ",
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
- "Show related videos: ": "Ĉu montri rilatajn filmetojn? ",
- "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
- "Automatically extend video description: ": "Aŭtomate etendi priskribon de filmeto: ",
- "Interactive 360 degree videos: ": "Interagaj 360-gradaj filmetoj: ",
- "Visual preferences": "Vidaj preferoj",
- "Player style: ": "Ludila stilo: ",
+ "preferences_related_videos_label": "Ĉu montri rilatajn filmetojn? ",
+ "preferences_annotations_label": "Ĉu montri prinotojn defaŭlte? ",
+ "preferences_extend_desc_label": "Aŭtomate etendi priskribon de filmeto: ",
+ "preferences_vr_mode_label": "Interagaj 360-gradaj filmetoj: ",
+ "preferences_category_visual": "Vidaj preferoj",
+ "preferences_player_style_label": "Ludila stilo: ",
"Dark mode: ": "Malhela reĝimo: ",
- "Theme: ": "Etoso: ",
+ "preferences_dark_mode_label": "Etoso: ",
"dark": "malhela",
"light": "hela",
- "Thin mode: ": "Maldika reĝimo: ",
- "Miscellaneous preferences": "Aliaj agordoj",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ",
- "Subscription preferences": "Abonaj agordoj",
- "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
+ "preferences_thin_mode_label": "Maldika reĝimo: ",
+ "preferences_category_misc": "Aliaj agordoj",
+ "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ",
+ "preferences_category_subscription": "Abonaj agordoj",
+ "preferences_annotations_subscribed_label": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
- "Number of videos shown in feed: ": "Nombro da filmetoj montritaj en fluo: ",
- "Sort videos by: ": "Ordi filmetojn per: ",
+ "preferences_max_results_label": "Nombro da filmetoj montritaj en fluo: ",
+ "preferences_sort_label": "Ordi filmetojn per: ",
"published": "publikigo",
"published - reverse": "publitigo - renverse",
"alphabetically": "alfabete",
@@ -101,12 +89,12 @@
"channel name - reverse": "kanala nombro - renverse",
"Only show latest video from channel: ": "Nur montri pli novan filmeton el kanalo: ",
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan filmeton el kanalo: ",
- "Only show unwatched: ": "Nur montri malviditajn: ",
- "Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
+ "preferences_unseen_only_label": "Nur montri malviditajn: ",
+ "preferences_notifications_only_label": "Nur montri sciigojn (se estas): ",
"Enable web notifications": "Ebligi retejajn sciigojn",
"`x` uploaded a video": "`x` alŝutis filmeton",
"`x` is live": "`x` estas nuna",
- "Data preferences": "Datumagordoj",
+ "preferences_category_data": "Datumagordoj",
"Clear watch history": "Forigi vidohistorion",
"Import/export data": "Importi/Eksporti datumojn",
"Change password": "Ŝanĝi pasvorton",
@@ -114,10 +102,10 @@
"Manage tokens": "Administri ĵetonojn",
"Watch history": "Vidohistorio",
"Delete account": "Forigi konton",
- "Administrator preferences": "Agordoj de administranto",
- "Default homepage: ": "Defaŭlta hejmpaĝo: ",
- "Feed menu: ": "Flua menuo: ",
- "Show nickname on top: ": "Montri kromnomon supre: ",
+ "preferences_category_admin": "Agordoj de administranto",
+ "preferences_default_home_label": "Defaŭlta hejmpaĝo: ",
+ "preferences_feed_menu_label": "Flua menuo: ",
+ "preferences_show_nick_label": "Montri kromnomon supre: ",
"Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ",
"CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ",
"Login enabled: ": "Ĉu ensaluto aktivita? ",
@@ -127,22 +115,10 @@
"Subscription manager": "Administrilo de abonoj",
"Token manager": "Ĵetona administrilo",
"Token": "Ĵetono",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonoj",
- "": "`x` abonoj"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ĵetonoj",
- "": "`x` ĵetonoj"
- },
"Import/export": "Importi/Eksporti",
"unsubscribe": "malabonu",
"revoke": "senvalidigi",
"Subscriptions": "Abonoj",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` neviditaj sciigoj",
- "": "`x` neviditaj sciigoj"
- },
"search": "serĉi",
"Log out": "Elsaluti",
"Released under the AGPLv3 on Github.": "Eldonita sub la AGPLv3 en Github.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Regionoj listigitaj en blanka listo: ",
"Blacklisted regions: ": "Regionoj listigitaj en nigra listo: ",
"Shared `x`": "Konigita `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spektaĵoj",
- "": "`x` spektaĵoj"
- },
"Premieres in `x`": "Premieras en `x`",
"Premieres `x`": "Premieras `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.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Ĉi tiu kanalo ne ekzistas.",
"Could not get channel info.": "Ne povis havigi kanalan informon.",
"Could not fetch comments": "Ne povis venigi komentojn",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` respondojn",
- "": "Vidi `x` respondojn"
- },
"`x` ago": "antaŭ `x`",
"Load more": "Ŝarĝi pli",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poentoj",
- "": "`x` poentoj"
- },
"Could not create mix.": "Ne povis krei mikson.",
"Empty playlist": "Ludlisto estas malplena",
"Not a playlist.": "Nevalida ludlisto.",
@@ -340,41 +304,13 @@
"Yiddish": "Jida",
"Yoruba": "Joruba",
"Zulu": "Zulua",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaroj",
- "": "`x` jaroj"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` monatoj",
- "": "`x` monatoj"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semajnoj",
- "": "`x` semajnoj"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tagoj",
- "": "`x` tagoj"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horoj",
- "": "`x` horoj"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutoj",
- "": "`x` minutoj"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundoj",
- "": "`x` sekundoj"
- },
"Fallback comments: ": "Retrodefaŭltaj komentoj: ",
"Popular": "Popularaj",
"Search": "Serĉi",
"Top": "Supraj",
"About": "Pri",
"Rating: ": "Takso: ",
- "Language: ": "Lingvo: ",
+ "preferences_locale_label": "Lingvo: ",
"View as playlist": "Vidi kiel ludlisto",
"Default": "Defaŭlte",
"Music": "Muziko",
@@ -423,5 +359,15 @@
"Current version: ": "Nuna versio: ",
"next_steps_error_message": "Poste, vi provu: ",
"next_steps_error_message_refresh": "Reŝargi",
- "next_steps_error_message_go_to_youtube": "Iri al JuTubo"
+ "next_steps_error_message_go_to_youtube": "Iri al JuTubo",
+ "long": "Longa (> 20 minutos)",
+ "short": "Mallonga (< 4 minutos)",
+ "footer_documentation": "Dokumentaro",
+ "footer_source_code": "Fontkodo",
+ "adminprefs_modified_source_code_url_label": "URL al modifita deponejo de fontkodo",
+ "footer_modfied_source_code": "Modifita Fontkodo",
+ "footer_original_source_code": "Originala fontkodo",
+ "footer_donate_page": "Donaci",
+ "preferences_region_label": "Lando de la enhavo: ",
+ "preferences_quality_dash_label": "Preferata DASH-a videkvalito: "
}
diff --git a/locales/es.json b/locales/es.json
index 1f3f1c9e..d89b5c08 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` suscriptores",
- "": "`x` suscriptores"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos",
- "": "`x` vídeos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reproducción",
- "": "`x` listas de reproducción"
- },
"LIVE": "DIRECTO",
"Shared `x` ago": "Compartido hace `x`",
"Unsubscribe": "Desuscribirse",
@@ -60,39 +48,39 @@
"E-mail": "Correo",
"Google verification code": "Código de verificación de Google",
"Preferences": "Preferencias",
- "Player preferences": "Preferencias del reproductor",
- "Always loop: ": "Repetir siempre: ",
- "Autoplay: ": "Reproducción automática: ",
- "Play next by default: ": "Reproducir siguiente por defecto: ",
- "Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ",
- "Listen by default: ": "Activar el sonido por defecto: ",
- "Proxy videos: ": "¿Usar un proxy para los vídeos? ",
- "Default speed: ": "Velocidad por defecto: ",
- "Preferred video quality: ": "Calidad de vídeo preferida: ",
- "Player volume: ": "Volumen del reproductor: ",
- "Default comments: ": "Comentarios por defecto: ",
+ "preferences_category_player": "Preferencias del reproductor",
+ "preferences_video_loop_label": "Repetir siempre: ",
+ "preferences_autoplay_label": "Reproducción automática: ",
+ "preferences_continue_label": "Reproducir siguiente por defecto: ",
+ "preferences_continue_autoplay_label": "Reproducir automáticamente el vídeo siguiente: ",
+ "preferences_listen_label": "Activar el sonido por defecto: ",
+ "preferences_local_label": "¿Usar un proxy para los vídeos? ",
+ "preferences_speed_label": "Velocidad por defecto: ",
+ "preferences_quality_label": "Calidad de vídeo preferida: ",
+ "preferences_volume_label": "Volumen del reproductor: ",
+ "preferences_comments_label": "Comentarios por defecto: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Subtítulos por defecto: ",
+ "preferences_captions_label": "Subtítulos por defecto: ",
"Fallback captions: ": "Subtítulos alternativos: ",
- "Show related videos: ": "¿Mostrar vídeos relacionados? ",
- "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
- "Automatically extend video description: ": "Extender automáticamente la descripción del vídeo: ",
- "Interactive 360 degree videos: ": "Vídeos interactivos de 360 grados: ",
- "Visual preferences": "Preferencias visuales",
- "Player style: ": "Estilo de reproductor: ",
+ "preferences_related_videos_label": "¿Mostrar vídeos relacionados? ",
+ "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ",
+ "preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ",
+ "preferences_vr_mode_label": "Vídeos interactivos de 360 grados: ",
+ "preferences_category_visual": "Preferencias visuales",
+ "preferences_player_style_label": "Estilo de reproductor: ",
"Dark mode: ": "Modo oscuro: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "oscuro",
"light": "claro",
- "Thin mode: ": "Modo compacto: ",
- "Miscellaneous preferences": "Preferencias misceláneas",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ",
- "Subscription preferences": "Preferencias de la suscripción",
- "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
+ "preferences_thin_mode_label": "Modo compacto: ",
+ "preferences_category_misc": "Preferencias misceláneas",
+ "preferences_automatic_instance_redirect_label": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ",
+ "preferences_category_subscription": "Preferencias de la suscripción",
+ "preferences_annotations_subscribed_label": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
"Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ",
- "Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ",
- "Sort videos by: ": "Ordenar los vídeos por: ",
+ "preferences_max_results_label": "Número de vídeos mostrados en la fuente: ",
+ "preferences_sort_label": "Ordenar los vídeos por: ",
"published": "fecha de publicación",
"published - reverse": "fecha de publicación: orden inverso",
"alphabetically": "alfabéticamente",
@@ -101,12 +89,12 @@
"channel name - reverse": "nombre del canal: orden inverso",
"Only show latest video from channel: ": "Mostrar solo el último vídeo del canal: ",
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
- "Only show unwatched: ": "Mostrar solo los no vistos: ",
- "Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
+ "preferences_unseen_only_label": "Mostrar solo los no vistos: ",
+ "preferences_notifications_only_label": "Mostrar solo notificaciones (si hay alguna): ",
"Enable web notifications": "Habilitar notificaciones web",
"`x` uploaded a video": "`x` subió un video",
"`x` is live": "`x` esta en vivo",
- "Data preferences": "Preferencias de los datos",
+ "preferences_category_data": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos",
"Change password": "Cambiar contraseña",
@@ -114,10 +102,10 @@
"Manage tokens": "Gestionar tokens",
"Watch history": "Historial de reproducción",
"Delete account": "Borrar cuenta",
- "Administrator preferences": "Preferencias de administrador",
- "Default homepage: ": "Página de inicio por defecto: ",
- "Feed menu: ": "Menú de fuentes: ",
- "Show nickname on top: ": "Mostrar nombre de usuario arriba: ",
+ "preferences_category_admin": "Preferencias de administrador",
+ "preferences_default_home_label": "Página de inicio por defecto: ",
+ "preferences_feed_menu_label": "Menú de fuentes: ",
+ "preferences_show_nick_label": "Mostrar nombre de usuario arriba: ",
"Top enabled: ": "¿Habilitar los destacados? ",
"CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ",
"Login enabled: ": "¿Habilitar el inicio de sesión? ",
@@ -127,22 +115,10 @@
"Subscription manager": "Gestor de suscripciones",
"Token manager": "Gestor de tokens",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` suscripciones",
- "": "`x` suscripciones"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
- "": "`x` tokens"
- },
"Import/export": "Importar/Exportar",
"unsubscribe": "Desuscribirse",
"revoke": "revocar",
"Subscriptions": "Suscripciones",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificaciones sin ver",
- "": "`x` notificaciones sin ver"
- },
"search": "buscar",
"Log out": "Cerrar la sesión",
"Released under the AGPLv3 on Github.": "Publicado bajo la AGPLv3 en Github.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Regiones permitidas: ",
"Blacklisted regions: ": "Regiones bloqueadas: ",
"Shared `x`": "Compartido `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizaciones",
- "": "`x` visualizaciones"
- },
"Premieres in `x`": "Se estrena en `x`",
"Premieres `x`": "Estrenos `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "El canal no existe.",
"Could not get channel info.": "No se ha podido obtener información del canal.",
"Could not fetch comments": "No se han podido recuperar los comentarios",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respuestas",
- "": "Ver `x` respuestas"
- },
"`x` ago": "hace `x`",
"Load more": "Cargar más",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puntos",
- "": "`x` puntos"
- },
"Could not create mix.": "No se ha podido crear la mezcla.",
"Empty playlist": "La lista de reproducción está vacía",
"Not a playlist.": "Lista de reproducción no válida.",
@@ -340,41 +304,13 @@
"Yiddish": "Yidis",
"Yoruba": "Yoruba",
"Zulu": "Zulú",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` años",
- "": "`x` años"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
- "": "`x` meses"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
- "": "`x` semanas"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` días",
- "": "`x` días"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
- "": "`x` horas"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
- "": "`x` minutos"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
- "": "`x` segundos"
- },
"Fallback comments: ": "Comentarios alternativos: ",
"Popular": "Populares",
"Search": "Buscar",
"Top": "Destacados",
"About": "Acerca de",
"Rating: ": "Valoración: ",
- "Language: ": "Idioma: ",
+ "preferences_locale_label": "Idioma: ",
"View as playlist": "Ver como lista de reproducción",
"Default": "Por defecto",
"Music": "Música",
@@ -386,7 +322,7 @@
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Enlace permanente de YouTube del comentario",
- "permalink": "permalink",
+ "permalink": "enlace permanente",
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio",
"Video mode": "Modo de vídeo",
@@ -423,5 +359,81 @@
"Current version: ": "Versión actual: ",
"next_steps_error_message": "Después de lo cual deberías intentar: ",
"next_steps_error_message_refresh": "Recargar",
- "next_steps_error_message_go_to_youtube": "Ir a YouTube"
+ "next_steps_error_message_go_to_youtube": "Ir a YouTube",
+ "short": "Corto (< 4 minutos)",
+ "long": "Largo (> 20 minutos)",
+ "footer_documentation": "Documentación",
+ "footer_original_source_code": "Código fuente original",
+ "adminprefs_modified_source_code_url_label": "URL al repositorio de código fuente modificado",
+ "footer_source_code": "Código fuente",
+ "footer_modfied_source_code": "Código fuente modificado",
+ "footer_donate_page": "Donar",
+ "preferences_region_label": "País del contenido: ",
+ "preferences_quality_dash_label": "Calidad de vídeo DASH preferida: ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Intermedia",
+ "preferences_quality_dash_option_auto": "Automática",
+ "none": "ninguno",
+ "videoinfo_started_streaming_x_ago": "Comenzó difusión hace `x`",
+ "download_subtitles": "Subtítulos- `x` (.vtt)",
+ "user_created_playlists": "`x` listas de reproducción creadas",
+ "user_saved_playlists": "`x` listas de reproducción guardadas",
+ "Video unavailable": "Vídeo no disponible",
+ "videoinfo_youTube_embed_link": "Insertar",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "invidious": "Invidious",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_option_dash": "DASH (calidad adaptativa)",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_quality_option_small": "Pequeña",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_best": "La mejor",
+ "preferences_quality_dash_option_worst": "La peor",
+ "videoinfo_invidious_embed_link": "Enlace para Insertar",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "purchased": "Comprado",
+ "360": "360°",
+ "videoinfo_watch_on_youTube": "Ver en YouTube",
+ "preferences_save_player_pos_label": "Guardar posición de reproducción: ",
+ "generic_views_count": "{{count}} visualización",
+ "generic_views_count_plural": "{{count}} visualizaciones",
+ "generic_subscribers_count": "{{count}} suscriptor",
+ "generic_subscribers_count_plural": "{{count}} suscriptores",
+ "generic_subscriptions_count": "{{count}} suscripción",
+ "generic_subscriptions_count_plural": "{{count}} suscripciones",
+ "subscriptions_unseen_notifs_count": "{{count}} notificación no vista",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} notificaciones no vistas",
+ "generic_count_days": "{{count}} día",
+ "generic_count_days_plural": "{{count}} días",
+ "comments_view_x_replies": "Ver {{count}} respuesta",
+ "comments_view_x_replies_plural": "Ver {{count}} respuestas",
+ "generic_count_weeks": "{{count}} semana",
+ "generic_count_weeks_plural": "{{count}} semanas",
+ "generic_playlists_count": "{{count}} lista de reproducción",
+ "generic_playlists_count_plural": "{{count}} listas de reproducción",
+ "generic_videos_count": "{{count}} vídeo",
+ "generic_videos_count_plural": "{{count}} vídeos",
+ "generic_count_months": "{{count}} mes",
+ "generic_count_months_plural": "{{count}} meses",
+ "comments_points_count": "{{count}} punto",
+ "comments_points_count_plural": "{{count}} puntos",
+ "generic_count_years": "{{count}} año",
+ "generic_count_years_plural": "{{count}} años",
+ "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",
+ "crash_page_before_reporting": "Antes de notificar un error asegúrate de que has:",
+ "crash_page_switch_instance": "probado a <a href=\"`x`\">usar otra instancia</a>",
+ "crash_page_read_the_faq": "leído las <a href=\"`x`\">Preguntas Frecuentes</a>",
+ "crash_page_search_issue": "buscado <a href=\"`x`\">problemas existentes en Github</a>",
+ "crash_page_you_found_a_bug": "¡Parece que has encontrado un error en Invidious!",
+ "crash_page_refresh": "probado a <a href=\"`x`\">recargar la página</a>",
+ "crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye el siguiente texto en tu mensaje (NO traduzcas este texto):"
}
diff --git a/locales/eu.json b/locales/eu.json
index df3f4329..a5c7c562 100644
--- a/locales/eu.json
+++ b/locales/eu.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` harpidedun"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` bideo"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` erreprodukzio-zerrenda"
- },
"LIVE": "ZUZENEAN",
"Shared `x` ago": "Duela `x` partekatua",
"Unsubscribe": "Harpidetza kendu",
@@ -28,7 +16,6 @@
"New passwords must match": "Pasahitza berriek bat egin behar dute",
"Cannot change password for Google accounts": "Ezin da pasahitza aldatu Google kontuetan",
"Authorize token?": "Baimendu tokena?",
- "Authorize token for `x`?": "",
"Yes": "Bai",
"No": "Ez",
"Import and Export Data": "Datuak inportatu eta esportatu",
@@ -58,370 +45,22 @@
"Sign In": "Hasi saioa",
"Register": "Eman izena",
"E-mail": "E-posta",
- "Google verification code": "",
"Preferences": "Hobespenak",
- "Player preferences": "Erreproduzigailuaren hobespenak",
- "Always loop: ": "",
- "Autoplay: ": "Automatikoki erreproduzitu: ",
- "Play next by default: ": "",
- "Autoplay next video: ": "Erreproduzitu automatikoki hurrengo bideoa: ",
- "Listen by default: ": "",
- "Proxy videos: ": "",
- "Default speed: ": "",
- "Preferred video quality: ": "Hobetsitako bideoaren kalitatea: ",
- "Player volume: ": "Erreproduzigailuaren bolumena: ",
- "Default comments: ": "Lehenetsitako iruzkinak: ",
+ "preferences_category_player": "Erreproduzigailuaren hobespenak",
+ "preferences_autoplay_label": "Automatikoki erreproduzitu: ",
+ "preferences_continue_autoplay_label": "Erreproduzitu automatikoki hurrengo bideoa: ",
+ "preferences_quality_label": "Hobetsitako bideoaren kalitatea: ",
+ "preferences_volume_label": "Erreproduzigailuaren bolumena: ",
+ "preferences_comments_label": "Lehenetsitako iruzkinak: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Lehenetsitako azpitituluak: ",
- "Fallback captions: ": "",
- "Show related videos: ": "Erakutsi erlazionatutako bideoak: ",
- "Show annotations by default: ": "Erakutsi oharrak modu lehenetsian: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Hobespen bisualak",
- "Player style: ": "Erreproduzigailu mota: ",
+ "preferences_captions_label": "Lehenetsitako azpitituluak: ",
+ "preferences_related_videos_label": "Erakutsi erlazionatutako bideoak: ",
+ "preferences_annotations_label": "Erakutsi oharrak modu lehenetsian: ",
+ "preferences_category_visual": "Hobespen bisualak",
+ "preferences_player_style_label": "Erreproduzigailu mota: ",
"Dark mode: ": "Gai iluna: ",
- "Theme: ": "Gaia: ",
+ "preferences_dark_mode_label": "Gaia: ",
"dark": "iluna",
- "light": "argia",
- "Thin mode: ": "",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Harpidetzen hobespenak",
- "Show annotations by default for subscribed channels: ": "",
- "Redirect homepage to feed: ": "",
- "Number of videos shown in feed: ": "",
- "Sort videos by: ": "",
- "published": "",
- "published - reverse": "",
- "alphabetically": "",
- "alphabetically - reverse": "",
- "channel name": "",
- "channel name - reverse": "",
- "Only show latest video from channel: ": "",
- "Only show latest unwatched video from channel: ": "",
- "Only show unwatched: ": "",
- "Only show notifications (if there are any): ": "",
- "Enable web notifications": "",
- "`x` uploaded a video": "",
- "`x` is live": "",
- "Data preferences": "",
- "Clear watch history": "",
- "Import/export data": "",
- "Change password": "",
- "Manage subscriptions": "",
- "Manage tokens": "",
- "Watch history": "",
- "Delete account": "",
- "Administrator preferences": "",
- "Default homepage: ": "",
- "Feed menu: ": "",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
- "Save preferences": "",
- "Subscription manager": "",
- "Token manager": "",
- "Token": "",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "search": "",
- "Log out": "",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "",
- "View JavaScript license information.": "",
- "View privacy policy.": "",
- "Trending": "",
- "Public": "",
- "Unlisted": "",
- "Private": "",
- "View all playlists": "",
- "Updated `x` ago": "",
- "Delete playlist `x`?": "",
- "Delete playlist": "",
- "Create playlist": "",
- "Title": "",
- "Playlist privacy": "",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
- "Genre: ": "",
- "License: ": "",
- "Family friendly? ": "",
- "Wilson score: ": "",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "light": "argia"
}
diff --git a/locales/fa.json b/locales/fa.json
index 68a016c4..48b5a17d 100644
--- a/locales/fa.json
+++ b/locales/fa.json
@@ -1,51 +1,44 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشترکان",
- "": "`x` مشترکان"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدیو ها",
- "": "`x` ویدیو ها"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` لیست های پخش",
- "": "`x` لیست های پخش"
- },
+ "generic_views_count_0": "{{count}} بازدید",
+ "generic_videos_count_0": "{{count}} ویدئو",
+ "generic_playlists_count_0": "{{count}} فهرست پخش",
+ "generic_subscribers_count_0": "{{count}} دنبال کننده",
+ "generic_subscriptions_count_0": "{{count}} اشتراک ها",
"LIVE": "زنده",
- "Shared `x` ago": "به اشتراک گذاشته شده `x` پیش",
+ "Shared `x` ago": "`x` پیش به اشتراک گذاشته شده",
"Unsubscribe": "لغو اشتراک",
"Subscribe": "مشترک شدن",
- "View channel on YouTube": "نمایش کانال در یوتیوب",
- "View playlist on YouTube": "نمایش لیست پخش در یوتیوب",
- "newest": "جدید تر",
- "oldest": "قدیمی تر",
+ "View channel on YouTube": "دیدن کانال در یوتیوب",
+ "View playlist on YouTube": "دیدن فهرست پخش در یوتیوب",
+ "newest": "تازه‌ترین",
+ "oldest": "کهنه‌ترین",
"popular": "محبوب",
"last": "آخرین",
"Next page": "صفحه بعد",
"Previous page": "صفحه قبل",
"Clear watch history?": "پاک کردن تاریخچه نمایش؟",
- "New password": "گذرواژه جدید",
- "New passwords must match": "گذارواژه های جدید باید باهم همخوانی داشته باشند",
+ "New password": "گذرواژه تازه",
+ "New passwords must match": "گذارواژه های تازه باید باهم همخوانی داشته باشند",
"Cannot change password for Google accounts": "نمیتوان گذرواژه را برای حساب های کاربری گوگل تغییر داد",
"Authorize token?": "توکن دسترسی؟",
"Authorize token for `x`?": "توکن دسترسی برای `x`؟",
"Yes": "بله",
"No": "خیر",
- "Import and Export Data": "وارد کردن و خارج کردن داده ها",
- "Import": "وارد کردن",
- "Import Invidious data": "وارد کردن داده Invidious",
- "Import YouTube subscriptions": "وارد کردن اشتراک های یوتیوب",
- "Import FreeTube subscriptions (.db)": "وارد کردن اشتراک های فری توب (.db)",
- "Import NewPipe subscriptions (.json)": "وارد کردن اشتراک های نیو پایپ (.json)",
- "Import NewPipe data (.zip)": "وارد کردن داده نیو پایپ (.zip)",
- "Export": "خارج کردن",
- "Export subscriptions as OPML": "خارج کردن اشتراک ها به عنوان OPML",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "خارج کردن اشتراک ها به عنوان OPML (برای فری توب و نیو پایپ)",
- "Export data as JSON": "خارج کردن داده ها به عنوان JSON",
+ "Import and Export Data": "درون‌برد و برون‌برد داده",
+ "Import": "درون‌برد",
+ "Import Invidious data": "درون‌برد داده اینویدیوس",
+ "Import YouTube subscriptions": "درون‌برد اشتراک‌های یوتیوب",
+ "Import FreeTube subscriptions (.db)": "درون‌برد اشتراک‌های فری‌تیوب (.db)",
+ "Import NewPipe subscriptions (.json)": "درون‌برد اشتراک‌های نیوپایپ (.json)",
+ "Import NewPipe data (.zip)": "درون‌برد داده نیوپایپ (.zip)",
+ "Export": "برون‌برد",
+ "Export subscriptions as OPML": "برون‌برد اشتراک‌ها در قالب OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "برون‌برد اشتراک‌ها در قالب OPML (برای نیوپایپ و فری‌تیوب)",
+ "Export data as JSON": "برون‌برد داده در قالب JSON",
"Delete account?": "حذف حساب کاربری؟",
"History": "تاریخچه",
- "An alternative front-end to YouTube": "یک فرانت-اند جایگذین برای یوتیوب",
- "JavaScript license information": "اطلاعات مجوز جاوا اسکریپت",
+ "An alternative front-end to YouTube": "یک پیشانه جایگزین برای یوتیوب",
+ "JavaScript license information": "اطلاعات پروانه جاوااسکریپت",
"source": "منبع",
"Log in": "ورود",
"Log in/register": "ورود/ثبت نام",
@@ -53,46 +46,46 @@
"User ID": "شناسه کاربری",
"Password": "گذرواژه",
"Time (h:mm:ss):": "زمان (h:mm:ss):",
- "Text CAPTCHA": "متن CAPTCHA",
- "Image CAPTCHA": "تصویر CAPTCHA",
+ "Text CAPTCHA": "کپچای متنی",
+ "Image CAPTCHA": "کپچای تصویری",
"Sign In": "ورود",
"Register": "ثبت نام",
"E-mail": "ایمیل",
"Google verification code": "کد تایید گوگل",
"Preferences": "ترجیحات",
- "Player preferences": "ترجیحات نمایش‌دهنده",
- "Always loop: ": "همیشه تکرار شنوده: ",
- "Autoplay: ": "نمایش خودکار: ",
- "Play next by default: ": "پخش بعدی به طور پیشفرض: ",
- "Autoplay next video: ": "پخش خودکار ویدیو بعدی: ",
- "Listen by default: ": "گوش کردن به طور پیشفرض: ",
- "Proxy videos: ": "پروکسی ویدیو ها: ",
- "Default speed: ": "سرعت پیشفرض: ",
- "Preferred video quality: ": "کیفیت ویدیوی ترجیحی: ",
- "Player volume: ": "صدای پخش کننده: ",
- "Default comments: ": "نظرات پیشفرض: ",
+ "preferences_category_player": "ترجیحات نمایش‌دهنده",
+ "preferences_video_loop_label": "همواره ویدئو را بازپخش کن ",
+ "preferences_autoplay_label": "نمایش خودکار: ",
+ "preferences_continue_label": "پخش بعدی به طور پیشفرض: ",
+ "preferences_continue_autoplay_label": "پخش خودکار ویدیو بعدی: ",
+ "preferences_listen_label": "گوش کردن به طور پیشفرض: ",
+ "preferences_local_label": "پروکسی ویدیو ها: ",
+ "preferences_speed_label": "سرعت پیشفرض: ",
+ "preferences_quality_label": "کیفیت ویدیوی ترجیحی: ",
+ "preferences_volume_label": "صدای پخش کننده: ",
+ "preferences_comments_label": "نظرات پیشفرض: ",
"youtube": "یوتیوب",
"reddit": "ردیت",
- "Default captions: ": "زیرنویس های پیشفرض: ",
+ "preferences_captions_label": "زیرنویس های پیشفرض: ",
"Fallback captions: ": "عقب گرد زیرنویس ها: ",
- "Show related videos: ": "نمایش ویدیو های مرتبط: ",
- "Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "ترجیحات بصری",
- "Player style: ": "حالت پخش کننده: ",
+ "preferences_related_videos_label": "نمایش ویدیو های مرتبط: ",
+ "preferences_annotations_label": "نمایش حاشیه نویسی ها به طور پیشفرض: ",
+ "preferences_extend_desc_label": "گسترش خودکار توضیحات ویدئو: ",
+ "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی: ",
+ "preferences_category_visual": "ترجیحات بصری",
+ "preferences_player_style_label": "حالت پخش کننده: ",
"Dark mode: ": "حالت تاریک: ",
- "Theme: ": "تم: ",
+ "preferences_dark_mode_label": "تم: ",
"dark": "تاریک",
"light": "روشن",
- "Thin mode: ": "حالت نازک: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "ترجیحات اشتراک",
- "Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ",
+ "preferences_thin_mode_label": "حالت نازک: ",
+ "preferences_category_misc": "ترجیحات متفرقه",
+ "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (به طور پیش‌فرض به redirect.invidious.io): ",
+ "preferences_category_subscription": "ترجیحات اشتراک",
+ "preferences_annotations_subscribed_label": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ",
"Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ",
- "Number of videos shown in feed: ": "تعداد ویدیو های نمایش داده شده در خوراک: ",
- "Sort videos by: ": "مرتب سازی ویدیو ها بر اساس: ",
+ "preferences_max_results_label": "تعداد ویدیو های نمایش داده شده در خوراک: ",
+ "preferences_sort_label": "مرتب سازی ویدیو ها بر اساس: ",
"published": "منتشر شده",
"published - reverse": "منتشر شده - معکوس",
"alphabetically": "بر اساس حروف الفبا",
@@ -101,12 +94,12 @@
"channel name - reverse": "نام کانال - معکوس",
"Only show latest video from channel: ": "تنها نمایش آخرین ویدیو های کانال: ",
"Only show latest unwatched video from channel: ": "تنها نمایش آخرین ویدیو های تماشا نشده از کانال: ",
- "Only show unwatched: ": "تنها نمایش ویدیو های تماشا نشده: ",
- "Only show notifications (if there are any): ": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ",
+ "preferences_unseen_only_label": "تنها نمایش ویدیو های تماشا نشده: ",
+ "preferences_notifications_only_label": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ",
"Enable web notifications": "فعال کردن اعلان های وب",
"`x` uploaded a video": "`x` یک ویدیو بارگذاری کرد",
"`x` is live": "`x` زنده است",
- "Data preferences": "ترجیحات داده",
+ "preferences_category_data": "ترجیحات داده",
"Clear watch history": "پاک‌کردن تاریخچه تماشا",
"Import/export data": "وارد کردن/خارج کردن داده",
"Change password": "تغییر گذرواژه",
@@ -114,10 +107,10 @@
"Manage tokens": "مدیریت توکن ها",
"Watch history": "تاریخچه تماشا",
"Delete account": "حذف حساب کاربری",
- "Administrator preferences": "ترجیحات مدیریت",
- "Default homepage: ": "صفحه خانه پیشفرض ",
- "Feed menu: ": "منو خوراک: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "ترجیحات مدیریت",
+ "preferences_default_home_label": "صفحه خانه پیشفرض ",
+ "preferences_feed_menu_label": "منو خوراک: ",
+ "preferences_show_nick_label": "نمایش نام مستعار در بالا: ",
"Top enabled: ": "بالا فعال شده: ",
"CAPTCHA enabled: ": "CAPTCHA فعال شده: ",
"Login enabled: ": "ورود فعال شده: ",
@@ -127,25 +120,15 @@
"Subscription manager": "مدیریت اشتراک",
"Token manager": "مدیر توکن",
"Token": "توکن",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اشتراک ها",
- "": "`x` اشتراک ها"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` توکن ها",
- "": "`x` توکن ها"
- },
+ "tokens_count_0": "{{count}} توکن ها",
"Import/export": "وارد کردن/خارج کردن",
"unsubscribe": "لغو اشتراک",
"revoke": "ابطال",
"Subscriptions": "اشتراک ها",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اعلان نادیده",
- "": "`x` اعلان نادیده"
- },
+ "subscriptions_unseen_notifs_count_0": "{{count}} اعلان نادیده",
"search": "جستجو",
"Log out": "خروج",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "منتشر شده تحت پروانه AGPLv3 روی گیت‌هاب.",
"Source available here.": "منبع اینجا دردسترس است.",
"View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.",
"View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.",
@@ -153,19 +136,19 @@
"Public": "عمومی",
"Unlisted": "لیست نشده",
"Private": "خصوصی",
- "View all playlists": "نمایش همه لیست پخش",
+ "View all playlists": "نمایش همه سیاهه‌های پخش",
"Updated `x` ago": "بروز شده `x` پیش",
- "Delete playlist `x`?": "حذف لیست پخش `x`؟",
- "Delete playlist": "حذف لیست پخش",
- "Create playlist": "ایجاد لیست پخش",
+ "Delete playlist `x`?": "حذف سیاههٔ پخش `x`؟",
+ "Delete playlist": "حذف سیاههٔ پخش",
+ "Create playlist": "ایجاد سیاههٔ پخش",
"Title": "عنوان",
- "Playlist privacy": "حریم خصوصی لیست پخش",
- "Editing playlist `x`": "تغییر لیست پخش `x`",
- "Show more": "",
- "Show less": "",
+ "Playlist privacy": "حریم خصوصی سیاههٔ پخش",
+ "Editing playlist `x`": "تغییر سیاههٔ پخش `x`",
+ "Show more": "نمایش بیش‌تر",
+ "Show less": "نمایش کم‌تر",
"Watch on YouTube": "تماشا در یوتیوب",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "تعویض نمونه اینویدیوس",
+ "Broken? Try another Invidious Instance": "کار نمی‌کند؟ نمونه دیگری از اینویدیوس را امتحان کنید",
"Hide annotations": "مخفی کردن حاشیه نویسی ها",
"Show annotations": "نمایش حاشیه نویسی ها",
"Genre: ": "ژانر: ",
@@ -176,10 +159,6 @@
"Whitelisted regions: ": "مناطق لیست سفید: ",
"Blacklisted regions: ": "مناطق لیست سیاه: ",
"Shared `x`": "به اشتراک گذاشته شده `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` بازدید",
- "": "`x` بازدید"
- },
"Premieres in `x`": "برای اولین بار در `x`",
"Premieres `x`": "برای اولین بار `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "سلام! مثل اینکه تو جاوا اسکریپت رو خاموش کرده ای. اینجا کلیک کن تا نظرات را ببینی، این رو یادت باشه که ممکنه بارگذاری اونها کمی طول بکشه.",
@@ -213,20 +192,14 @@
"This channel does not exist.": "این کانال وجود ندارد.",
"Could not get channel info.": "نمیتوان اطلاعات کانال را دریافت کرد.",
"Could not fetch comments": "نمیتوان نظرات را دریافت کرد",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "نمایش `x` پاسخ ها",
- "": "نمایش `x` پاسخ ها"
- },
+ "comments_view_x_replies_0": "نمایش {{count}} پاسخ ها",
"`x` ago": "`x` پیش",
"Load more": "بارگذاری بیشتر",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقطه ها",
- "": "`x` نقطه ها"
- },
+ "comments_points_count_0": "{{count}} نقطه ها",
"Could not create mix.": "نمیتوان میکس ساخت.",
- "Empty playlist": "لیست پخش خالی",
- "Not a playlist.": "یک لیست پخش نیست.",
- "Playlist does not exist.": "لیست پخش وجود ندارد.",
+ "Empty playlist": "سیاههٔ پخش خالی",
+ "Not a playlist.": "یک سیاههٔ پخش نیست.",
+ "Playlist does not exist.": "سیاههٔ پخش وجود ندارد.",
"Could not pull trending pages.": "نمیتوان صفحه های پر طرفدار را بکشد.",
"Hidden field \"challenge\" is a required field": "فیلد مخفی \"چالش\" یک فیلد ضروری است",
"Hidden field \"token\" is a required field": "فیلد مخفی \"توکن\" یک فیلد ضروری است",
@@ -340,42 +313,21 @@
"Yiddish": "ییدیش",
"Yoruba": "یوروبایی",
"Zulu": "زولو",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سال",
- "": "`x` سال"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ماه",
- "": "`x` ماه"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` هفته",
- "": "`x` هفته"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` روز",
- "": "`x` روز"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعت",
- "": "`x` ساعت"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقیقه",
- "": "`x` دقیقه"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثانیه",
- "": "`x` ثانیه"
- },
+ "generic_count_years_0": "{{count}} سال",
+ "generic_count_months_0": "{{count}} ماه",
+ "generic_count_weeks_0": "{{count}} هفته",
+ "generic_count_days_0": "{{count}} روز",
+ "generic_count_hours_0": "{{count}} ساعت",
+ "generic_count_minutes_0": "{{count}} دقیقه",
+ "generic_count_seconds_0": "{{count}} ثانیه",
"Fallback comments: ": "نظرات عقب گرد: ",
"Popular": "محبوب",
- "Search": "",
+ "Search": "جستجو",
"Top": "بالا",
"About": "درباره",
"Rating: ": "رتبه دهی: ",
- "Language: ": "زبان: ",
- "View as playlist": "نمایش به عنوان لیست پخش",
+ "preferences_locale_label": "زبان: ",
+ "View as playlist": "نمایش به عنوان سیاههٔ پخش",
"Default": "پیشفرض",
"Music": "موسیقی",
"Gaming": "بازی",
@@ -391,37 +343,74 @@
"Audio mode": "حالت صدا",
"Video mode": "حالت ویدیو",
"Videos": "ویدیو ها",
- "Playlists": "لیست های پخش",
+ "Playlists": "سیاهه‌های پخش",
"Community": "اجتماع",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
+ "relevance": "مرتبط بودن",
+ "rating": "امتیاز",
+ "date": "تاریخ بارگذاری",
+ "views": "تعداد بازدید",
+ "content_type": "نوع",
+ "duration": "مدت",
+ "features": "ویژگی‌ها",
+ "sort": "به ترتیب",
+ "hour": "یک ساعت گذشته",
+ "today": "امروز",
+ "week": "این هفته",
+ "month": "این ماه",
+ "year": "امسال",
+ "video": "ویدئو",
+ "channel": "کانال",
+ "playlist": "سیاههٔ پخش",
+ "movie": "فیلم",
+ "show": "نمایش",
+ "hd": "HD",
+ "subtitles": "زیرنویس",
+ "creative_commons": "کریتیو کامونز",
+ "3d": "سه‌بعدی",
+ "live": "زنده",
+ "4k": "4K",
+ "location": "مکان",
+ "hdr": "HDR",
+ "filter": "پالایه",
"Current version: ": "نسخه فعلی: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "اکنون بایستی یکی از این موارد را امتحان کنید: ",
+ "next_steps_error_message_refresh": "تازه‌سازی",
+ "next_steps_error_message_go_to_youtube": "رفتن به یوتیوب",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_dash": "DASH (کیفیت قابل تطبیق)",
+ "preferences_quality_option_medium": "میانه",
+ "preferences_quality_option_small": "پایین",
+ "preferences_quality_dash_option_auto": "خودکار",
+ "preferences_quality_dash_option_best": "بهترین",
+ "preferences_quality_dash_option_worst": "بدترین",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "اینویدیوس",
+ "360": "360°",
+ "footer_donate_page": "کمک مالی",
+ "footer_source_code": "کد منبع",
+ "footer_modfied_source_code": "کد منبع ویرایش شده",
+ "none": "هیچ‌کدام",
+ "videoinfo_started_streaming_x_ago": "پخش جریانی `x` پیش آغاز شد",
+ "videoinfo_watch_on_youTube": "تماشا در یوتیوب",
+ "videoinfo_youTube_embed_link": "توکار",
+ "videoinfo_invidious_embed_link": "پیوند توکار",
+ "download_subtitles": "زیرنویس‌ها - `x` (.vtt)",
+ "Video unavailable": "ویدئو دردسترس نیست",
+ "preferences_save_player_pos_label": "ذخیره زمان کنونی ویدئو: ",
+ "purchased": "خریداری شده",
+ "preferences_quality_dash_label": "کیفیت ترجیحی ویدئو DASH: ",
+ "preferences_region_label": "کشور محتوا: ",
+ "footer_documentation": "مستندات",
+ "footer_original_source_code": "کد منبع اصلی",
+ "long": "بلند (> 20 دقیقه)",
+ "adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده",
+ "short": "کوتاه (< 4 دقیقه)"
}
diff --git a/locales/fi.json b/locales/fi.json
index 6a830177..df7bb2be 100644
--- a/locales/fi.json
+++ b/locales/fi.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilaaja",
- "": "`x` tilaajaa"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` videota"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` soittolista",
- "": "`x` soittolistaa"
- },
"LIVE": "SUORA",
"Shared `x` ago": "Jaettu `x` sitten",
"Unsubscribe": "Peruuta tilaus",
@@ -60,39 +48,39 @@
"E-mail": "Sähköposti",
"Google verification code": "Google-vahvistuskoodi",
"Preferences": "Asetukset",
- "Player preferences": "Soittimen asetukset",
- "Always loop: ": "Aina silmukka: ",
- "Autoplay: ": "Automaattinen toisto: ",
- "Play next by default: ": "Toista seuraava oletuksena: ",
- "Autoplay next video: ": "Toista seuraava video automaattisesti: ",
- "Listen by default: ": "Kuuntele oletuksena: ",
- "Proxy videos: ": "Proxy videot: ",
- "Default speed: ": "Oletusnopeus: ",
- "Preferred video quality: ": "Ensisijainen videon laatu: ",
- "Player volume: ": "Soittimen äänenvoimakkuus: ",
- "Default comments: ": "Oletuskommentit: ",
+ "preferences_category_player": "Soittimen asetukset",
+ "preferences_video_loop_label": "Aina silmukka: ",
+ "preferences_autoplay_label": "Automaattinen toisto: ",
+ "preferences_continue_label": "Toista seuraava oletuksena: ",
+ "preferences_continue_autoplay_label": "Toista seuraava video automaattisesti: ",
+ "preferences_listen_label": "Kuuntele oletuksena: ",
+ "preferences_local_label": "Proxy videot: ",
+ "preferences_speed_label": "Oletusnopeus: ",
+ "preferences_quality_label": "Ensisijainen videon laatu: ",
+ "preferences_volume_label": "Soittimen äänenvoimakkuus: ",
+ "preferences_comments_label": "Oletuskommentit: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Tekstitykset: ",
+ "preferences_captions_label": "Tekstitykset: ",
"Fallback captions: ": "Toissijaiset tekstitykset: ",
- "Show related videos: ": "Näytä aiheeseen liittyviä videoita: ",
- "Show annotations by default: ": "Näytä huomautukset oletuksena: ",
- "Automatically extend video description: ": "Laajenna automaattisesti videon kuvausta: ",
- "Interactive 360 degree videos: ": "Interaktiiviset 360-asteiset videot: ",
- "Visual preferences": "Visuaaliset asetukset",
- "Player style: ": "Soittimen tyyli: ",
+ "preferences_related_videos_label": "Näytä aiheeseen liittyviä videoita: ",
+ "preferences_annotations_label": "Näytä huomautukset oletuksena: ",
+ "preferences_extend_desc_label": "Laajenna automaattisesti videon kuvausta: ",
+ "preferences_vr_mode_label": "Interaktiiviset 360-asteiset videot: ",
+ "preferences_category_visual": "Visuaaliset asetukset",
+ "preferences_player_style_label": "Soittimen tyyli: ",
"Dark mode: ": "Tumma tila: ",
- "Theme: ": "Teema: ",
+ "preferences_dark_mode_label": "Teema: ",
"dark": "tumma",
"light": "vaalea",
- "Thin mode: ": "Kapea tila ",
- "Miscellaneous preferences": "Sekalaiset asetukset",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ",
- "Subscription preferences": "Tilausten asetukset",
- "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ",
+ "preferences_thin_mode_label": "Kapea tila ",
+ "preferences_category_misc": "Sekalaiset asetukset",
+ "preferences_automatic_instance_redirect_label": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ",
+ "preferences_category_subscription": "Tilausten asetukset",
+ "preferences_annotations_subscribed_label": "Näytä oletuksena tilattujen kanavien huomautukset: ",
"Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ",
- "Number of videos shown in feed: ": "Syötteessä näytettävien videoiden määrä: ",
- "Sort videos by: ": "Videoiden lajitteluperuste: ",
+ "preferences_max_results_label": "Syötteessä näytettävien videoiden määrä: ",
+ "preferences_sort_label": "Videoiden lajitteluperuste: ",
"published": "julkaistu",
"published - reverse": "julkaistu - käänteinen",
"alphabetically": "aakkosjärjestys",
@@ -101,12 +89,12 @@
"channel name - reverse": "kanavan nimi - käänteinen",
"Only show latest video from channel: ": "Näytä vain uusin video kanavalta: ",
"Only show latest unwatched video from channel: ": "Näytä vain uusin katsomaton video kanavalta: ",
- "Only show unwatched: ": "Näytä vain katsomattomat: ",
- "Only show notifications (if there are any): ": "Näytä vain ilmoitukset (jos niitä on): ",
+ "preferences_unseen_only_label": "Näytä vain katsomattomat: ",
+ "preferences_notifications_only_label": "Näytä vain ilmoitukset (jos niitä on): ",
"Enable web notifications": "Näytä verkkoilmoitukset",
"`x` uploaded a video": "`x` latasi videon",
"`x` is live": "`x` lähettää suorana",
- "Data preferences": "Tietojen asetukset",
+ "preferences_category_data": "Tietojen asetukset",
"Clear watch history": "Tyhjennä katseluhistoria",
"Import/export data": "Tuo/vie tiedot",
"Change password": "Vaihda salasana",
@@ -114,10 +102,10 @@
"Manage tokens": "Hallinnoi tunnuksia",
"Watch history": "Katseluhistoria",
"Delete account": "Poista tili",
- "Administrator preferences": "Järjestelmänvalvojan asetukset",
- "Default homepage: ": "Oletuskotisivu: ",
- "Feed menu: ": "Syötevalikko: ",
- "Show nickname on top: ": "Näytä nimimerkki ylimpänä: ",
+ "preferences_category_admin": "Järjestelmänvalvojan asetukset",
+ "preferences_default_home_label": "Oletuskotisivu: ",
+ "preferences_feed_menu_label": "Syötevalikko: ",
+ "preferences_show_nick_label": "Näytä nimimerkki ylimpänä: ",
"Top enabled: ": "Yläosa käytössä: ",
"CAPTCHA enabled: ": "CAPTCHA käytössä: ",
"Login enabled: ": "Kirjautuminen käytössä: ",
@@ -127,25 +115,12 @@
"Subscription manager": "Tilausten hallinnoija",
"Token manager": "Tunnusten hallinnoija",
"Token": "Tunnus",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilausta",
- "": "`x` tilausta"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tunnistetta",
- "": "`x` tunnistetta"
- },
"Import/export": "Tuo/vie",
"unsubscribe": "peru tilaus",
"revoke": "kumoa",
"Subscriptions": "Tilaukset",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` näkemätöntä ilmoitusta",
- "": "`x` näkemätöntä ilmoitusta"
- },
"search": "haku",
"Log out": "Kirjaudu ulos",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Lähdekoodi on saatavilla täällä.",
"View JavaScript license information.": "JavaScript-koodin lisenssit.",
"View privacy policy.": "Katso tietosuojaseloste.",
@@ -176,10 +151,6 @@
"Whitelisted regions: ": "Sallitut alueet: ",
"Blacklisted regions: ": "Estetyt alueet: ",
"Shared `x`": "Jaettu `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` katselukerta",
- "": "`x` katselukertaa"
- },
"Premieres in `x`": "Ensiesitykseen aikaa `x`",
"Premieres `x`": "Ensiesitykseen `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.": "Hei! Vaikuttaa siltä, että sinulla on JavaScript pois käytöstä. Klikkaa tästä nähdäksesi kommentit, huomioi että lataamisessa voi kestää melko kauan.",
@@ -213,16 +184,8 @@
"This channel does not exist.": "Tätä kanavaa ei ole olemassa.",
"Could not get channel info.": "Kanavatietoa ei saatu ladattua.",
"Could not fetch comments": "Kommenttien nouto epäonnistui",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Näytä `x` vastausta",
- "": "Näytä `x` vastausta"
- },
"`x` ago": "`x` sitten",
"Load more": "Lataa lisää",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pistettä",
- "": "`x` pistettä"
- },
"Could not create mix.": "Sekoituksen luominen epäonnistui.",
"Empty playlist": "Tyhjennä soittolista",
"Not a playlist.": "Ei ole soittolista.",
@@ -340,41 +303,13 @@
"Yiddish": "jiddiš",
"Yoruba": "joruba",
"Zulu": "zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vuotta",
- "": "`x` vuotta"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` kuukautta",
- "": "`x` kuukautta"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` viikkoa",
- "": "`x` viikkoa"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` päivää",
- "": "`x` päivää"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tuntia",
- "": "`x` tuntia"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuuttia",
- "": "`x` minuuttia"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekuntia",
- "": "`x` sekuntia"
- },
"Fallback comments: ": "Varakommentit: ",
"Popular": "Suosittu",
"Search": "Etsi",
"Top": "Ylin",
"About": "Tietoa",
"Rating: ": "Arvosana: ",
- "Language: ": "Kieli: ",
+ "preferences_locale_label": "Kieli: ",
"View as playlist": "Näytä soittolistana",
"Default": "Oletus",
"Music": "Musiikki",
diff --git a/locales/fr.json b/locales/fr.json
index a7fe004d..8593feb1 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1,16 +1,14 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonné",
- "": "`x` abonnés"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vidéo",
- "": "`x` vidéos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` liste de lecture",
- "": "`x` listes de lecture"
- },
+ "generic_views_count": "{{count}} vue",
+ "generic_views_count_plural": "{{count}} vues",
+ "generic_videos_count": "{{count}} vidéo",
+ "generic_videos_count_plural": "{{count}} vidéos",
+ "generic_playlists_count": "{{count}} liste de lecture",
+ "generic_playlists_count_plural": "{{count}} listes de lecture",
+ "generic_subscribers_count": "{{count}} abonné",
+ "generic_subscribers_count_plural": "{{count}} abonnés",
+ "generic_subscriptions_count": "{{count}} abonnement",
+ "generic_subscriptions_count_plural": "{{count}} abonnements",
"LIVE": "EN DIRECT",
"Shared `x` ago": "Ajoutée il y a `x`",
"Unsubscribe": "Se désabonner",
@@ -60,39 +58,39 @@
"E-mail": "E-mail",
"Google verification code": "Code de vérification Google",
"Preferences": "Préférences",
- "Player preferences": "Préférences du lecteur",
- "Always loop: ": "Lire en boucle : ",
- "Autoplay: ": "Lancer la lecture automatiquement : ",
- "Play next by default: ": "Lire les vidéos suivantes par défaut : ",
- "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
- "Listen by default: ": "Audio uniquement : ",
- "Proxy videos: ": "Charger les vidéos à travers un proxy : ",
- "Default speed: ": "Vitesse par défaut : ",
- "Preferred video quality: ": "Qualité vidéo souhaitée : ",
- "Player volume: ": "Volume du lecteur : ",
- "Default comments: ": "Source des commentaires : ",
+ "preferences_category_player": "Préférences du lecteur",
+ "preferences_video_loop_label": "Lire en boucle : ",
+ "preferences_autoplay_label": "Lancer la lecture automatiquement : ",
+ "preferences_continue_label": "Lire les vidéos suivantes par défaut : ",
+ "preferences_continue_autoplay_label": "Lire automatiquement la vidéo suivante : ",
+ "preferences_listen_label": "Audio uniquement : ",
+ "preferences_local_label": "Charger les vidéos à travers un proxy : ",
+ "preferences_speed_label": "Vitesse par défaut : ",
+ "preferences_quality_label": "Qualité vidéo souhaitée : ",
+ "preferences_volume_label": "Volume du lecteur : ",
+ "preferences_comments_label": "Source des commentaires : ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Sous-titres par défaut : ",
+ "preferences_captions_label": "Sous-titres par défaut : ",
"Fallback captions: ": "Sous-titres alternatifs : ",
- "Show related videos: ": "Voir les vidéos liées : ",
- "Show annotations by default: ": "Afficher les annotations par défaut : ",
- "Automatically extend video description: ": "Etendre automatiquement la description : ",
- "Interactive 360 degree videos: ": "Vidéos interactives à 360° : ",
- "Visual preferences": "Préférences du site",
- "Player style: ": "Style du lecteur : ",
+ "preferences_related_videos_label": "Voir les vidéos liées : ",
+ "preferences_annotations_label": "Afficher les annotations par défaut : ",
+ "preferences_extend_desc_label": "Etendre automatiquement la description : ",
+ "preferences_vr_mode_label": "Vidéos interactives à 360° : ",
+ "preferences_category_visual": "Préférences du site",
+ "preferences_player_style_label": "Style du lecteur : ",
"Dark mode: ": "Mode sombre : ",
- "Theme: ": "Thème : ",
+ "preferences_dark_mode_label": "Thème : ",
"dark": "sombre",
"light": "clair",
- "Thin mode: ": "Mode léger : ",
- "Miscellaneous preferences": "Paramètres divers",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ",
- "Subscription preferences": "Préférences des abonnements",
- "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ",
+ "preferences_thin_mode_label": "Mode léger : ",
+ "preferences_category_misc": "Paramètres divers",
+ "preferences_automatic_instance_redirect_label": "Redirection automatique vers une autre instance (via redirect.invidious.io) : ",
+ "preferences_category_subscription": "Préférences des abonnements",
+ "preferences_annotations_subscribed_label": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ",
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
- "Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ",
- "Sort videos by: ": "Trier les vidéos par : ",
+ "preferences_max_results_label": "Nombre de vidéos affichées dans la page d'abonnements : ",
+ "preferences_sort_label": "Trier les vidéos par : ",
"published": "date de publication",
"published - reverse": "date de publication - inversé",
"alphabetically": "ordre alphabétique",
@@ -101,12 +99,12 @@
"channel name - reverse": "nom de la chaîne - inversé",
"Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés : ",
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui n'a pas été regardée : ",
- "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas été regardées : ",
- "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
+ "preferences_unseen_only_label": "Afficher uniquement les vidéos qui n'ont pas été regardées : ",
+ "preferences_notifications_only_label": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "Activer les notifications web",
"`x` uploaded a video": "`x` a partagé une vidéo",
"`x` is live": "`x` est en direct",
- "Data preferences": "Préférences liées aux données",
+ "preferences_category_data": "Préférences liées aux données",
"Clear watch history": "Supprimer l'historique des vidéos regardées",
"Import/export data": "Importer/exporter les données",
"Change password": "Modifier le mot de passe",
@@ -114,10 +112,10 @@
"Manage tokens": "Gérer les tokens",
"Watch history": "Historique de visionnage",
"Delete account": "Supprimer votre compte",
- "Administrator preferences": "Préferences d'Administration",
- "Default homepage: ": "Page d'accueil par défaut : ",
- "Feed menu: ": "Préferences des abonnements : ",
- "Show nickname on top: ": "Afficher le nom d'utilisateur en haut à droite : ",
+ "preferences_category_admin": "Préferences d'Administration",
+ "preferences_default_home_label": "Page d'accueil par défaut : ",
+ "preferences_feed_menu_label": "Préferences des abonnements : ",
+ "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ",
"Top enabled: ": "Top activé : ",
"CAPTCHA enabled: ": "CAPTCHA activé : ",
"Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ",
@@ -127,22 +125,14 @@
"Subscription manager": "Gestionnaire d'abonnement",
"Token manager": "Gestionnaire de token",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnements",
- "": "`x` abonnements"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` tokens"
- },
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} tokens",
"Import/export": "Importer/Exporter",
"unsubscribe": "se désabonner",
"revoke": "révoquer",
"Subscriptions": "Abonnements",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notification non vue",
- "": "`x` notifications non vues"
- },
+ "subscriptions_unseen_notifs_count": "{{count}} notification non vue",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} notifications non vues",
"search": "rechercher",
"Log out": "Se déconnecter",
"Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 sur Github.",
@@ -160,7 +150,7 @@
"Create playlist": "Créer une liste de lecture",
"Title": "Titre",
"Playlist privacy": "Paramètres de confidentialité de la liste de lecture",
- "Editing playlist `x`": "Liste de lecture modifier le `x`",
+ "Editing playlist `x`": "Modifier la liste de lecture `x`",
"Show more": "Afficher plus",
"Show less": "Afficher moins",
"Watch on YouTube": "Voir la vidéo sur Youtube",
@@ -176,10 +166,6 @@
"Whitelisted regions: ": "Régions sur liste blanche : ",
"Blacklisted regions: ": "Régions sur liste noire : ",
"Shared `x`": "Ajoutée le `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vues",
- "": "`x` vues"
- },
"Premieres in `x`": "Première dans `x`",
"Premieres `x`": "Première le `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.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires, mais gardez à l'esprit que le chargement peut prendre plus de temps.",
@@ -213,16 +199,12 @@
"This channel does not exist.": "Cette chaine n'existe pas.",
"Could not get channel info.": "Impossible de charger les informations de cette chaîne.",
"Could not fetch comments": "Impossible de charger les commentaires",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Voir `x` réponse",
- "": "Voir `x` réponses"
- },
+ "comments_view_x_replies": "Voir {{count}} réponse",
+ "comments_view_x_replies_plural": "Voir {{count}} réponses",
"`x` ago": "il y a `x`",
"Load more": "Voir plus",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point",
- "": "`x` points"
- },
+ "comments_points_count": "{{count}} point",
+ "comments_points_count_plural": "{{count}} points",
"Could not create mix.": "Impossible de charger cette liste de lecture.",
"Empty playlist": "La liste de lecture est vide",
"Not a playlist.": "La liste de lecture est invalide.",
@@ -340,41 +322,27 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zoulou",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` an",
- "": "`x` ans"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mois",
- "": "`x` mois"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semaine",
- "": "`x` semaines"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jour",
- "": "`x` jours"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` heure",
- "": "`x` heures"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute",
- "": "`x` minutes"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconde",
- "": "`x` secondes"
- },
+ "generic_count_years": "{{count}} an",
+ "generic_count_years_plural": "{{count}} ans",
+ "generic_count_months": "{{count}} mois",
+ "generic_count_months_plural": "{{count}} mois",
+ "generic_count_weeks": "{{count}} semaine",
+ "generic_count_weeks_plural": "{{count}} semaines",
+ "generic_count_days": "{{count}} jour",
+ "generic_count_days_plural": "{{count}} jours",
+ "generic_count_hours": "{{count}} heure",
+ "generic_count_hours_plural": "{{count}} heures",
+ "generic_count_minutes": "{{count}} minute",
+ "generic_count_minutes_plural": "{{count}} minutes",
+ "generic_count_seconds": "{{count}} seconde",
+ "generic_count_seconds_plural": "{{count}} secondes",
"Fallback comments: ": "Commentaires alternatifs : ",
"Popular": "Populaire",
"Search": "Rechercher",
"Top": "Top",
"About": "À propos",
"Rating: ": "Évaluation : ",
- "Language: ": "Langue : ",
+ "preferences_locale_label": "Langue : ",
"View as playlist": "Voir en tant que liste de lecture",
"Default": "Défaut",
"Music": "Musique",
@@ -423,5 +391,51 @@
"Current version: ": "Version actuelle : ",
"next_steps_error_message": "Vous pouvez essayer de : ",
"next_steps_error_message_refresh": "Rafraîchir la page",
- "next_steps_error_message_go_to_youtube": "Aller sur YouTube"
+ "next_steps_error_message_go_to_youtube": "Aller sur YouTube",
+ "preferences_quality_dash_label": "Qualité vidéo DASH préférée : ",
+ "footer_source_code": "Code source",
+ "preferences_region_label": "Pays du contenu : ",
+ "footer_donate_page": "Faire un don",
+ "footer_modfied_source_code": "Code source modifié",
+ "short": "Courte (< 4 minutes)",
+ "long": "Longue (> 20 minutes)",
+ "adminprefs_modified_source_code_url_label": "URL du dépôt du code source modifié",
+ "footer_documentation": "Documentation",
+ "footer_original_source_code": "Code source original",
+ "preferences_quality_option_medium": "Moyenne",
+ "preferences_quality_option_small": "Petite",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "La plus haute",
+ "preferences_quality_dash_option_worst": "La plus basse",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "360": "360°",
+ "none": "aucun",
+ "videoinfo_started_streaming_x_ago": "En stream depuis `x`",
+ "videoinfo_watch_on_youTube": "Regarder sur YouTube",
+ "videoinfo_youTube_embed_link": "Intégrer",
+ "purchased": "Acheter",
+ "videoinfo_invidious_embed_link": "Lien intégré",
+ "download_subtitles": "Sous-titres - `x` (.vtt)",
+ "user_saved_playlists": "`x` listes de lecture sauvegardées",
+ "Video unavailable": "Vidéo non disponible",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_dash": "DASH (qualité adaptative)",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "user_created_playlists": "`x` listes de lecture créées",
+ "preferences_save_player_pos_label": "Sauvegarder la position du lecteur : ",
+ "crash_page_you_found_a_bug": "Il semblerait que vous ayez trouvé un bug dans Invidious !",
+ "crash_page_refresh": "tenté de <a href=\"`x`\">rafraîchir la page</a>",
+ "crash_page_switch_instance": "essayé d'<a href=\"`x`\">utiliser une autre instance</a>",
+ "crash_page_read_the_faq": "lu la <a href=\"`x`\">Foire Aux Questions (FAQ)</a>",
+ "crash_page_search_issue": "<a href=\"`x`\">cherché ce bug sur Github</a>",
+ "crash_page_before_reporting": "Avant de signaler un bug, veuillez vous assurez que vous avez :",
+ "crash_page_report_issue": "Si aucune des solutions proposées ci-dessus ne vous a aidé, veuillez <a href=\"`x`\">ouvrir une \"issue\" sur GitHub</a> (de préférence en anglais) et d'y inclure le message suivant (ne PAS traduire le texte) :"
}
diff --git a/locales/he.json b/locales/he.json
index 6778e4dd..2c9258b9 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` רשומים",
- "": "`x` רשומים"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` סרטונים",
- "": "`x` סרטונים"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` פלייליסטים",
- "": "`x` פלייליסטים"
- },
"LIVE": "שידור חי",
"Shared `x` ago": "שותף לפני `x`",
"Unsubscribe": "ביטול מינוי",
@@ -60,53 +48,43 @@
"E-mail": "דוא״ל",
"Google verification code": "קוד האימות של Google",
"Preferences": "העדפות",
- "Player preferences": "העדפות הנגן",
- "Always loop: ": "",
- "Autoplay: ": "ניגון אוטומטי: ",
- "Play next by default: ": "ניגון הסרטון הבא כברירת מחדל: ",
- "Autoplay next video: ": "ניגון הסרטון הבא באופן אוטומטי: ",
- "Listen by default: ": "שמע כברירת מחדל: ",
- "Proxy videos: ": "",
- "Default speed: ": "מהירות ברירת המחדל: ",
- "Preferred video quality: ": "איכות הווידאו המועדפת: ",
- "Player volume: ": "עצמת השמע של הנגן: ",
- "Default comments: ": "תגובות ברירת מחדל ",
+ "preferences_category_player": "העדפות הנגן",
+ "preferences_autoplay_label": "ניגון אוטומטי: ",
+ "preferences_continue_label": "ניגון הסרטון הבא כברירת מחדל: ",
+ "preferences_continue_autoplay_label": "ניגון הסרטון הבא באופן אוטומטי: ",
+ "preferences_listen_label": "שמע כברירת מחדל: ",
+ "preferences_speed_label": "מהירות ברירת המחדל: ",
+ "preferences_quality_label": "איכות הווידאו המועדפת: ",
+ "preferences_volume_label": "עצמת השמע של הנגן: ",
+ "preferences_comments_label": "תגובות ברירת מחדל ",
"youtube": "יוטיוב",
"reddit": "reddit",
- "Default captions: ": "כתוביות ברירת מחדל ",
+ "preferences_captions_label": "כתוביות ברירת מחדל ",
"Fallback captions: ": "כתוביות גיבוי ",
- "Show related videos: ": "הצגת סרטונים קשורים: ",
- "Show annotations by default: ": "הצגת הערות כברירת מחדל: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "העדפות חזותיות",
- "Player style: ": "סגנון הנגן: ",
+ "preferences_related_videos_label": "הצגת סרטונים קשורים: ",
+ "preferences_annotations_label": "הצגת הערות כברירת מחדל: ",
+ "preferences_category_visual": "העדפות חזותיות",
+ "preferences_player_style_label": "סגנון הנגן: ",
"Dark mode: ": "מצב כהה: ",
- "Theme: ": "ערכת נושא: ",
+ "preferences_dark_mode_label": "ערכת נושא: ",
"dark": "כהה",
"light": "בהיר",
- "Thin mode: ": "",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "העדפות מינויים",
- "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",
- "Redirect homepage to feed: ": "",
- "Number of videos shown in feed: ": "מספר הסרטונים שמוצגים בהזנה: ",
- "Sort videos by: ": "מיון הסרטונים לפי: ",
+ "preferences_category_subscription": "העדפות מינויים",
+ "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ",
+ "preferences_max_results_label": "מספר הסרטונים שמוצגים בהזנה: ",
+ "preferences_sort_label": "מיון הסרטונים לפי: ",
"published": "פורסם",
- "published - reverse": "",
"alphabetically": "בסדר אלפביתי",
"alphabetically - reverse": "בסדר אלפביתי - הפוך",
"channel name": "שם הערוץ",
"channel name - reverse": "שם הערוץ - הפוך",
"Only show latest video from channel: ": "הצגת הסרטון האחרון מהערוץ בלבד: ",
"Only show latest unwatched video from channel: ": "הצגת הסרטון האחרון שלא נצפה מהערוץ בלבד: ",
- "Only show unwatched: ": "הצגת סרטונים שלא נצפו בלבד: ",
- "Only show notifications (if there are any): ": "הצגת התראות בלבד (אם ישנן): ",
- "Enable web notifications": "",
+ "preferences_unseen_only_label": "הצגת סרטונים שלא נצפו בלבד: ",
+ "preferences_notifications_only_label": "הצגת התראות בלבד (אם ישנן): ",
"`x` uploaded a video": "סרטון הועלה על ידי `x`",
"`x` is live": "`x` בשידור חי",
- "Data preferences": "העדפות נתונים",
+ "preferences_category_data": "העדפות נתונים",
"Clear watch history": "ניקוי היסטוריית הצפייה",
"Import/export data": "ייבוא/ייצוא נתונים",
"Change password": "שינוי הסיסמה",
@@ -114,40 +92,19 @@
"Manage tokens": "ניהול אסימונים",
"Watch history": "היסטוריית צפייה",
"Delete account": "מחיקת החשבון",
- "Administrator preferences": "הגדרות ניהול מערכת",
- "Default homepage: ": "Default homepage: ",
- "Feed menu: ": "תפריט ההזנה: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
+ "preferences_category_admin": "הגדרות ניהול מערכת",
+ "preferences_default_home_label": "Default homepage: ",
+ "preferences_feed_menu_label": "תפריט ההזנה: ",
"Save preferences": "שמירת ההעדפות",
"Subscription manager": "מנהל המינויים",
"Token manager": "Token manager",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` מינויים",
- "": "`x` מינויים"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"Import/export": "ייבוא/ייצוא",
"unsubscribe": "ביטול מנוי",
- "revoke": "",
"Subscriptions": "מינויים",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` הודעות שלא נראו",
- "": "`x` הודעות שלא נראו"
- },
"search": "חיפוש",
"Log out": "יציאה",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "קוד המקור זמין כאן.",
- "View JavaScript license information.": "",
"View privacy policy.": "להצגת מדיניות הפרטיות.",
"Trending": "הסרטונים החמים",
"Public": "ציבורי",
@@ -158,30 +115,12 @@
"Delete playlist `x`?": "למחוק את פלייליסט `x`?",
"Delete playlist": "מחיקת פלייליסט",
"Create playlist": "יצירת פלייליסט",
- "Title": "",
"Playlist privacy": "Playlist privacy",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "צפייה ב־YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
"Genre: ": "Genre: ",
"License: ": "רישיון: ",
"Family friendly? ": "לכל המשפחה? ",
"Wilson score: ": "ציון וילסון: ",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` צפיות.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` צפיות"
- },
- "Premieres in `x`": "",
- "Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "שלום! נראה ש־JavaScript כבוי. יש ללחוץ כאן להצגת התגובות, נא לקחת בחשבון שהטעינה תיקח קצת יותר זמן.",
"View YouTube comments": "הצגת התגובות מ־YouTube",
"View more comments on Reddit": "להצגת תגובות נוספות ב־Reddit",
@@ -193,45 +132,24 @@
"Hide replies": "הסתרת תגובות",
"Show replies": "הצגת תגובות",
"Incorrect password": "סיסמה שגויה",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
"Wrong answer": "תשובה שגויה",
- "Erroneous CAPTCHA": "",
"CAPTCHA is a required field": "שדה CAPTCHA הוא שדה חובה",
"User ID is a required field": "חובה למלא את שדה שם המשתמש",
"Password is a required field": "חובה למלא את שדה הסיסמה",
"Wrong username or password": "שם משתמש שגוי או סיסמה שגויה",
"Please sign in using 'Log in with Google'": "נא להתחבר בעזרת \"התחברות עם Google\"",
- "Password cannot be empty": "",
"Password cannot be longer than 55 characters": "על אורך הסיסמה להיות 55 תווים לכל היותר",
"Please log in": "נא להתחבר",
- "Invidious Private Feed for `x`": "",
"channel:`x`": "ערוץ:`x`",
"Deleted or invalid channel": "הערוץ נמחק או שאינו תקין",
"This channel does not exist.": "הערוץ הזה אינו קיים.",
"Could not get channel info.": "לא היה ניתן לקבל מידע על הערוץ.",
"Could not fetch comments": "לא היה ניתן למשוך את התגובות",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות",
- "": "הצגת `x` תגובות"
- },
"`x` ago": "לפני `x`",
"Load more": "לטעון עוד",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
"Empty playlist": "פלייליסט ריק",
"Not a playlist.": "לא פלייליסט.",
"Playlist does not exist.": "הפלייליסט אינו קיים.",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
"No such user": "אין משתמש כזה",
"Token is expired, please try again": "תוקף האסימון פג, נא לנסות שוב",
"English": "אנגלית",
@@ -302,7 +220,6 @@
"Mongolian": "מונגולית",
"Nepali": "נפאלית",
"Norwegian Bokmål": "Norwegian Bokmål",
- "Nyanja": "",
"Pashto": "פשטו",
"Persian": "פרסית",
"Polish": "פולנית",
@@ -313,16 +230,13 @@
"Samoan": "סמואית",
"Scottish Gaelic": "גאלית סקוטית",
"Serbian": "Serbian",
- "Shona": "",
"Sindhi": "סינדהי",
"Sinhala": "סינהלית",
"Slovak": "Slovak",
"Slovenian": "Slovenian",
"Somali": "סומלית",
- "Southern Sotho": "",
"Spanish": "ספרדית",
"Spanish (Latin America)": "ספרדית (אמריקה הלטינית)",
- "Sundanese": "",
"Swahili": "סווהילי",
"Swedish": "שוודית",
"Tajik": "טג׳יקית",
@@ -335,46 +249,15 @@
"Uzbek": "אוזבקית",
"Vietnamese": "וייטנאמית",
"Welsh": "ולשית",
- "Western Frisian": "",
"Xhosa": "קוסה",
"Yiddish": "יידיש",
"Yoruba": "יורובה",
"Zulu": "זולו",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים",
- "": "`x` שנים"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים",
- "": "`x` חודשים"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות",
- "": "`x` שבועות"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים",
- "": "`x` ימים"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות",
- "": "`x` שעות"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות",
- "": "`x` דקות"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות",
- "": "`x` שניות"
- },
- "Fallback comments: ": "",
"Popular": "סרטונים פופולריים",
- "Search": "",
"Top": "Top",
"About": "על אודות",
"Rating: ": "דירוג: ",
- "Language: ": "שפה: ",
+ "preferences_locale_label": "שפה: ",
"View as playlist": "הצגה כפלייליסט",
"Default": "ברירת מחדל",
"Music": "מוזיקה",
@@ -385,8 +268,6 @@
"Download as: ": "הורדה בתור: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(לאחר עריכה)",
- "YouTube comment permalink": "",
- "permalink": "",
"`x` marked it with a ❤": "סומנה ב־❤ על ידי `x`",
"Audio mode": "Audio mode",
"Video mode": "Video mode",
@@ -420,8 +301,5 @@
"location": "מיקום",
"hdr": "HDR",
"filter": "סינון",
- "Current version: ": "הגרסה הנוכחית: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "הגרסה הנוכחית: "
}
diff --git a/locales/hr.json b/locales/hr.json
index dd6d14a9..5770041e 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -1,22 +1,10 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplatnika",
- "": "`x` pretplatnika"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videa",
- "": "`x` videa"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playliste",
- "": "`x` playliste"
- },
"LIVE": "UŽIVO",
"Shared `x` ago": "Dijeljeno prije `x`",
"Unsubscribe": "Odjavi pretplatu",
"Subscribe": "Pretplati se",
"View channel on YouTube": "Prikaži kanal na YouTubeu",
- "View playlist on YouTube": "Prikaži playlistu na YouTubeu",
+ "View playlist on YouTube": "Prikaži zbirku na YouTubeu",
"newest": "najnovije",
"oldest": "najstarije",
"popular": "popularni",
@@ -60,39 +48,39 @@
"E-mail": "E-mail",
"Google verification code": "Googleov potvrdni kod",
"Preferences": "Postavke",
- "Player preferences": "Postavke playera",
- "Always loop: ": "Uvijek ponavljaj: ",
- "Autoplay: ": "Automatski reproduciraj: ",
- "Play next by default: ": "Standardno reproduciraj sljedeći: ",
- "Autoplay next video: ": "Automatski reproduciraj sljedeći video: ",
- "Listen by default: ": "Standardno slušaj: ",
- "Proxy videos: ": "Koristi posrednika videa: ",
- "Default speed: ": "Standardna brzina: ",
- "Preferred video quality: ": "Primarna kvaliteta videa: ",
- "Player volume: ": "Glasnoća playera: ",
- "Default comments: ": "Standardni komentari: ",
+ "preferences_category_player": "Postavke playera",
+ "preferences_video_loop_label": "Uvijek ponavljaj: ",
+ "preferences_autoplay_label": "Automatski reproduciraj: ",
+ "preferences_continue_label": "Standardno reproduciraj sljedeći: ",
+ "preferences_continue_autoplay_label": "Automatski reproduciraj sljedeći video: ",
+ "preferences_listen_label": "Standardno slušaj: ",
+ "preferences_local_label": "Koristi posrednika videa: ",
+ "preferences_speed_label": "Standardna brzina: ",
+ "preferences_quality_label": "Preferirana kvaliteta videa: ",
+ "preferences_volume_label": "Glasnoća playera: ",
+ "preferences_comments_label": "Standardni komentari: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Standardni titlovi: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Standardni titlovi: ",
"Fallback captions: ": "Alternativni titlovi: ",
- "Show related videos: ": "Prikaži povezana videa: ",
- "Show annotations by default: ": "Standardno prikaži napomene: ",
- "Automatically extend video description: ": "Automatski proširi opis videa: ",
- "Interactive 360 degree videos: ": "Interaktivna videa od 360 stupnjeva: ",
- "Visual preferences": "Postavke prikaza",
- "Player style: ": "Stil playera: ",
+ "preferences_related_videos_label": "Prikaži povezana videa: ",
+ "preferences_annotations_label": "Standardno prikaži napomene: ",
+ "preferences_extend_desc_label": "Automatski proširi opis videa: ",
+ "preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva: ",
+ "preferences_category_visual": "Postavke prikaza",
+ "preferences_player_style_label": "Stil playera: ",
"Dark mode: ": "Tamni modus: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "tamno",
"light": "svijetlo",
- "Thin mode: ": "Pojednostavljen prikaz: ",
- "Miscellaneous preferences": "Razne postavke",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ",
- "Subscription preferences": "Postavke pretplata",
- "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ",
+ "preferences_thin_mode_label": "Pojednostavljen prikaz: ",
+ "preferences_category_misc": "Razne postavke",
+ "preferences_automatic_instance_redirect_label": "Automatsko preusmjeravanje instance (u krajnjem slučaju će se koristiti redirect.invidious.io): ",
+ "preferences_category_subscription": "Postavke pretplata",
+ "preferences_annotations_subscribed_label": "Standardno prikaži napomene za pretplaćene kanale: ",
"Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ",
- "Number of videos shown in feed: ": "Broj prikazanih videa u feedu: ",
- "Sort videos by: ": "Razvrstaj videa prema: ",
+ "preferences_max_results_label": "Broj prikazanih videa u feedu: ",
+ "preferences_sort_label": "Razvrstaj videa prema: ",
"published": "objavljeno",
"published - reverse": "objavljeno – obrnuto",
"alphabetically": "abecednim redom",
@@ -101,12 +89,12 @@
"channel name - reverse": "ime kanala – obrnuto",
"Only show latest video from channel: ": "Prikaži samo najnovija videa kanala: ",
"Only show latest unwatched video from channel: ": "Prikaži samo najnovija nepogledana videa kanala: ",
- "Only show unwatched: ": "Prikaži samo nepogledane: ",
- "Only show notifications (if there are any): ": "Prikaži samo obavijesti (ako ih ima): ",
+ "preferences_unseen_only_label": "Prikaži samo nepogledane: ",
+ "preferences_notifications_only_label": "Prikaži samo obavijesti (ako ih ima): ",
"Enable web notifications": "Aktiviraj web-obavijesti",
"`x` uploaded a video": "`x` je poslao/la video",
"`x` is live": "`x` je uživo",
- "Data preferences": "Postavke podataka",
+ "preferences_category_data": "Postavke podataka",
"Clear watch history": "Izbriši povijest gledanja",
"Import/export data": "Uvezi/izvezi podatke",
"Change password": "Promijeni lozinku",
@@ -114,10 +102,10 @@
"Manage tokens": "Upravljaj tokenima",
"Watch history": "Povijest gledanja",
"Delete account": "Izbriši račun",
- "Administrator preferences": "Postavke administratora",
- "Default homepage: ": "Standardna početna stranica: ",
- "Feed menu: ": "Izbornik za feedove: ",
- "Show nickname on top: ": "Prikaži nadimak na vrhu: ",
+ "preferences_category_admin": "Postavke administratora",
+ "preferences_default_home_label": "Standardna početna stranica: ",
+ "preferences_feed_menu_label": "Izbornik za feedove: ",
+ "preferences_show_nick_label": "Prikaži nadimak na vrhu: ",
"Top enabled: ": "Najbolji aktivirani: ",
"CAPTCHA enabled: ": "Aktivirani CAPTCHA: ",
"Login enabled: ": "Prijava aktivirana: ",
@@ -127,25 +115,13 @@
"Subscription manager": "Upravljanje pretplatama",
"Token manager": "Upravljanje tokenima",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplate",
- "": "`x` pretplate"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokena",
- "": "`x` tokena"
- },
"Import/export": "Uvezi/izvezi",
"unsubscribe": "odjavi pretplatu",
"revoke": "opozovi",
"Subscriptions": "Pretplate",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` neviđene obavijesti",
- "": "`x` neviđene obavijesti"
- },
"search": "traži",
"Log out": "Odjavi se",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Izdano pod licencom AGPLv3 na Github-u.",
"Source available here.": "Izvor je ovdje dostupan.",
"View JavaScript license information.": "Prikaži informacije o JavaScript licenci.",
"View privacy policy.": "Prikaži politiku privatnosti.",
@@ -153,14 +129,14 @@
"Public": "Javno",
"Unlisted": "Nenavedeno",
"Private": "Privatno",
- "View all playlists": "Prikaži sve playliste",
+ "View all playlists": "Prikaži sve zbirke",
"Updated `x` ago": "Aktualizirano prije `x`",
- "Delete playlist `x`?": "Izbrisati playlistu `x`?",
- "Delete playlist": "Izbriši playlistu",
- "Create playlist": "Stvori playlistu",
+ "Delete playlist `x`?": "Izbrisati zbirku `x`?",
+ "Delete playlist": "Izbriši zbirku",
+ "Create playlist": "Stvori zbirku",
"Title": "Naslov",
- "Playlist privacy": "Privatnost playliste",
- "Editing playlist `x`": "Uređivanje playliste `x`",
+ "Playlist privacy": "Privatnost zbirke",
+ "Editing playlist `x`": "Uređivanje zbirke `x`",
"Show more": "Pokaži više",
"Show less": "Pokaži manje",
"Watch on YouTube": "Gledaj na YouTubeu",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Odobrene regije: ",
"Blacklisted regions: ": "Blokirane regije: ",
"Shared `x`": "Dijeljeno `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gledanja.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` gledanja"
- },
"Premieres in `x`": "Premijera za `x`",
"Premieres `x`": "Premijera `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.": "Bok! Izgleda da je JavaScript isključen. Pritisni ovdje za prikaz komentara. Učitavanje će možda trajati malo duže.",
@@ -213,20 +185,12 @@
"This channel does not exist.": "Ovaj kanal ne postoji.",
"Could not get channel info.": "Neuspjelo dobivanje podataka kanala.",
"Could not fetch comments": "Neuspjelo dohvaćanje komentara",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora",
- "": "Prikaži `x` odgovora"
- },
"`x` ago": "prije `x`",
"Load more": "Učitaj više",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova",
- "": "`x` bodova"
- },
"Could not create mix.": "Neuspjelo stvaranje miksa.",
- "Empty playlist": "Prazna playlista",
- "Not a playlist.": "Nije playlista.",
- "Playlist does not exist.": "Playlista ne postoji.",
+ "Empty playlist": "Prazna zbirka",
+ "Not a playlist.": "Nije zbirka.",
+ "Playlist does not exist.": "Zbirka ne postoji.",
"Could not pull trending pages.": "Neuspjelo preuzimanje stranica u trendu.",
"Hidden field \"challenge\" is a required field": "Skriveno polje „izazov” je obavezno polje",
"Hidden field \"token\" is a required field": "Skriveno polje „token” je obavezno polje",
@@ -340,42 +304,14 @@
"Yiddish": "Jidiš",
"Yoruba": "Jorubški",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g",
- "": "`x` g"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj",
- "": "`x` mj"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj",
- "": "`x` tj"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana",
- "": "`x` dana"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h",
- "": "`x` h"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min",
- "": "`x` min"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s",
- "": "`x` s"
- },
"Fallback comments: ": "Alternativni komentari: ",
"Popular": "Popularni",
"Search": "Traži",
"Top": "Najbolji",
"About": "Informacije",
"Rating: ": "Ocjena: ",
- "Language: ": "Jezik: ",
- "View as playlist": "Prikaži kao playlistu",
+ "preferences_locale_label": "Jezik: ",
+ "View as playlist": "Prikaži kao zbirku",
"Default": "Standardno",
"Music": "Glazba",
"Gaming": "Videoigre",
@@ -383,7 +319,7 @@
"Movies": "Filmovi",
"Download": "Preuzmi",
"Download as: ": "Preuzmi kao: ",
- "%A %B %-d, %Y": "%A, %-d. %B %Y.",
+ "%A %B %-d, %Y": "%A, %-d. %B %Y",
"(edited)": "(uređeno)",
"YouTube comment permalink": "Stalna poveznica YouTube komentara",
"permalink": "stalna poveznica",
@@ -391,7 +327,7 @@
"Audio mode": "Audio modus",
"Video mode": "Videomodus",
"Videos": "Videa",
- "Playlists": "Playliste",
+ "Playlists": "Zbirke",
"Community": "Zajednica",
"relevance": "značaj",
"rating": "ocjena",
@@ -408,7 +344,7 @@
"year": "godina",
"video": "video",
"channel": "kanal",
- "playlist": "playlista",
+ "playlist": "Zbirka",
"movie": "film",
"show": "emisija",
"hd": "hd",
@@ -421,7 +357,94 @@
"hdr": "hdr",
"filter": "filtar",
"Current version: ": "Trenutačna verzija: ",
- "next_steps_error_message": "Nakon toga pokušaj sljedeće: ",
+ "next_steps_error_message": "Nakon toga bi trebali pokušati sljedeće: ",
"next_steps_error_message_refresh": "Aktualiziraj stranicu",
- "next_steps_error_message_go_to_youtube": "Idi na YouTube"
+ "next_steps_error_message_go_to_youtube": "Idi na YouTube",
+ "footer_donate_page": "Doniraj",
+ "adminprefs_modified_source_code_url_label": "URL do repozitorija izmijenjenog izvornog koda",
+ "short": "Kratki (< 4 minute)",
+ "long": "Dugi (> 20 minute)",
+ "footer_source_code": "Izvorni kod",
+ "footer_modfied_source_code": "Izmijenjeni izvorni kod",
+ "footer_documentation": "Dokumentacija",
+ "footer_original_source_code": "Izvoran izvorni kod",
+ "preferences_region_label": "Zemlja sadržaja: ",
+ "preferences_quality_dash_label": "Preferirana DASH videokvaliteta: ",
+ "preferences_quality_option_dash": "DASH (adaptativna kvaliteta)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Srednja",
+ "preferences_quality_dash_option_worst": "Najgora",
+ "preferences_quality_dash_option_4320p": "4320 p",
+ "preferences_quality_dash_option_2160p": "2160 p",
+ "preferences_quality_dash_option_1440p": "1440 p",
+ "preferences_quality_dash_option_1080p": "1080 p",
+ "preferences_quality_dash_option_360p": "360 p",
+ "preferences_quality_dash_option_240p": "240 p",
+ "preferences_quality_dash_option_144p": "144 p",
+ "invidious": "Invidious",
+ "purchased": "Kupljeno",
+ "360": "360 °",
+ "none": "bez",
+ "videoinfo_youTube_embed_link": "Ugradi",
+ "user_created_playlists": "`x` stvorene zbirke",
+ "user_saved_playlists": "`x` spremljene zbirke",
+ "Video unavailable": "Video nedostupan",
+ "preferences_save_player_pos_label": "Spremi mjesto reprodukcije: ",
+ "videoinfo_watch_on_youTube": "Gledaj na YouTubeu",
+ "download_subtitles": "Podnaslovi - `x` (.vtt)",
+ "preferences_quality_dash_option_auto": "Automatska",
+ "preferences_quality_option_small": "Niska",
+ "preferences_quality_dash_option_best": "Najbolja",
+ "preferences_quality_dash_option_720p": "720 p",
+ "preferences_quality_dash_option_480p": "480 p",
+ "videoinfo_started_streaming_x_ago": "Započet prijenos prije `x`",
+ "videoinfo_invidious_embed_link": "Ugradi poveznicu",
+ "generic_count_hours_0": "{{count}} sat",
+ "generic_count_hours_1": "{{count}} sata",
+ "generic_count_hours_2": "{{count}} sati",
+ "generic_subscribers_count_0": "{{count}} pretplatnik",
+ "generic_subscribers_count_1": "{{count}} pretplatnika",
+ "generic_subscribers_count_2": "{{count}} pretplatnika",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokena",
+ "tokens_count_2": "{{count}} tokena",
+ "subscriptions_unseen_notifs_count_0": "{{count}} neviđena obavijest",
+ "subscriptions_unseen_notifs_count_1": "{{count}} neviđene obavijesti",
+ "subscriptions_unseen_notifs_count_2": "{{count}} neviđenih obavijesti",
+ "generic_count_years_0": "{{count}} godina",
+ "generic_count_years_1": "{{count}} godine",
+ "generic_count_years_2": "{{count}} godina",
+ "generic_count_months_0": "{{count}} mjesec",
+ "generic_count_months_1": "{{count}} mjeseca",
+ "generic_count_months_2": "{{count}} mjeseci",
+ "generic_count_weeks_0": "{{count}} tjedan",
+ "generic_count_weeks_1": "{{count}} tjedna",
+ "generic_count_weeks_2": "{{count}} tjedana",
+ "generic_count_minutes_0": "{{count}} minuta",
+ "generic_count_minutes_1": "{{count}} minute",
+ "generic_count_minutes_2": "{{count}} minuta",
+ "generic_count_seconds_0": "{{count}} sekunda",
+ "generic_count_seconds_1": "{{count}} sekunde",
+ "generic_count_seconds_2": "{{count}} sekundi",
+ "comments_points_count_0": "{{count}} točka",
+ "comments_points_count_1": "{{count}} točke",
+ "comments_points_count_2": "{{count}} točaka",
+ "generic_subscriptions_count_0": "{{count}} pretplata",
+ "generic_subscriptions_count_1": "{{count}} pretplate",
+ "generic_subscriptions_count_2": "{{count}} pretplata",
+ "generic_playlists_count_0": "{{count}} zbirka",
+ "generic_playlists_count_1": "{{count}} zbirke",
+ "generic_playlists_count_2": "{{count}} zbirka",
+ "generic_videos_count_0": "{{count}} video",
+ "generic_videos_count_1": "{{count}} videa",
+ "generic_videos_count_2": "{{count}} videa",
+ "generic_count_days_0": "{{count}} dan",
+ "generic_count_days_1": "{{count}} dana",
+ "generic_count_days_2": "{{count}} dana",
+ "generic_views_count_0": "{{count}} prikaz",
+ "generic_views_count_1": "{{count}} prikaza",
+ "generic_views_count_2": "{{count}} prikaza",
+ "comments_view_x_replies_0": "Prikaži {{count}} odgovor",
+ "comments_view_x_replies_1": "Prikaži {{count}} odgovora",
+ "comments_view_x_replies_2": "Prikaži {{count}} odgovora"
}
diff --git a/locales/hu-HU.json b/locales/hu-HU.json
index d5570a18..60285d94 100644
--- a/locales/hu-HU.json
+++ b/locales/hu-HU.json
@@ -1,241 +1,216 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` feliratkozó"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` videó"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` playlist"
- },
+ "generic_views_count": "{{count}} már látta",
+ "generic_views_count_plural": "{{count}} már látta",
+ "generic_videos_count": "{{count}} videó",
+ "generic_videos_count_plural": "{{count}} videó",
+ "generic_playlists_count": "{{count}} lejátszási lista",
+ "generic_playlists_count_plural": "{{count}} lejátszási lista",
+ "generic_subscribers_count": "{{count}} feliratkozó",
+ "generic_subscribers_count_plural": "{{count}} feliratkozó",
+ "generic_subscriptions_count": "{{count}} csatornára van feliratkozás",
+ "generic_subscriptions_count_plural": "{{count}} csatornára van feliratkozás",
"LIVE": "ÉLŐ",
- "Shared `x` ago": "`x` óta megosztva",
+ "Shared `x` ago": "`x` ezelőtt megosztva",
"Unsubscribe": "Leiratkozás",
"Subscribe": "Feliratkozás",
- "View channel on YouTube": "csatorna megtekintése a YouTube-on",
- "View playlist on YouTube": "lejátszási lista megtekintése a YouTube-on",
+ "View channel on YouTube": "Csatorna megnézése YouTube-on",
+ "View playlist on YouTube": "Lejátszási lista megnézése YouTube-on",
"newest": "legújabb",
"oldest": "legrégibb",
"popular": "népszerű",
"last": "utolsó",
"Next page": "Következő oldal",
"Previous page": "Előző oldal",
- "Clear watch history?": "Megtekintési napló törlése?",
+ "Clear watch history?": "Törölve legyen a megnézett videók naplója?",
"New password": "Új jelszó",
- "New passwords must match": "Az új jelszavaknak egyezniük kell",
- "Cannot change password for Google accounts": "Google fiók jelszavát nem lehet megváltoztatni",
- "Authorize token?": "Token felhatalmazása?",
- "Authorize token for `x`?": "Token felhatalmazása `x`-ra?",
+ "New passwords must match": "Az új jelszavaknak egyezniük kell.",
+ "Cannot change password for Google accounts": "A Google-fiók jelszavát nem lehet megváltoztatni.",
+ "Authorize token?": "Engedélyezve legyen a token?",
+ "Authorize token for `x`?": "Engedélyezve legyen a token erre? „`x`”",
"Yes": "Igen",
"No": "Nem",
"Import and Export Data": "Adatok importálása és exportálása",
"Import": "Importálás",
- "Import Invidious data": "Invidious adatainak importálása",
- "Import YouTube subscriptions": "YouTube feliratkozások importálása",
- "Import FreeTube subscriptions (.db)": "FreeTube feliratkozások importálása (.db)",
- "Import NewPipe subscriptions (.json)": "NewPipe feliratkozások importálása (.json)",
+ "Import Invidious data": "Az Invidious adatainak importálása",
+ "Import YouTube subscriptions": "YouTube-feliratkozások importálása",
+ "Import FreeTube subscriptions (.db)": "FreeTube-feliratkozások importálása (.db)",
+ "Import NewPipe subscriptions (.json)": "NewPipe-feliratkozások importálása (.json)",
"Import NewPipe data (.zip)": "NewPipe adatainak importálása (.zip)",
"Export": "Exportálás",
"Export subscriptions as OPML": "Feliratkozások exportálása OPML-ként",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe és FreeTube számára)",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe-hoz és FreeTube-hoz)",
"Export data as JSON": "Adat exportálása JSON-ként",
- "Delete account?": "Fiók törlése?",
- "History": "Megtekintési napló",
- "An alternative front-end to YouTube": "Alternatív YouTube front-end",
- "JavaScript license information": "JavaScript licensz információ",
+ "Delete account?": "Törlésre kerüljön a fiók?",
+ "History": "Megnézett videók naplója",
+ "An alternative front-end to YouTube": "Ez az oldal egyike a YouTube alternatív kezelőfelületeinek",
+ "JavaScript license information": "A JavaScript licencinformációja",
"source": "forrás",
"Log in": "Bejelentkezés",
- "Log in/register": "Bejelentkezés/Regisztráció",
- "Log in with Google": "Bejelentkezés Google fiókkal",
- "User ID": "Felhasználó-ID",
+ "Log in/register": "Bejelentkezés/Regisztrálás",
+ "Log in with Google": "Bejelentkezés Google-fiókkal",
+ "User ID": "Felhasználói azonosító",
"Password": "Jelszó",
- "Time (h:mm:ss):": "Idő (h:mm:ss):",
- "Text CAPTCHA": "Szöveg-CAPTCHA",
- "Image CAPTCHA": "Kép-CAPTCHA",
+ "Time (h:mm:ss):": "A pontos idő (ó:pp:mm):",
+ "Text CAPTCHA": "Szöveges CAPTCHA kérése",
+ "Image CAPTCHA": "Kép CAPTCHA kérése",
"Sign In": "Bejelentkezés",
- "Register": "Regisztráció",
- "E-mail": "E-mail",
- "Google verification code": "Google verifikációs kód",
+ "Register": "Regisztrálás",
+ "E-mail": "E-mail-cím",
+ "Google verification code": "A Google ellenőrző kódja",
"Preferences": "Beállítások",
- "Player preferences": "Lejátszó beállítások",
- "Always loop: ": "Mindig loop-ol: ",
- "Autoplay: ": "Automatikus lejátszás: ",
- "Play next by default: ": "Következő lejátszása alapértelmezésben: ",
- "Autoplay next video: ": "Következő automatikus lejátszása: ",
- "Listen by default: ": "Hallgatás alapértelmezésben: ",
- "Proxy videos: ": "Videók proxyzása: ",
- "Default speed: ": "Alapértelmezett sebesség: ",
- "Preferred video quality: ": "Kívánt video minőség: ",
- "Player volume: ": "Hangerő: ",
- "Default comments: ": "Alapértelmezett kommentek: ",
+ "preferences_category_player": "Lejátszó beállításai",
+ "preferences_video_loop_label": "Videó állandó ismétlése: ",
+ "preferences_autoplay_label": "Automatikus lejátszás: ",
+ "preferences_continue_label": "A következő videót mindig automatikusan játssza le: ",
+ "preferences_continue_autoplay_label": "A következő videó automatikus lejátszása: ",
+ "preferences_listen_label": "Mindig csak a hangsáv lejátszása: ",
+ "preferences_local_label": "Videók proxyn keresztüli lejátszása: ",
+ "preferences_speed_label": "Alapértelmezett sebesség: ",
+ "preferences_quality_label": "Videó minősége: ",
+ "preferences_volume_label": "Hangerő: ",
+ "preferences_comments_label": "Mindig innen legyenek betöltve a hozzászólások: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Alapértelmezett feliratok: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Felirat nyelvének sorrendje: ",
"Fallback captions: ": "Másodlagos feliratok: ",
- "Show related videos: ": "Hasonló videók mutatása: ",
- "Show annotations by default: ": "Szövegmagyarázatok mutatása alapértelmezésben: ",
- "Automatically extend video description: ": "Automatikusan növelje meg a videó leírását",
- "Interactive 360 degree videos: ": "Interaktív 360° videók",
- "Visual preferences": "Kinézeti beállítások",
- "Player style: ": "Lejátszó stílusa: ",
- "Dark mode: ": "Sötét mód: ",
- "Theme: ": "Téma: ",
+ "preferences_related_videos_label": "Hasonló videók ajánlása: ",
+ "preferences_annotations_label": "Szövegmagyarázat alapértelmezett mutatása: ",
+ "preferences_extend_desc_label": "A videó leírása automatikusan látható: ",
+ "preferences_vr_mode_label": "Interaktív, 360°-os videók ",
+ "preferences_category_visual": "Kinézet, elrendezés és régió beállításai",
+ "preferences_player_style_label": "Lejátszó kinézete: ",
+ "Dark mode: ": "Elsötétített mód: ",
+ "preferences_dark_mode_label": "Téma: ",
"dark": "sötét",
"light": "világos",
- "Thin mode: ": "Vékony mód: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Feliratkozási beállítások",
- "Show annotations by default for subscribed channels: ": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ",
- "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ",
- "Number of videos shown in feed: ": "Feed-ben mutatott videók száma: ",
- "Sort videos by: ": "Videók sorrendje: ",
- "published": "közzétéve",
- "published - reverse": "közzétéve - fordítva",
- "alphabetically": "ABC sorrend",
- "alphabetically - reverse": "ABC sorrend - fordítva",
- "channel name": "csatorna neve",
- "channel name - reverse": "csatorna neve - fordítva",
- "Only show latest video from channel: ": "Csak a legutolsó videó mutatása a csatornából: ",
- "Only show latest unwatched video from channel: ": "Csak a legutolsó nem megtekintett videó mutatása a csatornából: ",
- "Only show unwatched: ": "Csak a nem megtekintettek mutatása: ",
- "Only show notifications (if there are any): ": "Csak értesítések mutatása (ha van): ",
- "Enable web notifications": "Web értesítések bekapcsolása",
+ "preferences_thin_mode_label": "Vékony mód: ",
+ "preferences_category_subscription": "Feliratkozott tartalmak beállításai",
+ "preferences_annotations_subscribed_label": "A feliratkozott csatornák szövegmagyarázatának alapértelmezett mutatása: ",
+ "Redirect homepage to feed: ": "Kezdőoldal átirányitása a feedre: ",
+ "preferences_max_results_label": "Feedben mutatott videók száma: ",
+ "preferences_sort_label": "Videók rendezése: ",
+ "published": "közzététel szerint",
+ "published - reverse": "közzététel szerint – fordított sorrendben",
+ "alphabetically": "ABC-sorrend szerint",
+ "alphabetically - reverse": "Fordított ABC-sorrend szerint",
+ "channel name": "csatorna neve szerint",
+ "channel name - reverse": "csatorna neve szerint – fordított sorrendben",
+ "Only show latest video from channel: ": "Csak a csatorna legújabb videójának mutatása: ",
+ "Only show latest unwatched video from channel: ": "Csak a csatorna legújabb, de még nem megnézett videójának mutatása: ",
+ "preferences_unseen_only_label": "A még nem megnézett videók mutatása: ",
+ "preferences_notifications_only_label": "Csak az értesítések mutatása (ha van): ",
+ "Enable web notifications": "Böngészőn belüli értesítések bekapcsolása",
"`x` uploaded a video": "`x` feltöltött egy videót",
- "`x` is live": "`x` élő",
- "Data preferences": "Adat beállítások",
- "Clear watch history": "Megtekintési napló törlése",
- "Import/export data": "Adat Import/Export",
- "Change password": "Jelszócsere",
+ "`x` is live": "`x` élőben közvetít",
+ "preferences_category_data": "Fiók beállításai és egyéb lehetőségek",
+ "Clear watch history": "Megnézett videók naplójának törlése",
+ "Import/export data": "Adatok importálása vagy exportálása",
+ "Change password": "Jelszó megváltoztatása",
"Manage subscriptions": "Feliratkozások kezelése",
"Manage tokens": "Tokenek kezelése",
- "Watch history": "Megtekintési napló",
+ "Watch history": "Megnézett videók naplója",
"Delete account": "Fiók törlése",
- "Administrator preferences": "Adminisztrátor beállítások",
- "Default homepage: ": "Alapértelmezett oldal: ",
- "Feed menu: ": "Feed menü: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "Top lista engedélyezve: ",
+ "preferences_category_admin": "Adminisztrátorok beállításai",
+ "preferences_default_home_label": "Kezdőoldal: ",
+ "preferences_feed_menu_label": "Feed menü sorrendje: ",
+ "Top enabled: ": "Toplista engedélyezve: ",
"CAPTCHA enabled: ": "CAPTCHA engedélyezve: ",
"Login enabled: ": "Bejelentkezés engedélyezve: ",
- "Registration enabled: ": "Registztráció engedélyezve: ",
- "Report statistics: ": "Statisztikák gyűjtése: ",
+ "Registration enabled: ": "Regisztrálás engedélyezve: ",
+ "Report statistics: ": "Statisztika jelentése: ",
"Save preferences": "Beállítások mentése",
- "Subscription manager": "Feliratkozás kezelő",
- "Token manager": "Token kezelő",
+ "Subscription manager": "Feliratkozások kezelője",
+ "Token manager": "Tokenek kezelője",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` feliratkozás"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` token"
- },
- "Import/export": "Import/export",
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} token",
+ "Import/export": "Importálás/exportálás",
"unsubscribe": "leiratkozás",
"revoke": "visszavonás",
"Subscriptions": "Feliratkozások",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` kimaradt érdesítés"
- },
- "search": "keresés",
+ "subscriptions_unseen_notifs_count": "{{count}} kimaradt értesítés",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} kimaradt értesítés",
+ "search": "Videó keresése",
"Log out": "Kijelentkezés",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "A forráskód itt érhető el.",
- "View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.",
- "View privacy policy.": "Adatvédelmi irányelvek megtekintése.",
+ "Source available here.": "A forráskód itt érhető el",
+ "View JavaScript license information.": "JavaScript licencinformáció megnyitása",
+ "View privacy policy.": "Adatvédelmi szabályzat megnyitása",
"Trending": "Felkapott",
- "Public": "Nyilvános",
- "Unlisted": "Nem nyilvános",
- "Private": "Privát",
- "View all playlists": "Minden lejátszási lista megtekintése",
- "Updated `x` ago": "Frissitve: `x`",
- "Delete playlist `x`?": "`x` playlist törlése?",
+ "Public": "nyilvános",
+ "Unlisted": "nem nyilvános",
+ "Private": "magán",
+ "View all playlists": "Összes lejátszási lista megnézése",
+ "Updated `x` ago": "`x` ezelőtt frissítve",
+ "Delete playlist `x`?": "Törlésre kerüljön ez a lejátszási lista? „`x`”",
"Delete playlist": "Lejátszási lista törlése",
"Create playlist": "Lejátszási lista létrehozása",
- "Title": "Cím",
+ "Title": "Lejátszási lista címe",
"Playlist privacy": "Lejátszási lista láthatósága",
- "Editing playlist `x`": "`x` lista szerkesztése",
- "Show more": "Mutass többet",
- "Show less": "Mutass kevesebbet",
- "Watch on YouTube": "Megtekintés a YouTube-on",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "Szövegmagyarázat elrejtése",
- "Show annotations": "Szövegmagyarázat mutatása",
+ "Editing playlist `x`": "„`x`” lejátszási lista szerkesztése",
+ "Show more": "Többi szöveg mutatása",
+ "Show less": "Kevesebb szöveg mutatása",
+ "Watch on YouTube": "YouTube-on megnézni",
+ "Hide annotations": "Megjegyzések elrejtése",
+ "Show annotations": "Megjegyzések mutatása",
"Genre: ": "Műfaj: ",
- "License: ": "Licensz: ",
+ "License: ": "Licenc: ",
"Family friendly? ": "Családbarát? ",
"Wilson score: ": "Wilson-pontszám: ",
- "Engagement: ": "elkötelezettség: ",
+ "Engagement: ": "Visszajelzési mutató: ",
"Whitelisted regions: ": "Engedélyezett régiók: ",
"Blacklisted regions: ": "Tiltott régiók: ",
- "Shared `x`": "Megosztva `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` megtekintés"
- },
- "Premieres in `x`": "premierel `x` múlva",
- "Premieres `x`": "`x`-t premierel",
- "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Úgy látszik, hogy a JavaScript ki van kapcsolva a böngésződben. Kattints ide hogy megtekintsd a kommenteket, de tudd, hogy így kicsit tovább tarthat a betöltés.",
- "View YouTube comments": "YouTube kommentek megtekintése",
- "View more comments on Reddit": "További kommentek megtekintése Redditen",
+ "Shared `x`": "`x` napon osztották meg",
+ "Premieres in `x`": "`x` később lesz a premierje",
+ "Premieres `x`": "`x` lesz a premierje",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Helló! Úgy tűnik a JavaScript ki van kapcsolva a böngészőben. Ide kattintva lehet olvasni a hozzászólásokat, de a betöltésük így kicsit több időbe telik.",
+ "View YouTube comments": "YouTube-on lévő hozzászólások olvasása",
+ "View more comments on Reddit": "A többi hozzászólás olvasása Redditen",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` komment megtekintése"
+ "": "`x` hozzászólás olvasása",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hozzászólás olvasása"
},
- "View Reddit comments": "Reddit kommentek megtekintése",
+ "View Reddit comments": "Redditen lévő hozzászólások olvasása",
"Hide replies": "Válaszok elrejtése",
"Show replies": "Válaszok mutatása",
- "Incorrect password": "Helytelen jelszó",
- "Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés (hitelesítő vagy SMS) engedélyezve van.",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen bejelentkezés. Győződj meg róla, hogy a kétfaktoros hitelesítés engedélyezve van.",
- "Wrong answer": "Rossz válasz",
- "Erroneous CAPTCHA": "Hibás CAPTCHA",
- "CAPTCHA is a required field": "A CAPTCHA kötelező",
- "User ID is a required field": "A felhasználó-ID kötelező",
- "Password is a required field": "A jelszó kötelező",
- "Wrong username or password": "Rossz felhasználónév vagy jelszó",
- "Please sign in using 'Log in with Google'": "Kérem, jelentkezzen be a \"Bejelentkezés Google-el\"",
- "Password cannot be empty": "A jelszó nem lehet üres",
- "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél",
- "Please log in": "Kérem lépjen be",
- "Invidious Private Feed for `x`": "`x` Invidious privát feed-je",
- "channel:`x`": "`x` csatorna",
- "Deleted or invalid channel": "Törölt vagy nemlétező csatorna",
- "This channel does not exist.": "Ez a csatorna nem létezik.",
- "Could not get channel info.": "Nem sikerült lekérni a csatorna adatokat.",
- "Could not fetch comments": "Nem sikerült lekérni a kommenteket",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` válasz megtekintése"
- },
- "`x` ago": "`x` óta",
- "Load more": "További betöltése",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` pont"
- },
- "Could not create mix.": "Nem tudok mix-et készíteni.",
+ "Incorrect password": "A jelszó nem megfelelő",
+ "Quota exceeded, try again in a few hours": "A kvótát meghaladták. Néhány órával később próbáld meg újból betölteni.",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nem sikerült bejelentkezni. A kétlépcsős (hitelesítő vagy szöveges üzenet általi) hitelesítésnek bekapcsolva kell lennie.",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Nem sikerült bejelentkezni. Ennek oka lehet, hogy a kétlépcsős hitelesítés nincs bekapcsolva a fiók beállításaiban.",
+ "Wrong answer": "Nem jól válaszoltál.",
+ "Erroneous CAPTCHA": "A CAPTCHA hibás.",
+ "CAPTCHA is a required field": "A CAPTCHA-mezőt ki kell tölteni.",
+ "User ID is a required field": "A felhasználói azonosítót meg kell adni.",
+ "Password is a required field": "Meg kell adni egy jelszót.",
+ "Wrong username or password": "Vagy a felhasználói név, vagy pedig a jelszó nem megfelelő.",
+ "Please sign in using 'Log in with Google'": "A „Bejelentkezés Google-el” gombbal jelentkezz be.",
+ "Password cannot be empty": "A jelszót nem lehet kihagyni.",
+ "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél.",
+ "Please log in": "Kérjük, jelentkezz be.",
+ "Invidious Private Feed for `x`": "„`x`” Invidious magán feedje",
+ "channel:`x`": "`x` csatornája",
+ "Deleted or invalid channel": "A csatorna érvénytelen, vagy pedig törölve lett.",
+ "This channel does not exist.": "Nincs ilyen csatorna.",
+ "Could not get channel info.": "Nem lehetett betölteni a csatorna adatait.",
+ "Could not fetch comments": "Nem lehetett betölteni a hozzászólásokat.",
+ "comments_view_x_replies": "{{count}} válasz olvasása",
+ "comments_view_x_replies_plural": "{{count}} válasz olvasása",
+ "`x` ago": "`x` ezelőtt",
+ "Load more": "Többi hozzászólás betöltése",
+ "comments_points_count": "{{count}} pont",
+ "comments_points_count_plural": "{{count}} pont",
+ "Could not create mix.": "A válogatást nem lehetett elkészíteni.",
"Empty playlist": "Üres lejátszási lista",
- "Not a playlist.": "Nem lejátszási lista.",
+ "Not a playlist.": "Ez nem egy lejátszási lista.",
"Playlist does not exist.": "Nincs ilyen lejátszási lista.",
- "Could not pull trending pages.": "Nem sikerült lekérni a felkapott oldalt.",
- "Hidden field \"challenge\" is a required field": "A rejtett \"challenge\" mező kötelező",
- "Hidden field \"token\" is a required field": "A rejtett \"token\" mező kötelező",
+ "Could not pull trending pages.": "Nem lehetett betölteni a felkapott videók oldalát.",
+ "Hidden field \"challenge\" is a required field": "A rejtett „challenge” mezőt ki kell tölteni.",
+ "Hidden field \"token\" is a required field": "A rejtett „token” mezőt ki kell tölteni.",
"Erroneous challenge": "Hibás challenge",
"Erroneous token": "Hibás token",
"No such user": "Nincs ilyen felhasználó",
- "Token is expired, please try again": "Lejárt token, kérem próbáld újra",
+ "Token is expired, please try again": "A token lejárt. Kérjük, próbáld meg újból.",
"English": "angol",
- "English (auto-generated)": "angol (automatikusan generált)",
+ "English (auto-generated)": "angol (automatikusan létrehozott)",
"Afrikaans": "afrikaans",
"Albanian": "albán",
"Amharic": "amhara",
@@ -262,10 +237,10 @@
"Filipino": "filippínó",
"Finnish": "finn",
"French": "francia",
- "Galician": "galíciai",
+ "Galician": "galiciai",
"Georgian": "grúz",
"German": "német",
- "Greek": "görök",
+ "Greek": "görög",
"Gujarati": "gudzsaráti",
"Haitian Creole": "haiti kreol",
"Hausa": "hausza",
@@ -276,13 +251,13 @@
"Hungarian": "magyar",
"Icelandic": "izlandi",
"Igbo": "igbo",
- "Indonesian": "indonéziai",
+ "Indonesian": "indonéz",
"Irish": "ír",
"Italian": "olasz",
"Japanese": "japán",
"Javanese": "jávai",
"Kannada": "kannada",
- "Kazakh": "kazah",
+ "Kazakh": "kazak",
"Khmer": "khmer",
"Korean": "koreai",
"Kurdish": "kurd",
@@ -292,17 +267,17 @@
"Latvian": "lett",
"Lithuanian": "litván",
"Luxembourgish": "luxemburgi",
- "Macedonian": "macedóniai",
+ "Macedonian": "macedón",
"Malagasy": "madagaszkári",
"Malay": "maláj",
"Malayalam": "malajálam",
"Maltese": "máltai",
"Maori": "maori",
- "Marathi": "Maráthi",
+ "Marathi": "maráthi",
"Mongolian": "mongol",
"Nepali": "nepáli",
- "Norwegian Bokmål": "bokmål",
- "Nyanja": "nyánja",
+ "Norwegian Bokmål": "norvég (bokmål)",
+ "Nyanja": "njándzsa (csicseva)",
"Pashto": "pastu",
"Persian": "perzsa",
"Polish": "lengyel",
@@ -313,115 +288,154 @@
"Samoan": "szamoai",
"Scottish Gaelic": "skót gael",
"Serbian": "szerb",
- "Shona": "shona",
- "Sindhi": "szindhi",
+ "Shona": "sona",
+ "Sindhi": "szindi",
"Sinhala": "szingaléz",
"Slovak": "szlovák",
"Slovenian": "szlovén",
"Somali": "szomáliai",
- "Southern Sotho": "déli szothó",
+ "Southern Sotho": "déli szútú",
"Spanish": "spanyol",
- "Spanish (Latin America)": "spanyol (Latin-Amerika)",
+ "Spanish (Latin America)": "spanyol (latinamerikai)",
"Sundanese": "szunda",
"Swahili": "szuahéli",
- "Swedish": "svld",
- "Tajik": "tadzsik",
+ "Swedish": "svéd",
+ "Tajik": "tádzsik",
"Tamil": "tamil",
"Telugu": "telugu",
"Thai": "thai",
"Turkish": "török",
"Ukrainian": "ukrán",
- "Urdu": "",
"Uzbek": "üzbég",
"Vietnamese": "vietnámi",
"Welsh": "walesi",
"Western Frisian": "nyugati fríz",
- "Xhosa": "",
"Yiddish": "jiddis",
"Yoruba": "joruba",
"Zulu": "zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` év"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` hónap"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` hét"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` nap"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` óra"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` perc"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` másodperc"
- },
+ "generic_count_years": "{{count}} évvel",
+ "generic_count_years_plural": "{{count}} évvel",
+ "generic_count_months": "{{count}} hónappal",
+ "generic_count_months_plural": "{{count}} hónappal",
+ "generic_count_weeks": "{{count}} héttel",
+ "generic_count_weeks_plural": "{{count}} héttel",
+ "generic_count_days": "{{count}} nappal",
+ "generic_count_days_plural": "{{count}} nappal",
+ "generic_count_hours": "{{count}} órával",
+ "generic_count_hours_plural": "{{count}} órával",
+ "generic_count_minutes": "{{count}} perccel",
+ "generic_count_minutes_plural": "{{count}} perccel",
+ "generic_count_seconds": "{{count}} másodperccel",
+ "generic_count_seconds_plural": "{{count}} másodperccel",
"Fallback comments: ": "Másodlagos kommentek: ",
"Popular": "Népszerű",
- "Search": "Keresés",
+ "Search": "Keresési oldal",
"Top": "Top",
"About": "Leírás",
- "Rating: ": "Besorolás: ",
- "Language: ": "Nyelv: ",
- "View as playlist": "Megtekintés lejátszási listaként",
+ "Rating: ": "Pontszám: ",
+ "preferences_locale_label": "Nyelv: ",
+ "View as playlist": "Megnézés lejátszási listában",
"Default": "Alapértelmezett",
- "Music": "Zene",
+ "Music": "Zenék",
"Gaming": "Játékok",
"News": "Hírek",
"Movies": "Filmek",
"Download": "Letöltés",
- "Download as: ": "Letöltés mint: ",
- "%A %B %-d, %Y": "",
+ "Download as: ": "Letöltés másként: ",
"(edited)": "(szerkesztve)",
- "YouTube comment permalink": "YouTube komment permalink",
- "permalink": "permalink",
- "`x` marked it with a ❤": "`x` jelölte ❤-vel",
- "Audio mode": "Audió mód",
- "Video mode": "Hang mód",
- "Videos": "Videók",
- "Playlists": "Lejátszási listák",
+ "YouTube comment permalink": "YouTube-hozzászólás idehivatkozása",
+ "permalink": "idehivatkozás",
+ "`x` marked it with a ❤": "`x` ❤ jelet adott a hozzászóláshoz",
+ "Audio mode": "Csak hanggal",
+ "Video mode": "Hanggal és képpel",
+ "Videos": "Videói",
+ "Playlists": "Lejátszási listái",
"Community": "Közösség",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
"Current version: ": "Jelenlegi verzió: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "preferences_quality_option_medium": "Közepes",
+ "preferences_quality_dash_option_auto": "Automatikus",
+ "preferences_quality_dash_option_best": "Legjobb",
+ "preferences_quality_dash_option_worst": "Legrosszabb",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "videoinfo_started_streaming_x_ago": "`x` ezelőtt kezdte streamelni",
+ "views": "Mennyien látták",
+ "purchased": "Megvásárolva",
+ "360": "360°-os",
+ "footer_original_source_code": "Eredeti forráskód",
+ "none": "egyik sem",
+ "videoinfo_watch_on_youTube": "YouTube-on megnézni",
+ "videoinfo_youTube_embed_link": "beágyazva",
+ "videoinfo_invidious_embed_link": "Beágyazás linkje",
+ "download_subtitles": "Felirat – `x` (.vtt)",
+ "user_created_playlists": "`x` létrehozott lejátszási lista",
+ "user_saved_playlists": "`x` mentett lejátszási lista",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_dash": "DASH (adaptív minőség)",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_label": "DASH-videó minősége: ",
+ "preferences_quality_option_small": "Rossz",
+ "date": "Feltöltés dátuma",
+ "Video unavailable": "A videó nem érhető el",
+ "preferences_save_player_pos_label": "A videó folytatása onnan, ahol félbe lett hagyva: ",
+ "preferences_show_nick_label": "Becenév mutatása felül: ",
+ "Released under the AGPLv3 on Github.": "AGPLv3 licenc alapján a GitHubon",
+ "3d": "3D-ben",
+ "live": "Élőben",
+ "filter": "Szűrők",
+ "next_steps_error_message_refresh": "Újratöltés",
+ "footer_donate_page": "Adakozás",
+ "footer_source_code": "Forráskód",
+ "footer_modfied_source_code": "Módosított forráskód",
+ "adminprefs_modified_source_code_url_label": "A módosított forráskód repositoryjának URL-je:",
+ "preferences_automatic_instance_redirect_label": "Váltáskor másik Invidious oldal automatikus betöltése (redirect.invidious.io töltődik, ha nem működne): ",
+ "preferences_region_label": "Ország tartalmainak mutatása: ",
+ "relevance": "Relevancia",
+ "rating": "Pontszám",
+ "content_type": "Típus",
+ "today": "Mai napon",
+ "channel": "Csatorna",
+ "video": "Videó",
+ "playlist": "Lejátszási lista",
+ "creative_commons": "Creative Commons",
+ "features": "Jellemzők",
+ "sort": "Rendezés módja",
+ "preferences_category_misc": "További beállítások",
+ "%A %B %-d, %Y": "%Y. %B %-d %A",
+ "long": "Hosszú (20 percnél hosszabb)",
+ "year": "Ebben az évben",
+ "hour": "Az elmúlt órában",
+ "movie": "Film",
+ "hdr": "HDR",
+ "Broken? Try another Invidious Instance": "Nem működik? Próbáld meg egy másik Invidious oldallal.",
+ "duration": "Játékidő",
+ "next_steps_error_message": "Az alábbi lehetőségek állnak rendelkezésre: ",
+ "Xhosa": "xhosza",
+ "Switch Invidious Instance": "Váltás másik Invidious-oldalra",
+ "Urdu": "urdu",
+ "week": "Ezen a héten",
+ "Invalid TFA code": "A kétlépéses hitelesítés kódja nem megfelelő",
+ "footer_documentation": "Dokumentáció",
+ "hd": "HD",
+ "next_steps_error_message_go_to_youtube": "Ugrás a YouTube-ra",
+ "show": "Műsor",
+ "4k": "4K",
+ "short": "Rövid (4 percnél nem több)",
+ "month": "Ebben a hónapban",
+ "subtitles": "Felirattal",
+ "location": "Közelben",
+ "crash_page_you_found_a_bug": "Úgy néz ki, találtál egy hibát az Invidiousban.",
+ "crash_page_before_reporting": "Mielőtt jelentenéd a hibát:",
+ "crash_page_read_the_faq": "olvasd el a <a href=\"`x`\">Gyakran Ismételt Kérdéseket (GYIK)</a>",
+ "crash_page_search_issue": "járj utána a <a href=\"`x`\">már meglévő issue-knak a GitHubon</a>",
+ "crash_page_switch_instance": "válts át <a href=\"`x`\">másik Invidious-oldalra</a>",
+ "crash_page_refresh": "<a href=\"`x`\">töltsd újra</a> az oldalt",
+ "crash_page_report_issue": "Ha a fentiek után nem jutottál eredményre, akkor <a href=\"`x`\">nyiss egy új issue-t a GitHubon</a> (lehetőleg angol nyelven írj) és másold be pontosan a lenti szöveget (ezt nem kell lefordítani):"
}
diff --git a/locales/id.json b/locales/id.json
index e15c6aaf..11016a1c 100644
--- a/locales/id.json
+++ b/locales/id.json
@@ -1,20 +1,13 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan",
- "": "`x` pelanggan"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` video"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar",
- "": "`x` daftar putar"
- },
+ "generic_views_count_0": "{{count}} tampilan",
+ "generic_videos_count_0": "{{count}} video",
+ "generic_playlists_count_0": "{{count}} daftar putar",
+ "generic_subscribers_count_0": "{{count}} pelanggan",
+ "generic_subscriptions_count_0": "{{count}} langganan",
"LIVE": "SIARAN LANGSUNG",
- "Shared `x` ago": "Dibagikan`x` lalu",
+ "Shared `x` ago": "Dibagikan `x` yang lalu",
"Unsubscribe": "Batal Langganan",
- "Subscribe": "Langganan",
+ "Subscribe": "Berlangganan",
"View channel on YouTube": "Lihat kanal di YouTube",
"View playlist on YouTube": "Lihat daftar putar di YouTube",
"newest": "terbaru",
@@ -44,11 +37,11 @@
"Export data as JSON": "Ekspor data sebagai JSON",
"Delete account?": "Hapus akun?",
"History": "Riwayat",
- "An alternative front-end to YouTube": "Sebuah alternatif front-end untuk YouTube",
+ "An alternative front-end to YouTube": "Sebuah alternatif layar depan untuk YouTube",
"JavaScript license information": "Informasi lisensi JavaScript",
"source": "sumber",
"Log in": "Masuk",
- "Log in/register": "Daftar",
+ "Log in/register": "Masuk/Daftar",
"Log in with Google": "Masuk dengan Google",
"User ID": "ID Pengguna",
"Password": "Kata Sandi",
@@ -60,53 +53,53 @@
"E-mail": "Surel",
"Google verification code": "Kode verifikasi Google",
"Preferences": "Preferensi",
- "Player preferences": "Preferensi pemutar",
- "Always loop: ": "Selalu ulangi: ",
- "Autoplay: ": "Putar-Otomatis: ",
- "Play next by default: ": "Putar selanjutnya secara default: ",
- "Autoplay next video: ": "Otomatis-Putar video berikutnya: ",
- "Listen by default: ": "Dengarkan secara default: ",
- "Proxy videos: ": "Video Proksi: ",
- "Default speed: ": "Kecepatan default: ",
- "Preferred video quality: ": "Kualitas video yang disukai: ",
- "Player volume: ": "Volume pemutar: ",
- "Default comments: ": "Komentar default: ",
+ "preferences_category_player": "Preferensi pemutar",
+ "preferences_video_loop_label": "Selalu ulangi: ",
+ "preferences_autoplay_label": "Putar Otomatis: ",
+ "preferences_continue_label": "Putar selanjutnya secara baku: ",
+ "preferences_continue_autoplay_label": "Putar otomatis video berikutnya: ",
+ "preferences_listen_label": "Dengarkan secara baku: ",
+ "preferences_local_label": "Proksi video: ",
+ "preferences_speed_label": "Kecepatan baku: ",
+ "preferences_quality_label": "Kualitas video yang disukai: ",
+ "preferences_volume_label": "Volume pemutar: ",
+ "preferences_comments_label": "Komentar baku: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Subtitel default: ",
- "Fallback captions: ": "Subtitel fallback: ",
- "Show related videos: ": "Tampilkan video terkait: ",
- "Show annotations by default: ": "Tampilkan anotasi secara default: ",
- "Automatically extend video description: ": "Perluas deskripsi video secara otomatis: ",
- "Interactive 360 degree videos: ": "Video interaktif 360°: ",
- "Visual preferences": "Preferensi visual",
- "Player style: ": "Gaya pemutar: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Takarir baku: ",
+ "Fallback captions: ": "Takarir cadangan: ",
+ "preferences_related_videos_label": "Tampilkan video terkait: ",
+ "preferences_annotations_label": "Tampilkan anotasi secara baku: ",
+ "preferences_extend_desc_label": "Perluas deskripsi video secara otomatis: ",
+ "preferences_vr_mode_label": "Video interaktif 360°: ",
+ "preferences_category_visual": "Preferensi visual",
+ "preferences_player_style_label": "Gaya pemutar: ",
"Dark mode: ": "Mode gelap: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "gelap",
"light": "terang",
- "Thin mode: ": "Mode tipis: ",
- "Miscellaneous preferences": "Preferensi lainnya",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Pengalihan instans otomatis (fallback ke redirect.invidious.io): ",
- "Subscription preferences": "Preferensi langganan",
- "Show annotations by default for subscribed channels: ": "Tampilkan anotasi secara default untuk kanal langganan: ",
+ "preferences_thin_mode_label": "Mode tipis: ",
+ "preferences_category_misc": "Preferensi lainnya",
+ "preferences_automatic_instance_redirect_label": "Pengalihan peladen otomatis (balik kembali ke redirect.invidious.io): ",
+ "preferences_category_subscription": "Preferensi langganan",
+ "preferences_annotations_subscribed_label": "Tampilkan anotasi secara baku untuk kanal yang dilanggan? ",
"Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ",
- "Number of videos shown in feed: ": "Jumlah video ditampilkan di umpan: ",
- "Sort videos by: ": "Urutkan video berdasarkan: ",
+ "preferences_max_results_label": "Jumlah video ditampilkan di umpan: ",
+ "preferences_sort_label": "Urutkan video berdasarkan: ",
"published": "dipublikasi",
- "published - reverse": "dipublikasi - sebaliknya",
+ "published - reverse": "dipublikasi - terbalik",
"alphabetically": "menurut abjad",
- "alphabetically - reverse": "menurut abjad - sebaliknya",
+ "alphabetically - reverse": "menurut abjad - terbalik",
"channel name": "nama kanal",
- "channel name - reverse": "nama kanal - sebaliknya",
+ "channel name - reverse": "nama kanal - terbalik",
"Only show latest video from channel: ": "Hanya tampilkan video terbaru dari kanal: ",
"Only show latest unwatched video from channel: ": "Hanya tampilkan video belum ditonton terbaru dari kanal: ",
- "Only show unwatched: ": "Hanya tampilkan belum ditonton: ",
- "Only show notifications (if there are any): ": "Hanya tampilkan pemberitahuan (jika ada): ",
+ "preferences_unseen_only_label": "Hanya tampilkan belum ditonton: ",
+ "preferences_notifications_only_label": "Hanya tampilkan pemberitahuan (jika ada): ",
"Enable web notifications": "Aktifkan pemberitahuan web",
"`x` uploaded a video": "`x` mengunggah video",
"`x` is live": "`x` sedang siaran langsung",
- "Data preferences": "Preferensi Data",
+ "preferences_category_data": "Preferensi data",
"Clear watch history": "Bersihkan riwayat tontonan",
"Import/export data": "Impor/Ekspor data",
"Change password": "Ganti kata sandi",
@@ -114,38 +107,28 @@
"Manage tokens": "Atur token",
"Watch history": "Riwayat tontonan",
"Delete account": "Hapus akun",
- "Administrator preferences": "Preferensi administrator",
- "Default homepage: ": "Laman beranda default: ",
- "Feed menu: ": "Menu umpan: ",
- "Show nickname on top: ": "Tampilkan nama panggilan di atas: ",
+ "preferences_category_admin": "Preferensi administrator",
+ "preferences_default_home_label": "Laman beranda baku: ",
+ "preferences_feed_menu_label": "Menu umpan: ",
+ "preferences_show_nick_label": "Tampilkan nama panggilan di atas: ",
"Top enabled: ": "Teratas diaktifkan: ",
"CAPTCHA enabled: ": "CAPTCHA diaktifkan: ",
"Login enabled: ": "Masuk diaktifkan: ",
- "Registration enabled: ": "Registrasi diaktifkan: ",
+ "Registration enabled: ": "Pendaftaran diaktifkan: ",
"Report statistics: ": "Laporan statistik: ",
"Save preferences": "Simpan preferensi",
"Subscription manager": "Pengatur langganan",
"Token manager": "Pengatur token",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan",
- "": "`x` langganan"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` token"
- },
+ "tokens_count_0": "{{count}} token",
"Import/export": "Impor/ekspor",
"unsubscribe": "batal langganan",
"revoke": "cabut",
"Subscriptions": "Langganan",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pemberitahuan belum dilihat",
- "": "`x` pemberitahuan belum dilihat"
- },
+ "subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat",
"search": "cari",
"Log out": "Keluar",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di Github.",
"Source available here.": "Sumber tersedia di sini.",
"View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.",
"View privacy policy.": "Lihat kebijakan privasi.",
@@ -161,11 +144,11 @@
"Title": "Judul",
"Playlist privacy": "Privasi daftar putar",
"Editing playlist `x`": "Menyunting daftar putar `x`",
- "Show more": "Tampilkan lainnya",
+ "Show more": "Tampilkan lebih banyak",
"Show less": "Tampilkan lebih sedikit",
"Watch on YouTube": "Tonton di YouTube",
- "Switch Invidious Instance": "Beralih Instance Invidious",
- "Broken? Try another Invidious Instance": "Rusak? Coba Instance Invidious lain",
+ "Switch Invidious Instance": "Ganti peladen Invidious",
+ "Broken? Try another Invidious Instance": "Rusak? Coba peladen Invidious yang lain",
"Hide annotations": "Sembunyikan anotasi",
"Show annotations": "Tampilkan anotasi",
"Genre: ": "Genre: ",
@@ -175,18 +158,14 @@
"Engagement: ": "Keterlibatan: ",
"Whitelisted regions: ": "Wilayah daftar-putih: ",
"Blacklisted regions: ": "Wilayah daftar-hitam: ",
- "Shared `x`": "Berbagi`x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan.([^.,0-9]|^)1([^.,0-9]|$)",
- "": "`x` tampilan"
- },
+ "Shared `x`": "Berbagi `x`",
"Premieres in `x`": "Tayang dalam `x`",
"Premieres `x`": "Tayang `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.": "Hai! Kelihatannya JavaScript kamu dimatikan. Klik di sini untuk melihat komentar, perlu diingat hal ini mungkin membutuhkan waktu sedikit lebih lama untuk dimuat.",
"View YouTube comments": "Lihat komentar YouTube",
"View more comments on Reddit": "Lihat lebih banyak komentar di Reddit",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar.([^.,0-9]|^)1([^.,0-9]|$)",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar",
"": "Lihat`x` komentar"
},
"View Reddit comments": "Lihat komentar Reddit",
@@ -209,20 +188,14 @@
"Please log in": "Harap masuk",
"Invidious Private Feed for `x`": "Umpan pribadi Invidious untuk`x`",
"channel:`x`": "kanal:`x`",
- "Deleted or invalid channel": "Kanal terhapus atau invalid",
+ "Deleted or invalid channel": "Kanal terhapus atau tidak valid",
"This channel does not exist.": "Kanal ini tidak ada.",
"Could not get channel info.": "Tidak bisa mendapatkan info kanal.",
"Could not fetch comments": "Tidak dapat memuat komentar",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan",
- "": "Lihat `x` balasan"
- },
+ "comments_view_x_replies_0": "Lihat {{count}} balasan",
"`x` ago": "`x` lalu",
"Load more": "Muat lebih banyak",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik",
- "": "`x` titik"
- },
+ "comments_points_count_0": "{{count}} poin",
"Could not create mix.": "Tidak dapat membuat mix.",
"Empty playlist": "Daftar putar kosong",
"Not a playlist.": "Bukan daftar putar.",
@@ -340,43 +313,22 @@
"Yiddish": "Bahasa Yiddi",
"Yoruba": "Bahasa Yoruba",
"Zulu": "Bahasa Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun",
- "": "`x` tahun"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan",
- "": "`x` bulan"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan",
- "": "`x` pekan"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari",
- "": "`x` hari"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam",
- "": "`x` jam"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit",
- "": "`x` menit"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik",
- "": "`x` detik"
- },
- "Fallback comments: ": "Komentar mundur: ",
+ "generic_count_years_0": "{{count}} tahun",
+ "generic_count_months_0": "{{count}} bulan",
+ "generic_count_weeks_0": "{{count}} pekan",
+ "generic_count_days_0": "{{count}} hari",
+ "generic_count_hours_0": "{{count}} jam",
+ "generic_count_minutes_0": "{{count}} menit",
+ "generic_count_seconds_0": "{{count}} detik",
+ "Fallback comments: ": "Komentar alternatif: ",
"Popular": "Populer",
"Search": "Cari",
"Top": "Teratas",
- "About": "Ihwal",
- "Rating: ": "Peringkat: ",
- "Language: ": "Bahasa: ",
- "View as playlist": "Tampilkan sebagai daftar putar",
- "Default": "Asali",
+ "About": "Tentang",
+ "Rating: ": "Rating: ",
+ "preferences_locale_label": "Bahasa: ",
+ "View as playlist": "Lihat sebagai daftar putar",
+ "Default": "Baku",
"Music": "Musik",
"Gaming": "Permainan",
"News": "Berita",
@@ -385,43 +337,82 @@
"Download as: ": "Unduh sebagai: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(disunting)",
- "YouTube comment permalink": "Komentar YouTube permalink",
- "permalink": "permalink",
+ "YouTube comment permalink": "Tautan permanen komentar YouTube",
+ "permalink": "tautan permanen",
"`x` marked it with a ❤": "`x` telah ditandai dengan ❤",
"Audio mode": "Mode audio",
"Video mode": "Mode video",
"Videos": "Video",
"Playlists": "Daftar putar",
"Community": "Komunitas",
- "relevance": "Relevan",
- "rating": "peringkat",
- "date": "tanggal",
- "views": "ditonton",
- "content_type": "tipe_konten",
- "duration": "durasi",
- "features": "fitur",
- "sort": "urut",
- "hour": "jam",
- "today": "hari ini",
- "week": "minggu",
- "month": "bulan",
- "year": "tahun",
- "video": "video",
- "channel": "kanal",
- "playlist": "daftar putar",
- "movie": "film",
- "show": "tampilkan",
- "hd": "hd",
- "subtitles": "subtitel",
- "creative_commons": "creative_commons",
- "3d": "3d",
- "live": "siaran langsung",
- "4k": "4k",
- "location": "lokasi",
- "hdr": "hdr",
- "filter": "saring",
+ "relevance": "Relevansi",
+ "rating": "Rating",
+ "date": "Tanggal unggah",
+ "views": "Jumlah ditonton",
+ "content_type": "Tipe",
+ "duration": "Durasi",
+ "features": "Fitur",
+ "sort": "Urut Berdasarkan",
+ "hour": "Jam Terakhir",
+ "today": "Hari Ini",
+ "week": "Pekan Ini",
+ "month": "Bulan Ini",
+ "year": "Tahun Ini",
+ "video": "Video",
+ "channel": "Kanal",
+ "playlist": "Daftar Putar",
+ "movie": "Film",
+ "show": "Pertunjukan/Acara",
+ "hd": "HD",
+ "subtitles": "Takarir",
+ "creative_commons": "Creative Commons",
+ "3d": "3D",
+ "live": "Siaran Langsung",
+ "4k": "4K",
+ "location": "Lokasi",
+ "hdr": "HDR",
+ "filter": "Saring",
"Current version: ": "Versi saat ini: ",
"next_steps_error_message": "Setelah itu Anda harus mencoba: ",
"next_steps_error_message_refresh": "Segarkan",
- "next_steps_error_message_go_to_youtube": "Buka YouTube"
+ "next_steps_error_message_go_to_youtube": "Buka YouTube",
+ "footer_donate_page": "Donasi",
+ "adminprefs_modified_source_code_url_label": "URL ke repositori kode sumber yang dimodifikasi",
+ "footer_source_code": "Kode sumber",
+ "footer_original_source_code": "Kode sumber yang asli",
+ "short": "Pendek (< 4 menit)",
+ "long": "Panjang (> 20 menit)",
+ "footer_modfied_source_code": "Kode sumber yang dimodifikasi",
+ "footer_documentation": "Dokumentasi",
+ "preferences_region_label": "Konten dari negara: ",
+ "preferences_quality_dash_label": "Kualitas video DASH yang disukai: ",
+ "preferences_quality_option_medium": "Medium",
+ "preferences_quality_option_small": "Rendah",
+ "preferences_quality_dash_option_best": "Terbaik",
+ "preferences_quality_dash_option_worst": "Terburuk",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "purchased": "Dibeli",
+ "360": "360°",
+ "none": "tidak ada",
+ "videoinfo_watch_on_youTube": "Tonton di YouTube",
+ "videoinfo_youTube_embed_link": "Tersemat",
+ "videoinfo_invidious_embed_link": "Tautan Tersemat",
+ "download_subtitles": "Takarir- `x` (.vtt)",
+ "user_saved_playlists": "`x` daftar putar yang disimpan",
+ "videoinfo_started_streaming_x_ago": "Mulai siaran `x` yang lalu",
+ "user_created_playlists": "`x` daftar putar yang dibuat",
+ "preferences_quality_option_dash": "DASH (kualitas adaptif)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_auto": "Otomatis",
+ "preferences_quality_dash_option_480p": "480p",
+ "Video unavailable": "Video tidak tersedia",
+ "preferences_save_player_pos_label": "Simpan posisi pemutaran: "
}
diff --git a/locales/is.json b/locales/is.json
index 478f363a..9258154e 100644
--- a/locales/is.json
+++ b/locales/is.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áskrifandar",
- "": "`x` áskrifendur"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` myndband",
- "": "`x` myndbönd"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spilunarlist",
- "": "`x` spilunarlistar"
- },
"LIVE": "BEINT",
"Shared `x` ago": "Deilt `x` síðan",
"Unsubscribe": "Afskrá",
@@ -60,39 +48,35 @@
"E-mail": "Tölvupóstur",
"Google verification code": "Google staðfestingarkóði",
"Preferences": "Kjörstillingar",
- "Player preferences": "Kjörstillingar spilara",
- "Always loop: ": "Alltaf lykkja: ",
- "Autoplay: ": "Spila sjálfkrafa: ",
- "Play next by default: ": "Spila næst sjálfgefið: ",
- "Autoplay next video: ": "Spila næst sjálfkrafa: ",
- "Listen by default: ": "Hlusta sjálfgefið: ",
- "Proxy videos: ": "Proxy myndbönd? ",
- "Default speed: ": "Sjálfgefinn hraði: ",
- "Preferred video quality: ": "Æskilegt myndbands gæði: ",
- "Player volume: ": "Spilara hljóðstyrkur: ",
- "Default comments: ": "Sjálfgefin ummæli: ",
+ "preferences_category_player": "Kjörstillingar spilara",
+ "preferences_video_loop_label": "Alltaf lykkja: ",
+ "preferences_autoplay_label": "Spila sjálfkrafa: ",
+ "preferences_continue_label": "Spila næst sjálfgefið: ",
+ "preferences_continue_autoplay_label": "Spila næst sjálfkrafa: ",
+ "preferences_listen_label": "Hlusta sjálfgefið: ",
+ "preferences_local_label": "Proxy myndbönd? ",
+ "preferences_speed_label": "Sjálfgefinn hraði: ",
+ "preferences_quality_label": "Æskilegt myndbands gæði: ",
+ "preferences_volume_label": "Spilara hljóðstyrkur: ",
+ "preferences_comments_label": "Sjálfgefin ummæli: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Sjálfgefin texti: ",
+ "preferences_captions_label": "Sjálfgefin texti: ",
"Fallback captions: ": "Varatextar: ",
- "Show related videos: ": "Sýna tengd myndbönd? ",
- "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Sjónrænar stillingar",
- "Player style: ": "Spilara stíl: ",
+ "preferences_related_videos_label": "Sýna tengd myndbönd? ",
+ "preferences_annotations_label": "Á að sýna glósur sjálfgefið? ",
+ "preferences_category_visual": "Sjónrænar stillingar",
+ "preferences_player_style_label": "Spilara stíl: ",
"Dark mode: ": "Myrkur ham: ",
- "Theme: ": "Þema: ",
+ "preferences_dark_mode_label": "Þema: ",
"dark": "dimmt",
"light": "ljóst",
- "Thin mode: ": "Þunnt ham: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Áskriftarstillingar",
- "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ",
+ "preferences_thin_mode_label": "Þunnt ham: ",
+ "preferences_category_subscription": "Áskriftarstillingar",
+ "preferences_annotations_subscribed_label": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ",
"Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ",
- "Number of videos shown in feed: ": "Fjöldi myndbanda sem sýndir eru í straumi: ",
- "Sort videos by: ": "Raða myndbönd eftir: ",
+ "preferences_max_results_label": "Fjöldi myndbanda sem sýndir eru í straumi: ",
+ "preferences_sort_label": "Raða myndbönd eftir: ",
"published": "birt",
"published - reverse": "birt - afturábak",
"alphabetically": "í stafrófsröð",
@@ -101,12 +85,12 @@
"channel name - reverse": "heiti rásar - afturábak",
"Only show latest video from channel: ": "Sýna aðeins nýjasta myndband frá rás: ",
"Only show latest unwatched video from channel: ": "Sýna aðeins nýjasta óséð myndband frá rás: ",
- "Only show unwatched: ": "Sýna aðeins óséð: ",
- "Only show notifications (if there are any): ": "Sýna aðeins tilkynningar (ef einhverjar eru): ",
+ "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` hlóð upp myndband",
"`x` is live": "`x` er í beinni",
- "Data preferences": "Gagnastillingar",
+ "preferences_category_data": "Gagnastillingar",
"Clear watch history": "Hreinsa áhorfssögu",
"Import/export data": "Flytja inn/út gögn",
"Change password": "Breyta lykilorði",
@@ -114,10 +98,9 @@
"Manage tokens": "Stjórna tákn",
"Watch history": "Áhorfssögu",
"Delete account": "Eyða reikningi",
- "Administrator preferences": "Kjörstillingar stjórnanda",
- "Default homepage: ": "Sjálfgefin heimasíða: ",
- "Feed menu: ": "Straum valmynd: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Kjörstillingar stjórnanda",
+ "preferences_default_home_label": "Sjálfgefin heimasíða: ",
+ "preferences_feed_menu_label": "Straum valmynd: ",
"Top enabled: ": "Toppur virkur? ",
"CAPTCHA enabled: ": "CAPTCHA virk? ",
"Login enabled: ": "Innskráning virk? ",
@@ -127,25 +110,12 @@
"Subscription manager": "Áskriftarstjóri",
"Token manager": "Táknstjóri",
"Token": "Tákn",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áskriftur",
- "": "`x` áskriftir"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tákn",
- "": "`x` tákn"
- },
"Import/export": "Flytja inn/út",
"unsubscribe": "afskrá",
"revoke": "afturkalla",
"Subscriptions": "Áskriftir",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` óséðar tilkynning",
- "": "`x` óséðar tilkynningar"
- },
"search": "leita",
"Log out": "Útskrá",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Frumkóði aðgengilegur hér.",
"View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.",
"View privacy policy.": "Skoða meðferð persónuupplýsinga.",
@@ -161,11 +131,7 @@
"Title": "Titill",
"Playlist privacy": "Spilunarlista opinberri",
"Editing playlist `x`": "Að breyta spilunarlista `x`",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "Horfa á YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Fela glósur",
"Show annotations": "Sýna glósur",
"Genre: ": "Tegund: ",
@@ -176,10 +142,6 @@
"Whitelisted regions: ": "Svæði á hvítum lista: ",
"Blacklisted regions: ": "Svæði á svörtum lista: ",
"Shared `x`": "Deilt `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áhorf",
- "": "`x` áhorf"
- },
"Premieres in `x`": "Frumflutt eftir `x`",
"Premieres `x`": "Frumflutt `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.": "Hæ! Lítur út eins og þú hafir slökkt á JavaScript. Smelltu hér til að skoða ummæli, hafðu í huga að þær geta tekið aðeins lengri tíma að hlaða.",
@@ -213,16 +175,8 @@
"This channel does not exist.": "Þessi rás er ekki til.",
"Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.",
"Could not fetch comments": "Ekki tókst að sækja ummæli",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Skoða `x` svar",
- "": "Skoða `x` svör"
- },
"`x` ago": "`x` síðan",
"Load more": "Hlaða meira",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` stig",
- "": "`x` stig"
- },
"Could not create mix.": "Ekki tókst að búa til blöndu.",
"Empty playlist": "Tómur spilunarlisti",
"Not a playlist.": "Ekki spilunarlisti.",
@@ -340,41 +294,12 @@
"Yiddish": "Jiddíska",
"Yoruba": "Jórúba",
"Zulu": "Zúlú",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ár",
- "": "`x` ár"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mánuð",
- "": "`x` mánuði"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vika",
- "": "`x` vikur"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagur",
- "": "`x` dagar"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` klukkustund",
- "": "`x` klukkustundir"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mínúta",
- "": "`x` mínútur"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekúnda",
- "": "`x` sekúndur"
- },
"Fallback comments: ": "Vara ummæli: ",
"Popular": "Vinsælt",
- "Search": "",
"Top": "Topp",
"About": "Um",
"Rating: ": "Einkunn: ",
- "Language: ": "Tungumál: ",
+ "preferences_locale_label": "Tungumál: ",
"View as playlist": "Skoða sem spilunarlista",
"Default": "Sjálfgefið",
"Music": "Tónlist",
@@ -393,35 +318,5 @@
"Videos": "Myndbönd",
"Playlists": "Spilunarlistar",
"Community": "Samfélag",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "Núverandi útgáfa: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "Núverandi útgáfa: "
}
diff --git a/locales/it.json b/locales/it.json
index df3642db..c80f4d96 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -1,16 +1,10 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto",
- "": "`x` iscritti"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` video"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
- "": "`x` playlist"
- },
+ "generic_subscribers_count": "{{count}} iscritto",
+ "generic_subscribers_count_plural": "{{count}} iscritti",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} video",
+ "generic_playlists_count": "{{count}} playlist",
+ "generic_playlists_count_plural": "{{count}} playlist",
"LIVE": "IN DIRETTA",
"Shared `x` ago": "Condiviso `x` fa",
"Unsubscribe": "Disiscriviti",
@@ -60,39 +54,35 @@
"E-mail": "E-mail",
"Google verification code": "Codice di verifica Google",
"Preferences": "Preferenze",
- "Player preferences": "Preferenze del riproduttore",
- "Always loop: ": "Ripeti sempre: ",
- "Autoplay: ": "Riproduzione automatica: ",
- "Play next by default: ": "Riproduzione successiva predefinita: ",
- "Autoplay next video: ": "Riproduci automaticamente il video successivo: ",
- "Listen by default: ": "Modalità solo audio predefinita: ",
- "Proxy videos: ": "Proxy per i video: ",
- "Default speed: ": "Velocità predefinita: ",
- "Preferred video quality: ": "Qualità video preferita: ",
- "Player volume: ": "Volume di riproduzione: ",
- "Default comments: ": "Origine dei commenti: ",
+ "preferences_category_player": "Preferenze del riproduttore",
+ "preferences_video_loop_label": "Ripeti sempre: ",
+ "preferences_autoplay_label": "Riproduzione automatica: ",
+ "preferences_continue_label": "Riproduzione successiva predefinita: ",
+ "preferences_continue_autoplay_label": "Riproduci automaticamente il video successivo: ",
+ "preferences_listen_label": "Modalità solo audio predefinita: ",
+ "preferences_local_label": "Proxy per i video: ",
+ "preferences_speed_label": "Velocità predefinita: ",
+ "preferences_quality_label": "Qualità video preferita: ",
+ "preferences_volume_label": "Volume di riproduzione: ",
+ "preferences_comments_label": "Origine dei commenti: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Sottotitoli predefiniti: ",
+ "preferences_captions_label": "Sottotitoli predefiniti: ",
"Fallback captions: ": "Sottotitoli alternativi: ",
- "Show related videos: ": "Mostra video correlati: ",
- "Show annotations by default: ": "Mostra le annotazioni in modo predefinito: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Preferenze grafiche",
- "Player style: ": "Stile riproduttore: ",
+ "preferences_related_videos_label": "Mostra video correlati: ",
+ "preferences_annotations_label": "Mostra le annotazioni in modo predefinito: ",
+ "preferences_category_visual": "Preferenze grafiche",
+ "preferences_player_style_label": "Stile riproduttore: ",
"Dark mode: ": "Tema scuro: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "scuro",
"light": "chiaro",
- "Thin mode: ": "Modalità per connessioni lente: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Preferenze iscrizioni",
- "Show annotations by default for subscribed channels: ": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ",
+ "preferences_thin_mode_label": "Modalità per connessioni lente: ",
+ "preferences_category_subscription": "Preferenze iscrizioni",
+ "preferences_annotations_subscribed_label": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ",
"Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ",
- "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ",
- "Sort videos by: ": "Ordina i video per: ",
+ "preferences_max_results_label": "Numero di video da mostrare nelle iscrizioni: ",
+ "preferences_sort_label": "Ordina i video per: ",
"published": "data di pubblicazione",
"published - reverse": "data di pubblicazione - decrescente",
"alphabetically": "ordine alfabetico",
@@ -101,12 +91,12 @@
"channel name - reverse": "nome del canale - decrescente",
"Only show latest video from channel: ": "Mostra solo il video più recente del canale: ",
"Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ",
- "Only show unwatched: ": "Mostra solo i video non guardati: ",
- "Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ",
+ "preferences_unseen_only_label": "Mostra solo i video non guardati: ",
+ "preferences_notifications_only_label": "Mostra solo le notifiche (se presenti): ",
"Enable web notifications": "Attiva le notifiche web",
"`x` uploaded a video": "`x` ha caricato un video",
"`x` is live": "`x` è in diretta",
- "Data preferences": "Preferenze dati",
+ "preferences_category_data": "Preferenze dati",
"Clear watch history": "Cancella la cronologia dei video guardati",
"Import/export data": "Importazione/esportazione dati",
"Change password": "Modifica password",
@@ -114,10 +104,9 @@
"Manage tokens": "Gestisci i gettoni",
"Watch history": "Cronologia dei video",
"Delete account": "Elimina l'account",
- "Administrator preferences": "Preferenze amministratore",
- "Default homepage: ": "Pagina principale predefinita: ",
- "Feed menu: ": "Menu iscrizioni: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Preferenze amministratore",
+ "preferences_default_home_label": "Pagina principale predefinita: ",
+ "preferences_feed_menu_label": "Menu iscrizioni: ",
"Top enabled: ": "Top abilitato: ",
"CAPTCHA enabled: ": "CAPTCHA attivati: ",
"Login enabled: ": "Accesso attivato: ",
@@ -127,25 +116,18 @@
"Subscription manager": "Gestione delle iscrizioni",
"Token manager": "Gestione dei gettoni",
"Token": "Gettone",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione",
- "": "`x` iscrizioni"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone",
- "": "`x` gettoni"
- },
+ "generic_subscriptions_count": "{{count}} iscrizione",
+ "generic_subscriptions_count_plural": "{{count}} iscrizioni",
+ "tokens_count": "{{count}} gettone",
+ "tokens_count_plural": "{{count}} gettoni",
"Import/export": "Importa/esporta",
"unsubscribe": "disiscriviti",
"revoke": "revoca",
"Subscriptions": "Iscrizioni",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata",
- "": "`x` notifiche non visualizzate"
- },
+ "subscriptions_unseen_notifs_count": "{{count}} notifica non visualizzata",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} notifiche non visualizzate",
"search": "Cerca",
"Log out": "Esci",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Codice sorgente.",
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
"View privacy policy.": "Vedi la politica sulla privacy.",
@@ -161,11 +143,7 @@
"Title": "Titolo",
"Playlist privacy": "Privacy playlist",
"Editing playlist `x`": "Modificando la playlist `x`",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "Guarda su YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Nascondi annotazioni",
"Show annotations": "Mostra annotazioni",
"Genre: ": "Genere: ",
@@ -176,10 +154,8 @@
"Whitelisted regions: ": "Regioni in lista bianca: ",
"Blacklisted regions: ": "Regioni in lista nera: ",
"Shared `x`": "Condiviso `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione",
- "": "`x` visualizzazioni"
- },
+ "generic_views_count": "{{count}} visualizzazione",
+ "generic_views_count_plural": "{{count}} visualizzazioni",
"Premieres in `x`": "In anteprima in `x`",
"Premieres `x`": "In anteprima `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.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.",
@@ -213,16 +189,8 @@
"This channel does not exist.": "Questo canale non esiste.",
"Could not get channel info.": "Impossibile ottenere le informazioni del canale.",
"Could not fetch comments": "Impossibile recuperare i commenti",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta",
- "": "Visualizza `x` risposte"
- },
"`x` ago": "`x` fa",
"Load more": "Carica altro",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto",
- "": "`x` punti"
- },
"Could not create mix.": "Impossibile creare il mix.",
"Empty playlist": "Playlist vuota",
"Not a playlist.": "Non è una playlist.",
@@ -340,41 +308,27 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno",
- "": "`x` anni"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese",
- "": "`x` mesi"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana",
- "": "`x` settimane"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno",
- "": "`x` giorni"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora",
- "": "`x` ore"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto",
- "": "`x` minuti"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo",
- "": "`x` secondi"
- },
+ "generic_count_years": "{{count}} anno",
+ "generic_count_years_plural": "{{count}} anni",
+ "generic_count_months": "{{count}} mese",
+ "generic_count_months_plural": "{{count}} mesi",
+ "generic_count_weeks": "{{count}} settimana",
+ "generic_count_weeks_plural": "{{count}} settimane",
+ "generic_count_days": "{{count}} giorno",
+ "generic_count_days_plural": "{{count}} giorni",
+ "generic_count_hours": "{{count}} ora",
+ "generic_count_hours_plural": "{{count}} ore",
+ "generic_count_minutes": "{{count}} minuto",
+ "generic_count_minutes_plural": "{{count}} minuti",
+ "generic_count_seconds": "{{count}} secondo",
+ "generic_count_seconds_plural": "{{count}} secondi",
"Fallback comments: ": "Commenti alternativi: ",
"Popular": "Popolare",
"Search": "Cerca",
"Top": "Top",
"About": "Al riguardo",
"Rating: ": "Punteggio: ",
- "Language: ": "Lingua: ",
+ "preferences_locale_label": "Lingua: ",
"View as playlist": "Vedi come playlist",
"Default": "Predefinito",
"Music": "Musica",
@@ -410,7 +364,6 @@
"channel": "Canale",
"playlist": "Playlist",
"movie": "Film",
- "show": "",
"hd": "AD",
"subtitles": "Sottotitoli / CC",
"creative_commons": "Creative Commons",
@@ -421,7 +374,23 @@
"hdr": "HDR",
"filter": "Filtra",
"Current version: ": "Versione attuale: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "360": "360°",
+ "preferences_quality_dash_option_144p": "144p",
+ "Released under the AGPLv3 on Github.": "Rilasciato su Github con licenza AGPLv3.",
+ "preferences_quality_option_medium": "Media",
+ "preferences_quality_option_small": "Piccola",
+ "preferences_quality_dash_option_best": "Migliore",
+ "preferences_quality_dash_option_worst": "Peggiore",
+ "invidious": "Invidious",
+ "preferences_quality_dash_label": "Qualità video DASH preferita ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_auto": "Automatica"
}
diff --git a/locales/ja.json b/locales/ja.json
index c4f78f96..e3014152 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -1,16 +1,9 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 人の登録者",
- "": "`x` 人の登録者"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の動画",
- "": "`x` 個の動画"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の再生リスト",
- "": "`x` 個の再生リスト"
- },
+ "generic_views_count_0": "{{count}} 回視聴",
+ "generic_videos_count_0": "{{count}} 個の動画",
+ "generic_playlists_count_0": "{{count}} 個の再生リスト",
+ "generic_subscribers_count_0": "{{count}} 人の登録者",
+ "generic_subscriptions_count_0": "{{count}} 個の登録チャンネル",
"LIVE": "ライブ",
"Shared `x` ago": "`x`前に共有",
"Unsubscribe": "登録解除",
@@ -60,39 +53,39 @@
"E-mail": "メールアドレス",
"Google verification code": "Google 認証コード",
"Preferences": "設定",
- "Player preferences": "プレイヤー設定",
- "Always loop: ": "常にループ: ",
- "Autoplay: ": "自動再生: ",
- "Play next by default: ": "デフォルトで次を再生: ",
- "Autoplay next video: ": "次の動画を自動再生: ",
- "Listen by default: ": "デフォルトでオーディオモードを使用: ",
- "Proxy videos: ": "動画をプロキシーに通す: ",
- "Default speed: ": "デフォルトの再生速度: ",
- "Preferred video quality: ": "優先する画質: ",
- "Player volume: ": "プレイヤーの音量: ",
- "Default comments: ": "デフォルトのコメント: ",
+ "preferences_category_player": "プレイヤー設定",
+ "preferences_video_loop_label": "常にループ: ",
+ "preferences_autoplay_label": "自動再生: ",
+ "preferences_continue_label": "デフォルトで次を再生: ",
+ "preferences_continue_autoplay_label": "次の動画を自動再生: ",
+ "preferences_listen_label": "デフォルトでオーディオモードを使用: ",
+ "preferences_local_label": "動画をプロキシーに通す: ",
+ "preferences_speed_label": "デフォルトの再生速度: ",
+ "preferences_quality_label": "優先する画質: ",
+ "preferences_volume_label": "プレイヤーの音量: ",
+ "preferences_comments_label": "デフォルトのコメント: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "デフォルトの字幕: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "デフォルトの字幕: ",
"Fallback captions: ": "フォールバック時の字幕: ",
- "Show related videos: ": "関連動画を表示: ",
- "Show annotations by default: ": "デフォルトでアノテーションを表示: ",
- "Automatically extend video description: ": "動画の説明文を自動的に拡張: ",
- "Interactive 360 degree videos: ": "インタラクティブ360°動画: ",
- "Visual preferences": "外観設定",
- "Player style: ": "プレイヤースタイル: ",
+ "preferences_related_videos_label": "関連動画を表示: ",
+ "preferences_annotations_label": "デフォルトでアノテーションを表示: ",
+ "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ",
+ "preferences_vr_mode_label": "対話的な360°動画: ",
+ "preferences_category_visual": "外観設定",
+ "preferences_player_style_label": "プレイヤースタイル: ",
"Dark mode: ": "ダークモード: ",
- "Theme: ": "テーマ: ",
+ "preferences_dark_mode_label": "テーマ: ",
"dark": "ダーク",
"light": "ライト",
- "Thin mode: ": "最小モード: ",
- "Miscellaneous preferences": "雑設定",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動インスタンスの移転(redirect.invidious.ioにフォールバック): ",
- "Subscription preferences": "登録チャンネル設定",
- "Show annotations by default for subscribed channels: ": "デフォルトで登録チャンネルのアノテーションを表示しますか? ",
+ "preferences_thin_mode_label": "最小モード: ",
+ "preferences_category_misc": "雑設定",
+ "preferences_automatic_instance_redirect_label": "自動的なインスタンスの移転(redirect.invidious.ioにフォールバック): ",
+ "preferences_category_subscription": "登録チャンネル設定",
+ "preferences_annotations_subscribed_label": "デフォルトで登録チャンネルのアノテーションを表示しますか? ",
"Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ",
- "Number of videos shown in feed: ": "フィードに表示する動画の量: ",
- "Sort videos by: ": "動画を並び替え: ",
+ "preferences_max_results_label": "フィードに表示する動画の量: ",
+ "preferences_sort_label": "動画を並び替え: ",
"published": "投稿日",
"published - reverse": "投稿日 - 逆順",
"alphabetically": "アルファベット",
@@ -101,12 +94,12 @@
"channel name - reverse": "チャンネル名 - 逆順",
"Only show latest video from channel: ": "チャンネルの最新動画のみを表示: ",
"Only show latest unwatched video from channel: ": "チャンネルの最新未視聴動画のみを表示: ",
- "Only show unwatched: ": "未視聴のみを表示: ",
- "Only show notifications (if there are any): ": "通知のみを表示 (ある場合): ",
+ "preferences_unseen_only_label": "未視聴のみを表示: ",
+ "preferences_notifications_only_label": "通知のみを表示 (ある場合): ",
"Enable web notifications": "ウェブ通知を有効化",
"`x` uploaded a video": "`x` が動画を投稿しました",
"`x` is live": "`x` がライブ中です",
- "Data preferences": "データ設定",
+ "preferences_category_data": "データ設定",
"Clear watch history": "再生履歴の削除",
"Import/export data": "データのインポート/エクスポート",
"Change password": "パスワードを変更",
@@ -114,10 +107,10 @@
"Manage tokens": "トークンを管理",
"Watch history": "再生履歴",
"Delete account": "アカウントを削除",
- "Administrator preferences": "管理者設定",
- "Default homepage: ": "デフォルトのホーム: ",
- "Feed menu: ": "フィードメニュー: ",
- "Show nickname on top: ": "ニックネームを一番上に表示する: ",
+ "preferences_category_admin": "管理者設定",
+ "preferences_default_home_label": "デフォルトのホーム: ",
+ "preferences_feed_menu_label": "フィードメニュー: ",
+ "preferences_show_nick_label": "ニックネームを一番上に表示する: ",
"Top enabled: ": "トップページを有効化: ",
"CAPTCHA enabled: ": "CAPTCHA を有効化: ",
"Login enabled: ": "ログインを有効化: ",
@@ -127,25 +120,15 @@
"Subscription manager": "登録チャンネルマネージャー",
"Token manager": "トークンマネージャー",
"Token": "トークン",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の登録チャンネル",
- "": "`x` 個の登録チャンネル"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個のトークン",
- "": "`x` 個のトークン"
- },
+ "tokens_count_0": "{{count}} 個のトークン",
"Import/export": "インポート/エクスポート",
"unsubscribe": "登録解除",
- "revoke": "revoke",
+ "revoke": "取り消す",
"Subscriptions": "登録チャンネル",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の未読通知",
- "": "`x` 個の未読通知"
- },
+ "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.": "プライバシーポリシー",
@@ -176,10 +159,6 @@
"Whitelisted regions: ": "ホワイトリストの地域: ",
"Blacklisted regions: ": "ブラックリストの地域: ",
"Shared `x`": "`x`に共有",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 回視聴",
- "": "`x` 回視聴"
- },
"Premieres in `x`": "`x`後にプレミア公開",
"Premieres `x`": "`x`にプレミア公開",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "やあ!君は JavaScript を無効にしているのかな?ここをクリックしてコメントを見れるけど、読み込みには少し時間がかかることがあるのを覚えておいてね。",
@@ -213,16 +192,10 @@
"This channel does not exist.": "このチャンネルは存在しません。",
"Could not get channel info.": "チャンネル情報を取得できませんでした。",
"Could not fetch comments": "コメントを取得できませんでした",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件の返信を見る",
- "": "`x` 件の返信を見る"
- },
+ "comments_view_x_replies_0": "{{count}} 件の返信を見る",
"`x` ago": "`x`前",
"Load more": "もっと読み込む",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ポイント",
- "": "`x` ポイント"
- },
+ "comments_points_count_0": "{{count}} ポイント",
"Could not create mix.": "ミックスを作成できませんでした。",
"Empty playlist": "空の再生リスト",
"Not a playlist.": "再生リストではありません。",
@@ -340,41 +313,20 @@
"Yiddish": "イディッシュ語",
"Yoruba": "ヨルバ語",
"Zulu": "ズール語",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`年",
- "": "`x`年"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`ヶ月",
- "": "`x`ヶ月"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`週",
- "": "`x`週"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`日",
- "": "`x`日"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`時間",
- "": "`x`時間"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`分",
- "": "`x`分"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x`秒",
- "": "`x`秒"
- },
+ "generic_count_years_0": "{{count}}年",
+ "generic_count_months_0": "{{count}}ヶ月",
+ "generic_count_weeks_0": "{{count}}週",
+ "generic_count_days_0": "{{count}}日",
+ "generic_count_hours_0": "{{count}}時間",
+ "generic_count_minutes_0": "{{count}}分",
+ "generic_count_seconds_0": "{{count}}秒",
"Fallback comments: ": "フォールバック時のコメント: ",
"Popular": "人気",
"Search": "検索",
"Top": "トップ",
"About": "このサービスについて",
"Rating: ": "評価: ",
- "Language: ": "言語: ",
+ "preferences_locale_label": "言語: ",
"View as playlist": "再生リストで見る",
"Default": "デフォルト",
"Music": "音楽",
@@ -423,5 +375,41 @@
"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へ",
+ "short": "4 分未満",
+ "footer_documentation": "文書",
+ "footer_source_code": "ソースコード",
+ "footer_original_source_code": "ソースコード(元)",
+ "footer_modfied_source_code": "ソースコード(編集)",
+ "adminprefs_modified_source_code_url_label": "編集したソースコードのレポジトリーURL",
+ "long": "20 分以上",
+ "preferences_region_label": "地域: ",
+ "footer_donate_page": "寄付する",
+ "preferences_quality_dash_label": "優先するDash画質 : ",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "中",
+ "preferences_quality_option_small": "小",
+ "invidious": "Invidious",
+ "preferences_quality_dash_option_auto": "自動",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_480p": "480p",
+ "videoinfo_youTube_embed_link": "埋め込み",
+ "videoinfo_invidious_embed_link": "埋め込みリンク",
+ "none": "なし",
+ "download_subtitles": "字幕 - `x` (.vtt)",
+ "purchased": "購入済み",
+ "preferences_quality_option_dash": "DASH (適切な品質)",
+ "preferences_quality_dash_option_worst": "最悪",
+ "preferences_quality_dash_option_best": "最高",
+ "videoinfo_started_streaming_x_ago": "`x`分前に配信を開始",
+ "videoinfo_watch_on_youTube": "YouTube上で見る",
+ "user_created_playlists": "`x`が作成したプレイリスト",
+ "Video unavailable": "ビデオは利用できません"
}
diff --git a/locales/ko.json b/locales/ko.json
index 94f781d4..a579fe56 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -1,42 +1,42 @@
{
- "Sort videos by: ": "동영상 정렬 기준: ",
- "Number of videos shown in feed: ": "피드에 표시된 동영상 수: ",
+ "preferences_sort_label": "동영상 정렬 기준: ",
+ "preferences_max_results_label": "피드에 표시된 동영상 수: ",
"Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ",
- "Show annotations by default for subscribed channels: ": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ",
- "Subscription preferences": "구독 설정",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ",
- "Thin mode: ": "단순 모드: ",
+ "preferences_annotations_subscribed_label": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ",
+ "preferences_category_subscription": "구독 설정",
+ "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ",
+ "preferences_thin_mode_label": "단순 모드: ",
"light": "라이트",
"dark": "다크",
- "Theme: ": "테마: ",
+ "preferences_dark_mode_label": "테마: ",
"Dark mode: ": "다크 모드: ",
- "Player style: ": "플레이어 스타일: ",
- "Visual preferences": "시각 설정",
- "Interactive 360 degree videos: ": "인터랙티브 360도 비디오: ",
- "Automatically extend video description: ": "자동으로 비디오 설명 확장: ",
- "Show annotations by default: ": "기본적으로 주석 표시: ",
- "Show related videos: ": "관련 동영상 보기: ",
+ "preferences_player_style_label": "플레이어 스타일: ",
+ "preferences_category_visual": "시각 설정",
+ "preferences_vr_mode_label": "인터랙티브 360도 비디오: ",
+ "preferences_extend_desc_label": "자동으로 비디오 설명 확장: ",
+ "preferences_annotations_label": "기본적으로 주석 표시: ",
+ "preferences_related_videos_label": "관련 동영상 보기: ",
"Fallback captions: ": "대체 자막: ",
- "Default captions: ": "기본 자막: ",
+ "preferences_captions_label": "기본 자막: ",
"reddit": "Reddit",
"youtube": "YouTube",
- "Default comments: ": "기본 댓글: ",
- "Player volume: ": "플레이어 볼륨: ",
- "Preferred video quality: ": "선호하는 비디오 품질: ",
- "Default speed: ": "기본 속도: ",
- "Proxy videos: ": "비디오를 프록시: ",
- "Listen by default: ": "기본적으로 듣기: ",
- "Autoplay next video: ": "다음 동영상 자동재생 ",
- "Play next by default: ": "기본적으로 다음 재생: ",
- "Autoplay: ": "자동재생: ",
- "Always loop: ": "항상 반복: ",
- "Player preferences": "플레이어 설정",
+ "preferences_comments_label": "기본 댓글: ",
+ "preferences_volume_label": "플레이어 볼륨: ",
+ "preferences_quality_label": "선호하는 비디오 품질: ",
+ "preferences_speed_label": "기본 속도: ",
+ "preferences_local_label": "비디오를 프록시: ",
+ "preferences_listen_label": "기본적으로 듣기: ",
+ "preferences_continue_autoplay_label": "다음 동영상 자동재생 ",
+ "preferences_continue_label": "기본적으로 다음 재생: ",
+ "preferences_autoplay_label": "자동재생: ",
+ "preferences_video_loop_label": "항상 반복: ",
+ "preferences_category_player": "플레이어 설정",
"Preferences": "설정",
"Google verification code": "구글 인증 코드",
"E-mail": "이메일",
"Register": "회원가입",
"Sign In": "로그인",
- "Miscellaneous preferences": "기타 설정",
+ "preferences_category_misc": "기타 설정",
"Image CAPTCHA": "이미지 CAPTCHA",
"Text CAPTCHA": "텍스트 CAPTCHA",
"Time (h:mm:ss):": "시각 (h:mm:ss):",
@@ -81,18 +81,11 @@
"Subscribe": "구독",
"Unsubscribe": "구독 취소",
"LIVE": "실시간",
- "`x` playlists": {
- "": "`x` 재생목록",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 재생목록"
- },
- "`x` videos": {
- "": "`x` 동영상",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 동영상"
- },
- "`x` subscribers": {
- "": "`x` 구독자",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 구독자"
- },
+ "generic_views_count_0": "{{count}} 조회수",
+ "generic_videos_count_0": "{{count}} 동영상",
+ "generic_playlists_count_0": "{{count}} 재생목록",
+ "generic_subscribers_count_0": "{{count}} 구독자",
+ "generic_subscriptions_count_0": "{{count}} 구독",
"playlist": "재생목록",
"Korean": "한국어",
"Japanese": "일본어",
@@ -136,7 +129,7 @@
"Delete playlist": "재생목록 삭제",
"Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?",
"Updated `x` ago": "`x` 전에 업데이트됨",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Github에 AGPLv3 으로 배포됩니다.",
"View all playlists": "모든 재생목록 보기",
"Private": "비공개",
"Unlisted": "목록에 없음",
@@ -146,22 +139,12 @@
"Source available here.": "소스는 여기에서 사용할 수 있습니다.",
"Log out": "로그아웃",
"search": "검색",
- "`x` unseen notifications": {
- "": "`x` 읽지 않은 알림",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 읽지 않은 알림"
- },
+ "subscriptions_unseen_notifs_count_0": "{{count}} 읽지 않은 알림",
"Subscriptions": "구독",
"revoke": "철회",
"unsubscribe": "구독 취소",
"Import/export": "가져오기/내보내기",
- "`x` tokens": {
- "": "`x` 토큰",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 토큰"
- },
- "`x` subscriptions": {
- "": "`x` 구독",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 구독"
- },
+ "tokens_count_0": "{{count}} 토큰",
"Token": "토큰",
"Token manager": "토큰 관리자",
"Subscription manager": "구독 관리자",
@@ -171,10 +154,10 @@
"Login enabled: ": "로그인 활성화: ",
"CAPTCHA enabled: ": "CAPTCHA 활성화: ",
"Top enabled: ": "Top 활성화: ",
- "Show nickname on top: ": "상단에 닉네임 표시: ",
- "Feed menu: ": "피드 메뉴: ",
- "Default homepage: ": "기본 홈페이지: ",
- "Administrator preferences": "관리자 설정",
+ "preferences_show_nick_label": "상단에 닉네임 표시: ",
+ "preferences_feed_menu_label": "피드 메뉴: ",
+ "preferences_default_home_label": "기본 홈페이지: ",
+ "preferences_category_admin": "관리자 설정",
"Delete account": "계정 삭제",
"Watch history": "시청 기록",
"Manage tokens": "토큰 관리",
@@ -182,12 +165,12 @@
"Change password": "비밀번호 변경",
"Import/export data": "데이터 가져오기/내보내기",
"Clear watch history": "시청 기록 지우기",
- "Data preferences": "데이터 설정",
+ "preferences_category_data": "데이터 설정",
"`x` is live": "`x` 이(가) 라이브 중입니다",
"`x` uploaded a video": "`x` 동영상 게시됨",
"Enable web notifications": "웹 알림 활성화",
- "Only show notifications (if there are any): ": "알림만 표시 (있는 경우): ",
- "Only show unwatched: ": "시청하지 않은 것만 표시: ",
+ "preferences_notifications_only_label": "알림만 표시 (있는 경우): ",
+ "preferences_unseen_only_label": "시청하지 않은 것만 표시: ",
"Only show latest unwatched video from channel: ": "채널의 시청하지 않은 최신 동영상만 표시: ",
"Only show latest video from channel: ": "채널의 최신 동영상만 표시: ",
"channel name - reverse": "채널 이름 - 역순",
@@ -226,7 +209,7 @@
"Download as: ": "다음으로 다운로드: ",
"Download": "다운로드",
"Search": "검색",
- "Language: ": "언어: ",
+ "preferences_locale_label": "언어: ",
"Malayalam": "말라얄람어",
"Malay": "말레이어",
"Malagasy": "말라가시어",
@@ -262,10 +245,7 @@
"Could not pull trending pages.": "인기 급상승 페이지를 가져올 수 없습니다.",
"Could not create mix.": "믹스를 생성할 수 없습니다.",
"`x` ago": "`x` 전",
- "View `x` replies": {
- "": "답글 `x`개 보기",
- "([^.,0-9]|^)1([^.,0-9]|$)": "답글 `x`개 보기"
- },
+ "comments_view_x_replies_0": "답글 {{count}}개 보기",
"View Reddit comments": "Reddit의 댓글 보기",
"Engagement: ": "약속: ",
"Wilson score: ": "Wilson Score: ",
@@ -300,10 +280,6 @@
"Shared `x`": "공유된 `x`",
"Whitelisted regions: ": "차단되지 않은 지역: ",
"views": "조회수",
- "`x` views": {
- "": "`x` 조회수",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 조회수"
- },
"Please log in": "로그인하세요",
"Password cannot be longer than 55 characters": "비밀번호는 55자 이하여야 합니다",
"Password cannot be empty": "비밀번호는 비워둘 수 없습니다",
@@ -336,36 +312,15 @@
"Scottish Gaelic": "스코틀랜드 게일어",
"Popular": "인기",
"Fallback comments: ": "대체 댓글: ",
- "`x` seconds": {
- "": "`x` 초",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 초"
- },
"Swahili": "스와힐리어",
"Sundanese": "순다어",
- "`x` hours": {
- "": "`x` 시",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 시"
- },
- "`x` minutes": {
- "": "`x` 분",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 분"
- },
- "`x` days": {
- "": "`x` 일",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 일"
- },
- "`x` weeks": {
- "": "`x` 주",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 주"
- },
- "`x` months": {
- "": "`x` 월",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 월"
- },
- "`x` years": {
- "": "`x` 년",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 년"
- },
+ "generic_count_years_0": "{{count}} 년",
+ "generic_count_months_0": "{{count}} 월",
+ "generic_count_weeks_0": "{{count}} 주",
+ "generic_count_days_0": "{{count}} 일",
+ "generic_count_hours_0": "{{count}} 시",
+ "generic_count_minutes_0": "{{count}} 분",
+ "generic_count_seconds_0": "{{count}} 초",
"Zulu": "줄루어",
"Yoruba": "요루바어",
"Yiddish": "이디시어",
@@ -383,10 +338,7 @@
"Tajik": "타지크어",
"Swedish": "스웨덴어",
"Spanish (Latin America)": "스페인어 (라틴 아메리카)",
- "`x` points": {
- "": "`x` 포인트",
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 포인트"
- },
+ "comments_points_count_0": "{{count}} 포인트",
"Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드",
"Premieres `x`": "최초 공개 `x`",
"Premieres in `x`": "`x` 에 최초 공개",
@@ -423,5 +375,12 @@
"today": "오늘",
"hour": "지난 1시간",
"sort": "정렬기준",
- "features": "기능별"
+ "features": "기능별",
+ "short": "4분 미만",
+ "long": "20분 초과",
+ "footer_documentation": "문서",
+ "footer_source_code": "소스 코드",
+ "footer_original_source_code": "원본 소스 코드",
+ "footer_modfied_source_code": "수정된 소스 코드",
+ "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL"
}
diff --git a/locales/lt.json b/locales/lt.json
index e8e84dcf..5b27eae4 100644
--- a/locales/lt.json
+++ b/locales/lt.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumeratorius",
- "": "`x` prenumeratoriai"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vaizdo įrašas",
- "": "`x` vaizdo įrašai"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` grojaraštis",
- "": "`x` grojaraščiai"
- },
"LIVE": "LIVE",
"Shared `x` ago": "Pasidalino prieš `x`",
"Unsubscribe": "Atšaukti prenumeratą",
@@ -60,39 +48,39 @@
"E-mail": "El. paštas",
"Google verification code": "Google patvirtinimo kodas",
"Preferences": "Pasirinktys",
- "Player preferences": "Grotuvo pasirinktys",
- "Always loop: ": "Visada kartoti: ",
- "Autoplay: ": "Leisti automatiškai: ",
- "Play next by default: ": "Leisti sekantį automatiškai kaip nustatyta: ",
- "Autoplay next video: ": "Automatiškai leisti sekantį vaizdo įrašą: ",
- "Listen by default: ": "Klausytis kaip nustatyta: ",
- "Proxy videos: ": "Vaizdo įrašams naudoti proxy: ",
- "Default speed: ": "Numatytasis greitis: ",
- "Preferred video quality: ": "Pageidaujama vaizdo kokybė: ",
- "Player volume: ": "Grotuvo garsas: ",
- "Default comments: ": "Numatytieji komentarai: ",
+ "preferences_category_player": "Grotuvo pasirinktys",
+ "preferences_video_loop_label": "Visada kartoti: ",
+ "preferences_autoplay_label": "Leisti automatiškai: ",
+ "preferences_continue_label": "Leisti sekantį automatiškai kaip nustatyta: ",
+ "preferences_continue_autoplay_label": "Automatiškai leisti sekantį vaizdo įrašą: ",
+ "preferences_listen_label": "Klausytis kaip nustatyta: ",
+ "preferences_local_label": "Vaizdo įrašams naudoti proxy: ",
+ "preferences_speed_label": "Numatytasis greitis: ",
+ "preferences_quality_label": "Pageidaujama vaizdo kokybė: ",
+ "preferences_volume_label": "Grotuvo garsas: ",
+ "preferences_comments_label": "Numatytieji komentarai: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Numatytieji subtitrai: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Numatytieji subtitrai: ",
"Fallback captions: ": "Atsarginiai subtitrai: ",
- "Show related videos: ": "Rodyti susijusius vaizdo įrašus: ",
- "Show annotations by default: ": "Rodyti anotacijas pagal nutylėjimą: ",
- "Automatically extend video description: ": "Automatiškai išplėsti vaizdo įrašo aprašymą: ",
- "Interactive 360 degree videos: ": "Interaktyvūs 360 laipsnių vaizdo įrašai: ",
- "Visual preferences": "Vizualinės nuostatos",
- "Player style: ": "Vaizdo grotuvo stilius: ",
+ "preferences_related_videos_label": "Rodyti susijusius vaizdo įrašus: ",
+ "preferences_annotations_label": "Rodyti anotacijas pagal nutylėjimą: ",
+ "preferences_extend_desc_label": "Automatiškai išplėsti vaizdo įrašo aprašymą: ",
+ "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai: ",
+ "preferences_category_visual": "Vizualinės nuostatos",
+ "preferences_player_style_label": "Vaizdo grotuvo stilius: ",
"Dark mode: ": "Tamsus rėžimas: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "tamsi",
"light": "šviesi",
- "Thin mode: ": "Sugretintas rėžimas: ",
- "Miscellaneous preferences": "Įvairios nuostatos",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ",
- "Subscription preferences": "Prenumeratų nuostatos",
- "Show annotations by default for subscribed channels: ": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ",
+ "preferences_thin_mode_label": "Sugretintas rėžimas: ",
+ "preferences_category_misc": "Įvairios nuostatos",
+ "preferences_automatic_instance_redirect_label": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ",
+ "preferences_category_subscription": "Prenumeratų nuostatos",
+ "preferences_annotations_subscribed_label": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ",
"Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ",
- "Number of videos shown in feed: ": "Vaizdo įrašų kiekis kanalų sąraše: ",
- "Sort videos by: ": "Rūšiuoti vaizdo įrašus pagal: ",
+ "preferences_max_results_label": "Vaizdo įrašų kiekis kanalų sąraše: ",
+ "preferences_sort_label": "Rūšiuoti vaizdo įrašus pagal: ",
"published": "paskelbta",
"published - reverse": "paskelbta - atvirkštine tvarka",
"alphabetically": "pagal abėcėlę",
@@ -101,12 +89,12 @@
"channel name - reverse": "kanalo pavadinimas - atvirkštine tvarka",
"Only show latest video from channel: ": "Rodyti tik naujausius vaizdo įrašus iš kanalo: ",
"Only show latest unwatched video from channel: ": "Rodyti tik naujausius nežiūrėtus vaizdo įrašus iš kanalo: ",
- "Only show unwatched: ": "Rodyti tik nežiūrėtus: ",
- "Only show notifications (if there are any): ": "Rodyti tik pranešimus (jei yra): ",
+ "preferences_unseen_only_label": "Rodyti tik nežiūrėtus: ",
+ "preferences_notifications_only_label": "Rodyti tik pranešimus (jei yra): ",
"Enable web notifications": "Įgalinti žiniatinklio pranešimus",
"`x` uploaded a video": "`x` įkėlė vaizdo įrašą",
"`x` is live": "`x` transliuoja tiesiogiai",
- "Data preferences": "Duomenų parinktys",
+ "preferences_category_data": "Duomenų parinktys",
"Clear watch history": "Išvalyti žiūrėjimo istoriją",
"Import/export data": "Importuoti/ eksportuoti duomenis",
"Change password": "Pakeisti slaptažodį",
@@ -114,10 +102,10 @@
"Manage tokens": "Valdyti žetonus",
"Watch history": "Žiūrėjimo istorija",
"Delete account": "Ištrinti paskyrą",
- "Administrator preferences": "Administratoriaus nuostatos",
- "Default homepage: ": "Numatytasis pagrindinis puslapis ",
- "Feed menu: ": "Kanalų sąrašo meniu: ",
- "Show nickname on top: ": "Rodyti slapyvardį viršuje: ",
+ "preferences_category_admin": "Administratoriaus nuostatos",
+ "preferences_default_home_label": "Numatytasis pagrindinis puslapis ",
+ "preferences_feed_menu_label": "Kanalų sąrašo meniu: ",
+ "preferences_show_nick_label": "Rodyti slapyvardį viršuje: ",
"Top enabled: ": "Įgalinti viršų: ",
"CAPTCHA enabled: ": "Įgalinta CAPTCHA: ",
"Login enabled: ": "Įgalintas prisijungimas: ",
@@ -127,22 +115,10 @@
"Subscription manager": "Prenumeratų valdytojas",
"Token manager": "Žetonų valdytojas",
"Token": "Žetonas",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumerata",
- "": "`x` prenumeratos"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` žetonas",
- "": "`x` žetonai"
- },
"Import/export": "Importuoti/ eksportuoti",
"unsubscribe": "atšaukti prenumeratą",
"revoke": "atšaukti",
"Subscriptions": "Prenumeratos",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nematytas pranešimas",
- "": "`x` nematyti pranešimai"
- },
"search": "ieškoti",
"Log out": "Atsijungti",
"Released under the AGPLv3 on Github.": "Išleista pagal AGPLv3 licenciją Github.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Prieinantys regionai: ",
"Blacklisted regions: ": "Blokuojami regionai: ",
"Shared `x`": "Pasidalino `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` peržiūrų",
- "": "`x` peržiūrų"
- },
"Premieres in `x`": "Premjera už `x`",
"Premieres `x`": "Premjera`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.": "Sveiki! Atrodo, kad turite išjungę \"JavaScript\". Spauskite čia norėdami peržiūrėti komentarus, turėkite omenyje, kad jų įkėlimas gali užtrukti.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Šis kanalas neegzistuoja.",
"Could not get channel info.": "Nepavyko gauti kanalo informacijos.",
"Could not fetch comments": "Nepavyko atsiųsti komentarų",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` atsakymus",
- "": "Žiūrėti `x` atsakymus"
- },
"`x` ago": "`x` prieš",
"Load more": "Pakrauti daugiau",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` taškai",
- "": "`x` taškai"
- },
"Could not create mix.": "Nepavyko sukurti derinio.",
"Empty playlist": "Tuščias grojaraštis",
"Not a playlist.": "Ne grojaraštis.",
@@ -340,41 +304,13 @@
"Yiddish": "Jidiš",
"Yoruba": "Yorubiečių",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` metus",
- "": "`x` metus"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mėnesį",
- "": "`x` mėnesius"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` savaitę",
- "": "`x` savaites"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dieną",
- "": "`x` dienas"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` valandą",
- "": "`x` valandas"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutę",
- "": "`x` minutes"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundę",
- "": "`x` sekundes"
- },
"Fallback comments: ": "Atsarginiai komentarai: ",
"Popular": "Populiaru",
"Search": "Paieška",
"Top": "Top",
"About": "Apie",
"Rating: ": "Reitingas: ",
- "Language: ": "Kalba: ",
+ "preferences_locale_label": "Kalba: ",
"View as playlist": "Žiūrėti kaip grojaraštį",
"Default": "Numatytasis",
"Music": "Muzika",
@@ -423,5 +359,15 @@
"Current version: ": "Dabartinė versija: ",
"next_steps_error_message": "Po to turėtumėte pabandyti: ",
"next_steps_error_message_refresh": "Atnaujinti",
- "next_steps_error_message_go_to_youtube": "Eiti į YouTube"
+ "next_steps_error_message_go_to_youtube": "Eiti į YouTube",
+ "short": "Trumpas (< 4 minučių)",
+ "long": "Ilgas (> 20 minučių)",
+ "footer_documentation": "Dokumentacija",
+ "footer_source_code": "Pirminis kodas",
+ "footer_original_source_code": "Pradinis pirminis kodas",
+ "adminprefs_modified_source_code_url_label": "URL į pakeisto pirminio kodo repozitoriją",
+ "footer_modfied_source_code": "Pakeistas pirminis kodas",
+ "footer_donate_page": "Paaukoti",
+ "preferences_region_label": "Turinio šalis: ",
+ "preferences_quality_dash_label": "Pageidaujama DASH vaizdo kokybė: "
}
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index 9e39a6c7..d1ad9c7a 100644
--- a/locales/nb-NO.json
+++ b/locales/nb-NO.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnenter",
- "": "`x` abonnenter"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer",
- "": "`x` videoer"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spillelister",
- "": "`x` spillelister"
- },
"LIVE": "SANNTIDSVISNING",
"Shared `x` ago": "Delt for `x` siden",
"Unsubscribe": "Opphev abonnement",
@@ -60,39 +48,39 @@
"E-mail": "E-post",
"Google verification code": "Google-bekreftelseskode",
"Preferences": "Innstillinger",
- "Player preferences": "Avspillerinnstillinger",
- "Always loop: ": "Alltid gjenta: ",
- "Autoplay: ": "Autoavspilling: ",
- "Play next by default: ": "Spill neste som forvalg: ",
- "Autoplay next video: ": "Autospill neste video: ",
- "Listen by default: ": "Lytt som forvalg: ",
- "Proxy videos: ": "Mellomtjen videoer? ",
- "Default speed: ": "Forvalgt hastighet: ",
- "Preferred video quality: ": "Foretrukket videokvalitet: ",
- "Player volume: ": "Avspillerlydstyrke: ",
- "Default comments: ": "Forvalgte kommentarer: ",
+ "preferences_category_player": "Avspillerinnstillinger",
+ "preferences_video_loop_label": "Alltid gjenta: ",
+ "preferences_autoplay_label": "Autoavspilling: ",
+ "preferences_continue_label": "Spill neste som forvalg: ",
+ "preferences_continue_autoplay_label": "Autospill neste video: ",
+ "preferences_listen_label": "Lytt som forvalg: ",
+ "preferences_local_label": "Mellomtjen videoer? ",
+ "preferences_speed_label": "Forvalgt hastighet: ",
+ "preferences_quality_label": "Foretrukket videokvalitet: ",
+ "preferences_volume_label": "Avspillerlydstyrke: ",
+ "preferences_comments_label": "Forvalgte kommentarer: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Forvalgte undertitler: ",
+ "preferences_captions_label": "Forvalgte undertitler: ",
"Fallback captions: ": "Tilbakefallsundertitler: ",
- "Show related videos: ": "Vis relaterte videoer? ",
- "Show annotations by default: ": "Vis merknader som forvalg? ",
- "Automatically extend video description: ": "Utvid videobeskrivelse automatisk: ",
- "Interactive 360 degree videos: ": "Interaktive 360-gradersfilmer: ",
- "Visual preferences": "Visuelle innstillinger",
- "Player style: ": "Avspillerstil: ",
+ "preferences_related_videos_label": "Vis relaterte videoer? ",
+ "preferences_annotations_label": "Vis merknader som forvalg? ",
+ "preferences_extend_desc_label": "Utvid videobeskrivelse automatisk: ",
+ "preferences_vr_mode_label": "Interaktive 360-gradersfilmer: ",
+ "preferences_category_visual": "Visuelle innstillinger",
+ "preferences_player_style_label": "Avspillerstil: ",
"Dark mode: ": "Mørk drakt: ",
- "Theme: ": "Drakt: ",
+ "preferences_dark_mode_label": "Drakt: ",
"dark": "Mørk",
"light": "Lys",
- "Thin mode: ": "Tynt modus: ",
- "Miscellaneous preferences": "Ulike innstillinger",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ",
- "Subscription preferences": "Abonnementsinnstillinger",
- "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ",
+ "preferences_thin_mode_label": "Tynt modus: ",
+ "preferences_category_misc": "Ulike innstillinger",
+ "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ",
+ "preferences_category_subscription": "Abonnementsinnstillinger",
+ "preferences_annotations_subscribed_label": "Vis merknader som forvalg for kanaler det abonneres på? ",
"Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ",
- "Number of videos shown in feed: ": "Antall videoer å vise i kilde: ",
- "Sort videos by: ": "Sorter videoer etter: ",
+ "preferences_max_results_label": "Antall videoer å vise i kilde: ",
+ "preferences_sort_label": "Sorter videoer etter: ",
"published": "publisert",
"published - reverse": "publisert - motsatt",
"alphabetically": "alfabetisk",
@@ -101,12 +89,12 @@
"channel name - reverse": "kanalnavn - motsatt",
"Only show latest video from channel: ": "Kun vis siste video fra kanal: ",
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
- "Only show unwatched: ": "Kun vis usette: ",
- "Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
+ "preferences_unseen_only_label": "Kun vis usette: ",
+ "preferences_notifications_only_label": "Kun vis merknader (hvis det er noen): ",
"Enable web notifications": "Skru på nettmerknader",
"`x` uploaded a video": "`x` lastet opp en video",
"`x` is live": "`x` er pålogget",
- "Data preferences": "Datainnstillinger",
+ "preferences_category_data": "Datainnstillinger",
"Clear watch history": "Tøm visningshistorikk",
"Import/export data": "Importer/eksporter data",
"Change password": "Endre passord",
@@ -114,10 +102,10 @@
"Manage tokens": "Behandle symboler",
"Watch history": "Visningshistorikk",
"Delete account": "Slett konto",
- "Administrator preferences": "Administratorinnstillinger",
- "Default homepage: ": "Forvalgt hjemmeside: ",
- "Feed menu: ": "Kilde-meny: ",
- "Show nickname on top: ": "Vis kallenavn på toppen: ",
+ "preferences_category_admin": "Administratorinnstillinger",
+ "preferences_default_home_label": "Forvalgt hjemmeside: ",
+ "preferences_feed_menu_label": "Kilde-meny: ",
+ "preferences_show_nick_label": "Vis kallenavn på toppen: ",
"Top enabled: ": "Topp påskrudd? ",
"CAPTCHA enabled: ": "CAPTCHA påskrudd? ",
"Login enabled: ": "Innlogging påskrudd? ",
@@ -127,25 +115,13 @@
"Subscription manager": "Abonnementsbehandler",
"Token manager": "Symbolbehandler",
"Token": "Symbol",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementer",
- "": "`x` abonnementer"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` symboler",
- "": "`x` symboler"
- },
"Import/export": "Importer/eksporter",
"unsubscribe": "opphev abonnement",
"revoke": "tilbakekall",
"Subscriptions": "Abonnement",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` usette merknader",
- "": "`x` usette merknader"
- },
"search": "søk",
"Log out": "Logg ut",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Tilgjengelig med AGPLv3-lisens på Github.",
"Source available here.": "Kildekode tilgjengelig her.",
"View JavaScript license information.": "Vis JavaScript-lisensinfo.",
"View privacy policy.": "Vis personvernspraksis.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Hvitlistede regioner: ",
"Blacklisted regions: ": "Svartelistede regioner: ",
"Shared `x`": "Delt `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger",
- "": "`x` visninger"
- },
"Premieres in `x`": "Premiere om `x`",
"Premieres `x`": "Première `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.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Denne kanalen finnes ikke.",
"Could not get channel info.": "Kunne ikke innhente kanalinfo.",
"Could not fetch comments": "Kunne ikke hente kommentarer",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` svar",
- "": "Vis `x` svar"
- },
"`x` ago": "`x` siden",
"Load more": "Last inn flere",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poeng",
- "": "`x` poeng"
- },
"Could not create mix.": "Kunne ikke opprette miks.",
"Empty playlist": "Spillelisten er tom",
"Not a playlist.": "Ugyldig spilleliste.",
@@ -340,41 +304,13 @@
"Yiddish": "Jiddisk",
"Yoruba": "Joruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` år",
- "": "`x` år"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` måneder",
- "": "`x` måneder"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uker",
- "": "`x` uker"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dager",
- "": "`x` dager"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` timer",
- "": "`x` timer"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutter",
- "": "`x` minutter"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder",
- "": "`x` sekunder"
- },
"Fallback comments: ": "Tilbakefallskommentarer: ",
"Popular": "Populært",
"Search": "Søk",
"Top": "Topp",
"About": "Om",
"Rating: ": "Vurdering: ",
- "Language: ": "Språk: ",
+ "preferences_locale_label": "Språk: ",
"View as playlist": "Vis som spilleliste",
"Default": "Forvalg",
"Music": "Musikk",
@@ -423,5 +359,76 @@
"Current version: ": "Gjeldende versjon: ",
"next_steps_error_message": "Etterpå bør du prøve dette: ",
"next_steps_error_message_refresh": "Gjenoppfrisk",
- "next_steps_error_message_go_to_youtube": "Gå til YouTube"
+ "next_steps_error_message_go_to_youtube": "Gå til YouTube",
+ "long": "Lang (> 20 minutter)",
+ "footer_donate_page": "Doner",
+ "short": "Kort (< 4 minutter)",
+ "footer_documentation": "Dokumentasjon",
+ "footer_source_code": "Kildekode",
+ "footer_original_source_code": "Opprinnelig kildekode",
+ "footer_modfied_source_code": "Endret kildekode",
+ "adminprefs_modified_source_code_url_label": "Nettadresse til kodelager inneholdende endret kildekode",
+ "preferences_quality_dash_label": "Foretrukket DASH-videokvalitet: ",
+ "preferences_region_label": "Innholdsland: ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_small": "Lav",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "Best",
+ "preferences_quality_dash_option_worst": "Verst",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "purchased": "Kjøpt",
+ "360": "360°",
+ "none": "intet",
+ "videoinfo_watch_on_youTube": "Se på YouTube",
+ "videoinfo_youTube_embed_link": "Bak inn",
+ "videoinfo_invidious_embed_link": "Bak inn lenke",
+ "download_subtitles": "Undertekster - `x` (.vtt)",
+ "user_created_playlists": "`x` spillelister opprettet",
+ "user_saved_playlists": "`x` spillelister lagret",
+ "Video unavailable": "Utilgjengelig video",
+ "preferences_quality_option_dash": "DASH (tilpasset kvalitet)",
+ "preferences_quality_option_medium": "Medium",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "videoinfo_started_streaming_x_ago": "Strømmen startet for `x` siden",
+ "generic_count_seconds": "{{count}} sekund",
+ "generic_count_seconds_plural": "{{count}} sekunder",
+ "preferences_save_player_pos_label": "Lagre avspillingsposisjon: ",
+ "generic_views_count": "{{count}} visning",
+ "generic_views_count_plural": "{{count}} visninger",
+ "tokens_count": "{{count}} symbol",
+ "tokens_count_plural": "{{count}} symboler",
+ "generic_subscriptions_count": "{{count}} abonnement",
+ "generic_subscriptions_count_plural": "{{count}} abonnementer",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} videoer",
+ "generic_playlists_count": "{{count}} spilleliste",
+ "generic_playlists_count_plural": "{{count}} spillelister",
+ "subscriptions_unseen_notifs_count": "{{count}} usett merknad",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} usette merknader",
+ "comments_view_x_replies": "Vis {{count}} svar",
+ "comments_view_x_replies_plural": "Vis {{count}} svar",
+ "generic_subscribers_count": "{{count}} abonnent",
+ "generic_subscribers_count_plural": "{{count}}abonnenter",
+ "generic_count_months": "{{count}} måned",
+ "generic_count_months_plural": "{{count}} måneder",
+ "generic_count_days": "{{count}} dag",
+ "generic_count_days_plural": "{{count}} dager",
+ "comments_points_count": "{{count}} poeng",
+ "comments_points_count_plural": "{{count}} poeng",
+ "generic_count_weeks": "{{count}} uke",
+ "generic_count_weeks_plural": "{{count}} uker",
+ "generic_count_hours": "{{count}} time",
+ "generic_count_hours_plural": "{{count}} timer",
+ "generic_count_minutes": "{{count}} minutt",
+ "generic_count_minutes_plural": "{{count}} minutter",
+ "generic_count_years": "{{count}} år",
+ "generic_count_years_plural": "{{count}} år"
}
diff --git a/locales/nl.json b/locales/nl.json
index 9fe604ad..d148d872 100644
--- a/locales/nl.json
+++ b/locales/nl.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnees",
- "": "`x` abonnees"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video's",
- "": "`x` video's"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspeellijsten",
- "": "`x` afspeellijsten"
- },
"LIVE": "LIVE",
"Shared `x` ago": "Gedeeld: `x` geleden",
"Unsubscribe": "Deabonneren",
@@ -42,7 +30,7 @@
"Export subscriptions as OPML": "Abonnementen exporteren als OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonnementen exporteren als OPML (voor NewPipe en FreeTube)",
"Export data as JSON": "Gegevens exporteren als JSON",
- "Delete account?": "Wil je je account verwijderen?",
+ "Delete account?": "Wilt u uw account verwijderen?",
"History": "Geschiedenis",
"An alternative front-end to YouTube": "Een alternatief front-end voor YouTube",
"JavaScript license information": "JavaScript-licentieinformatie",
@@ -60,39 +48,37 @@
"E-mail": "E-mailadres",
"Google verification code": "Google-verificatiecode",
"Preferences": "Instellingen",
- "Player preferences": "Spelerinstellingen",
- "Always loop: ": "Altijd herhalen: ",
- "Autoplay: ": "Automatisch afspelen: ",
- "Play next by default: ": "Standaard volgende video afspelen: ",
- "Autoplay next video: ": "Volgende video automatisch afspelen: ",
- "Listen by default: ": "Standaard luisteren: ",
- "Proxy videos: ": "Video's afspelen via proxy? ",
- "Default speed: ": "Standaard afspeelsnelheid: ",
- "Preferred video quality: ": "Voorkeurskwaliteit: ",
- "Player volume: ": "Spelervolume: ",
- "Default comments: ": "Reacties tonen van: ",
+ "preferences_category_player": "Spelerinstellingen",
+ "preferences_video_loop_label": "Altijd herhalen: ",
+ "preferences_autoplay_label": "Automatisch afspelen: ",
+ "preferences_continue_label": "Standaard volgende video afspelen: ",
+ "preferences_continue_autoplay_label": "Volgende video automatisch afspelen: ",
+ "preferences_listen_label": "Standaard luisteren: ",
+ "preferences_local_label": "Video's afspelen via proxy? ",
+ "preferences_speed_label": "Standaard afspeelsnelheid: ",
+ "preferences_quality_label": "Voorkeurskwaliteit: ",
+ "preferences_volume_label": "Spelervolume: ",
+ "preferences_comments_label": "Reacties tonen van: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Standaard ondertiteling: ",
+ "preferences_captions_label": "Standaard ondertiteling: ",
"Fallback captions: ": "Alternatieve ondertiteling: ",
- "Show related videos: ": "Gerelateerde video's tonen? ",
- "Show annotations by default: ": "Standaard annotaties tonen? ",
- "Automatically extend video description: ": "Breid videobeschrijving automatisch uit: ",
- "Interactive 360 degree videos: ": "Interactieve 360-graden-video's ",
- "Visual preferences": "Visuele instellingen",
- "Player style: ": "Speler vormgeving ",
+ "preferences_related_videos_label": "Gerelateerde video's tonen? ",
+ "preferences_annotations_label": "Standaard annotaties tonen? ",
+ "preferences_extend_desc_label": "Breid videobeschrijving automatisch uit: ",
+ "preferences_vr_mode_label": "Interactieve 360-graden-video's ",
+ "preferences_category_visual": "Visuele instellingen",
+ "preferences_player_style_label": "Speler vormgeving ",
"Dark mode: ": "Donkere modus: ",
- "Theme: ": "Thema: ",
+ "preferences_dark_mode_label": "Thema: ",
"dark": "donker",
"light": "licht",
- "Thin mode: ": "Smalle modus: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Abonnementsinstellingen",
- "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ",
+ "preferences_thin_mode_label": "Smalle modus: ",
+ "preferences_category_subscription": "Abonnementsinstellingen",
+ "preferences_annotations_subscribed_label": "Standaard annotaties tonen voor geabonneerde kanalen? ",
"Redirect homepage to feed: ": "Startpagina omleiden naar feed: ",
- "Number of videos shown in feed: ": "Aantal te tonen video's in feed: ",
- "Sort videos by: ": "Video's sorteren op: ",
+ "preferences_max_results_label": "Aantal te tonen video's in feed: ",
+ "preferences_sort_label": "Video's sorteren op: ",
"published": "publicatiedatum",
"published - reverse": "publicatiedatum - omgekeerd",
"alphabetically": "alfabetische volgorde",
@@ -101,12 +87,12 @@
"channel name - reverse": "kanaalnaam - omgekeerd",
"Only show latest video from channel: ": "Alleen nieuwste video van kanaal tonen: ",
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
- "Only show unwatched: ": "Alleen niet-bekeken videos tonen: ",
- "Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ",
+ "preferences_unseen_only_label": "Alleen niet-bekeken videos tonen: ",
+ "preferences_notifications_only_label": "Alleen meldingen tonen (als die er zijn): ",
"Enable web notifications": "Systemmeldingen inschakelen",
"`x` uploaded a video": "`x` heeft een video geüpload",
"`x` is live": "`x` zendt nu live uit",
- "Data preferences": "Gegevensinstellingen",
+ "preferences_category_data": "Gegevensinstellingen",
"Clear watch history": "Kijkgeschiedenis wissen",
"Import/export data": "Gegevens im-/exporteren",
"Change password": "Wachtwoord wijzigen",
@@ -114,10 +100,9 @@
"Manage tokens": "Toegangssleutels beheren",
"Watch history": "Kijkgeschiedenis",
"Delete account": "Account verwijderen",
- "Administrator preferences": "Beheerdersinstellingen",
- "Default homepage: ": "Standaard startpagina: ",
- "Feed menu: ": "Feedmenu: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Beheerdersinstellingen",
+ "preferences_default_home_label": "Standaard startpagina: ",
+ "preferences_feed_menu_label": "Feedmenu: ",
"Top enabled: ": "Bovenkant inschakelen? ",
"CAPTCHA enabled: ": "CAPTCHA gebruiken? ",
"Login enabled: ": "Inloggen toestaan? ",
@@ -127,25 +112,12 @@
"Subscription manager": "Abonnementen beheren",
"Token manager": "Toegangssleutels beheren",
"Token": "Toegangssleutel",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementen",
- "": "`x` abonnementen"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` toegangssleutels",
- "": "`x` toegangssleutels"
- },
"Import/export": "Importeren/Exporteren",
"unsubscribe": "Deabonneren",
"revoke": "Intrekken",
"Subscriptions": "Abonnementen",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ongelezen meldingen",
- "": "`x` ongelezen meldingen"
- },
"search": "zoeken",
"Log out": "Uitloggen",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "De broncode is hier beschikbaar.",
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
"View privacy policy.": "Privacybeleid tonen.",
@@ -164,8 +136,6 @@
"Show more": "Toon meer",
"Show less": "Toon minder",
"Watch on YouTube": "Video bekijken op YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Annotaties verbergen",
"Show annotations": "Annotaties tonen",
"Genre: ": "Genre: ",
@@ -176,10 +146,6 @@
"Whitelisted regions: ": "Toegestane regio's: ",
"Blacklisted regions: ": "Geblokkeerde regio's: ",
"Shared `x`": "`x` gedeeld",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weergaven",
- "": "`x` weergaven"
- },
"Premieres in `x`": "Verschijnt over `x`",
"Premieres `x`": "Verschijnt op `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.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.",
@@ -213,16 +179,8 @@
"This channel does not exist.": "Dit kanaal bestaat niet.",
"Could not get channel info.": "Kan geen kanaalinformatie ophalen.",
"Could not fetch comments": "Kan reacties niet ophalen",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` antwoorden tonen",
- "": "`x` antwoorden tonen"
- },
"`x` ago": "`x` geleden",
"Load more": "Meer laden",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punten",
- "": "`x` punten"
- },
"Could not create mix.": "Kan geen mix maken.",
"Empty playlist": "Lege afspeellijst",
"Not a playlist.": "Ongeldige afspeellijst.",
@@ -340,41 +298,13 @@
"Yiddish": "Joods",
"Yoruba": "Yoruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaar",
- "": "`x` jaren"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` maanden",
- "": "`x` maanden"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weken",
- "": "`x` weken"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagen",
- "": "`x` dagen"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uur",
- "": "`x` uren"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuten",
- "": "`x` minuten"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconden",
- "": "`x` seconden"
- },
"Fallback comments: ": "Terugvallen op ",
"Popular": "Populair",
"Search": "Zoeken",
"Top": "Top",
"About": "Over",
"Rating: ": "Waardering: ",
- "Language: ": "Taal: ",
+ "preferences_locale_label": "Taal: ",
"View as playlist": "Tonen als afspeellijst",
"Default": "Standaard",
"Music": "Muziek",
@@ -421,7 +351,52 @@
"hdr": "HDR",
"filter": "verfijnen",
"Current version: ": "Huidige versie: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Switch Invidious Instance": "Schakel tussen de Invidious Instanties",
+ "preferences_automatic_instance_redirect_label": "Automatische instantie-omleiding (terugval naar redirect.invidious.io): ",
+ "preferences_quality_dash_label": "Gewenste DASH-videokwaliteit: ",
+ "preferences_region_label": "Inhoud land: ",
+ "preferences_category_misc": "Diverse voorkeuren",
+ "preferences_show_nick_label": "Toon bijnaam bovenaan: ",
+ "Released under the AGPLv3 on Github.": "Uitgebracht onder de AGPLv3 op Github.",
+ "short": "Kort (<4 minuten)",
+ "next_steps_error_message_refresh": "Vernieuwen",
+ "next_steps_error_message_go_to_youtube": "Ga naar YouTube",
+ "footer_donate_page": "Doneren",
+ "footer_documentation": "Documentatie",
+ "footer_original_source_code": "Originele bron-code",
+ "footer_modfied_source_code": "Gewijzigde bron-code",
+ "adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats",
+ "Broken? Try another Invidious Instance": "Kapot? Probeer een andere Invidious Instantie",
+ "next_steps_error_message": "Waarna u moet proberen om: ",
+ "footer_source_code": "Bron-code",
+ "long": "Lang (> 20 minuten)",
+ "preferences_quality_option_dash": "DASH (adaptieve kwaliteit)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Gemiddeld",
+ "preferences_quality_option_small": "Klein",
+ "preferences_quality_dash_option_auto": "Automatisch",
+ "preferences_quality_dash_option_best": "Beste",
+ "preferences_quality_dash_option_worst": "Slechtste",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "videoinfo_started_streaming_x_ago": "Stream `x` geleden begonnen",
+ "videoinfo_watch_on_youTube": "Bekijken op YouTube",
+ "videoinfo_youTube_embed_link": "Inbedden",
+ "videoinfo_invidious_embed_link": "Link ingebedde versie",
+ "download_subtitles": "Ondertiteling - `x` (.vtt)",
+ "user_created_playlists": "`x` afspeellijsten aangemaakt",
+ "user_saved_playlists": "`x` afspeellijsten opgeslagen",
+ "Video unavailable": "Video onbeschikbaar",
+ "preferences_save_player_pos_label": "Huidig afspeeltijdstip opslaan: ",
+ "none": "geen",
+ "purchased": "Gekocht",
+ "360": "360º"
}
diff --git a/locales/pl.json b/locales/pl.json
index a33bbd45..5c4667f0 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subskrybcji",
- "": "`x` subskrybcji"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` filmów",
- "": "`x` filmów"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
- "": "`x` playlist"
- },
"LIVE": "NA ŻYWO",
"Shared `x` ago": "Udostępniono `x` temu",
"Unsubscribe": "Odsubskrybuj",
@@ -60,39 +48,39 @@
"E-mail": "E-mail",
"Google verification code": "Kod weryfikacyjny Google",
"Preferences": "Preferencje",
- "Player preferences": "Ustawienia odtwarzacza",
- "Always loop: ": "Zawsze zapętlaj: ",
- "Autoplay: ": "Autoodtwarzanie: ",
- "Play next by default: ": "Domyślnie odtwarzaj następny: ",
- "Autoplay next video: ": "Odtwórz następny film: ",
- "Listen by default: ": "Tryb dźwiękowy: ",
- "Proxy videos: ": "Filmy przez proxy? ",
- "Default speed: ": "Domyślna prędkość: ",
- "Preferred video quality: ": "Preferowana jakość filmów: ",
- "Player volume: ": "Głośność odtwarzacza: ",
- "Default comments: ": "Domyślne komentarze: ",
+ "preferences_category_player": "Ustawienia odtwarzacza",
+ "preferences_video_loop_label": "Zawsze zapętlaj: ",
+ "preferences_autoplay_label": "Autoodtwarzanie: ",
+ "preferences_continue_label": "Domyślnie odtwarzaj następny: ",
+ "preferences_continue_autoplay_label": "Odtwórz następny film: ",
+ "preferences_listen_label": "Tryb dźwiękowy: ",
+ "preferences_local_label": "Filmy przez proxy? ",
+ "preferences_speed_label": "Domyślna prędkość: ",
+ "preferences_quality_label": "Preferowana jakość filmów: ",
+ "preferences_volume_label": "Głośność odtwarzacza: ",
+ "preferences_comments_label": "Domyślne komentarze: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Domyślne napisy: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Domyślne napisy: ",
"Fallback captions: ": "Zastępcze napisy: ",
- "Show related videos: ": "Pokaż powiązane filmy? ",
- "Show annotations by default: ": "Domyślnie pokazuj adnotacje: ",
- "Automatically extend video description: ": "Automatycznie rozwijaj opisy filmów: ",
- "Interactive 360 degree videos: ": "Interaktywne filmy 360 stopni: ",
- "Visual preferences": "Preferencje Wizualne",
- "Player style: ": "Styl odtwarzacza: ",
+ "preferences_related_videos_label": "Pokaż powiązane filmy? ",
+ "preferences_annotations_label": "Domyślnie pokazuj adnotacje: ",
+ "preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ",
+ "preferences_vr_mode_label": "Interaktywne filmy 360 stopni: ",
+ "preferences_category_visual": "Preferencje Wizualne",
+ "preferences_player_style_label": "Styl odtwarzacza: ",
"Dark mode: ": "Ciemny motyw: ",
- "Theme: ": "Motyw: ",
+ "preferences_dark_mode_label": "Motyw: ",
"dark": "ciemny",
"light": "jasny",
- "Thin mode: ": "Tryb minimalny: ",
- "Miscellaneous preferences": "Różne preferencje",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatyczne przekierowanie instancji (powrót do redirect.invidious.io): ",
- "Subscription preferences": "Preferencje subskrybcji",
- "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ",
+ "preferences_thin_mode_label": "Tryb minimalny: ",
+ "preferences_category_misc": "Różne preferencje",
+ "preferences_automatic_instance_redirect_label": "Automatycznie przekierowanie instancji (powrót do redirect.invidious.io): ",
+ "preferences_category_subscription": "Preferencje subskrybcji",
+ "preferences_annotations_subscribed_label": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
- "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
- "Sort videos by: ": "Sortuj filmy: ",
+ "preferences_max_results_label": "Liczba filmów widoczna na stronie subskrybcji: ",
+ "preferences_sort_label": "Sortuj filmy: ",
"published": "po czasie publikacji",
"published - reverse": "po czasie publikacji od najstarszych",
"alphabetically": "alfabetycznie",
@@ -101,12 +89,12 @@
"channel name - reverse": "po nazwie kanału od tyłu",
"Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ",
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
- "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
- "Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
+ "preferences_unseen_only_label": "Pokazuj tylko nie obejrzane: ",
+ "preferences_notifications_only_label": "Pokazuj tylko powiadomienia (jeśli są): ",
"Enable web notifications": "Włącz powiadomienia",
"`x` uploaded a video": "`x` dodał film",
"`x` is live": "'x ' jest na żywo",
- "Data preferences": "Preferencje danych",
+ "preferences_category_data": "Preferencje danych",
"Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych",
"Change password": "Zmień hasło",
@@ -114,10 +102,10 @@
"Manage tokens": "Zarządzaj tokenami",
"Watch history": "Historia",
"Delete account": "Usuń konto",
- "Administrator preferences": "Preferencje administratora",
- "Default homepage: ": "Domyślna strona główna: ",
- "Feed menu: ": "Menu aktualności ",
- "Show nickname on top: ": "Pokaż pseudonim na górze: ",
+ "preferences_category_admin": "Preferencje administratora",
+ "preferences_default_home_label": "Domyślna strona główna: ",
+ "preferences_feed_menu_label": "Menu aktualności ",
+ "preferences_show_nick_label": "Pokaż pseudonim na górze: ",
"Top enabled: ": "\"Top\" aktywne: ",
"CAPTCHA enabled: ": "CAPTCHA aktywna? ",
"Login enabled: ": "Logowanie włączone? ",
@@ -127,25 +115,12 @@
"Subscription manager": "Manager subskrybcji",
"Token manager": "Menedżer tokenów",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subskrybcji",
- "": "`x` subskrybcji"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` tokenów"
- },
"Import/export": "Import/Eksport",
"unsubscribe": "odsubskrybuj",
"revoke": "cofnij",
"Subscriptions": "Subskrybcje",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nowych powiadomień",
- "": "`x` nowych powiadomień"
- },
"search": "szukaj",
"Log out": "Wyloguj",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Kod źródłowy dostępny tutaj.",
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "Polityka prywatności.",
@@ -176,10 +151,6 @@
"Whitelisted regions: ": "Dostępny na obszarach: ",
"Blacklisted regions: ": "Niedostępny na obszarach: ",
"Shared `x`": "Udostępniono `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` wyświetleń",
- "": "`x` wyświetleń"
- },
"Premieres in `x`": "Publikacja za `x`",
"Premieres `x`": "Publikacja za `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.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.",
@@ -213,16 +184,8 @@
"This channel does not exist.": "Ten kanał nie istnieje.",
"Could not get channel info.": "Nie udało się uzyskać informacji o kanale.",
"Could not fetch comments": "Nie udało się pobrać komentarzy",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` odpowiedzi",
- "": "Wyświetl `x` odpowiedzi"
- },
"`x` ago": "`x` temu",
"Load more": "Wczytaj więcej",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punktów",
- "": "`x` punktów"
- },
"Could not create mix.": "Nie udało się utworzyć miksu.",
"Empty playlist": "Lista odtwarzania jest pusta",
"Not a playlist.": "Niepoprawna lista.",
@@ -340,41 +303,13 @@
"Yiddish": "jidysz",
"Yoruba": "joruba",
"Zulu": "zuluski",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` lat",
- "": "`x` lat"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` miesięcy",
- "": "`x` miesięcy"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tygodni",
- "": "`x` tygodni"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dni",
- "": "`x` dni"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` godzin",
- "": "`x` godzin"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minut",
- "": "`x` minut"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekund",
- "": "`x` sekund"
- },
"Fallback comments: ": "Zastępcze komentarze: ",
"Popular": "Popularne",
"Search": "Szukaj",
"Top": "Top",
"About": "Informacje",
"Rating: ": "Ocena: ",
- "Language: ": "Język: ",
+ "preferences_locale_label": "Język: ",
"View as playlist": "Obejrzyj w playliście",
"Default": "Domyślnie",
"Music": "Muzyka",
@@ -423,5 +358,100 @@
"Current version: ": "Aktualna wersja: ",
"next_steps_error_message": "Po czym powinien*ś spróbować: ",
"next_steps_error_message_refresh": "Odśwież",
- "next_steps_error_message_go_to_youtube": "Przejdź do YouTube"
+ "next_steps_error_message_go_to_youtube": "Przejdź do YouTube",
+ "invidious": "Invidious",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokeny",
+ "tokens_count_2": "{{count}} tokenów",
+ "generic_videos_count_0": "{{count}} film",
+ "generic_videos_count_1": "{{count}} filmy",
+ "generic_videos_count_2": "{{count}} filmów",
+ "generic_views_count_0": "{{count}} wyświetlenie",
+ "generic_views_count_1": "{{count}} wyświetlenia",
+ "generic_views_count_2": "{{count}} wyświetleń",
+ "generic_playlists_count_0": "{{count}} playlista",
+ "generic_playlists_count_1": "{{count}} playlisty",
+ "generic_playlists_count_2": "{{count}} playlist",
+ "generic_subscribers_count_0": "{{count}} subskrybent",
+ "generic_subscribers_count_1": "{{count}} subskrybentów",
+ "generic_subscribers_count_2": "{{count}} subskrybentów",
+ "generic_subscriptions_count_0": "{{count}} subskrypcja",
+ "generic_subscriptions_count_1": "{{count}} subskrypcje",
+ "generic_subscriptions_count_2": "{{count}} subskrypcji",
+ "comments_view_x_replies_0": "Pokaż {{count}} odpowiedź",
+ "comments_view_x_replies_1": "Pokaż {{count}} odpowiedzi",
+ "comments_view_x_replies_2": "Pokaż {{count}} odpowiedzi",
+ "comments_points_count_0": "{{count}} punkt",
+ "comments_points_count_1": "{{count}} punkty",
+ "comments_points_count_2": "{{count}} punktów",
+ "generic_count_months_0": "{{count}} miesiąc",
+ "generic_count_months_1": "{{count}} miesiące",
+ "generic_count_months_2": "{{count}} miesięcy",
+ "generic_count_weeks_0": "{{count}} tydzień",
+ "generic_count_weeks_1": "{{count}} tygodnie",
+ "generic_count_weeks_2": "{{count}} tygodni",
+ "generic_count_days_0": "{{count}} dzień",
+ "generic_count_days_1": "{{count}} dni",
+ "generic_count_days_2": "{{count}} dni",
+ "generic_count_hours_0": "{{count}} godzina",
+ "generic_count_hours_1": "{{count}} godziny",
+ "generic_count_hours_2": "{{count}} godzin",
+ "generic_count_seconds_0": "{{count}} sekunda",
+ "generic_count_seconds_1": "{{count}} sekundy",
+ "generic_count_seconds_2": "{{count}} sekund",
+ "crash_page_you_found_a_bug": "Wygląda na to że udało ci się znaleźć błąd w Invidious!",
+ "crash_page_refresh": "próbowano <a href=\"`x`\">odświeżyć stronę</a>",
+ "crash_page_switch_instance": "spróbowano <a href=\"`x`\"> użyć innej instancji</a>",
+ "crash_page_read_the_faq": "przeczytaj <a href=\"`x`\"> Często Zadawane Pytania (FAQ)</a>",
+ "crash_page_search_issue": "próbowano poszukać <a href=\"`x`\"> istniejących zgłoszeń na GitHub'ie</a>",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "subscriptions_unseen_notifs_count_0": "{{count}} nieodczytane powiadomienie",
+ "subscriptions_unseen_notifs_count_1": "{{count}} nieodczytane powiadomienia",
+ "subscriptions_unseen_notifs_count_2": "{{count}} nieodczytanych powiadomień",
+ "generic_count_minutes_0": "{{count}} minuta",
+ "generic_count_minutes_1": "{{count}} minuty",
+ "generic_count_minutes_2": "{{count}} minut",
+ "generic_count_years_0": "{{count}} rok",
+ "generic_count_years_1": "{{count}} lata",
+ "generic_count_years_2": "{{count}} lat",
+ "crash_page_before_reporting": "Przed zgłoszeniem błędu, upewnij się że masz:",
+ "crash_page_report_issue": "Jeżeli nic z powyższych opcji nie pomogło, proszę <a href=\"`x`\"> otworzyć nowe zgłoszenie na GitHub'ie</a> (najlepiej po Angielsku) i dodać poniższy tekst w twojej wiadomości (NIE tłumacz tego tekstu):",
+ "preferences_quality_dash_option_auto": "Automatyczna",
+ "preferences_quality_dash_option_best": "Najlepsza",
+ "preferences_quality_dash_option_worst": "Najgorsza",
+ "preferences_quality_option_dash": "DASH (jakość adaptywna)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Średnia",
+ "preferences_quality_option_small": "Mała",
+ "preferences_quality_dash_label": "Preferowana jakość filmu DASH: ",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "purchased": "Zakupione",
+ "360": "360°",
+ "footer_donate_page": "Dotacja",
+ "none": "żadne",
+ "videoinfo_started_streaming_x_ago": "Transmisja rozpoczęta `x` temu",
+ "videoinfo_watch_on_youTube": "Obejrzyj na YouTube",
+ "videoinfo_youTube_embed_link": "Odtwarzacz typu Embed",
+ "videoinfo_invidious_embed_link": "Link do Embed",
+ "download_subtitles": "Napisy - `x` (.vtt)",
+ "user_created_playlists": "`x` utworzonych playlist",
+ "user_saved_playlists": "`x` zapisanych playlist",
+ "Video unavailable": "Film niedostępny",
+ "preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ",
+ "preferences_region_label": "Region zawartości: ",
+ "Released under the AGPLv3 on Github.": "Wydane na licencji AGPLv3 na Github'ie.",
+ "short": "Krótkie (< 4 minutes)",
+ "long": "Długie (> 20 minutes)",
+ "footer_documentation": "Dokumentacja",
+ "footer_source_code": "Kod źródłowy",
+ "footer_modfied_source_code": "Zmodyfikowany Kod źródłowy",
+ "footer_original_source_code": "Oryginalny kod źródłowy",
+ "adminprefs_modified_source_code_url_label": "Adres URL do repozytorium z zmodyfikowanym kodem źródłowym"
}
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index f1ffb7a8..71a232c7 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` inscritos",
- "": "`x` inscritos"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos",
- "": "`x` vídeos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução",
- "": "`x` listas de reprodução"
- },
"LIVE": "AO VIVO",
"Shared `x` ago": "Compartilhado `x` atrás",
"Unsubscribe": "Cancelar inscrição",
@@ -60,39 +48,39 @@
"E-mail": "E-mail",
"Google verification code": "Código de verificação do Google",
"Preferences": "Preferências",
- "Player preferences": "Preferências do reprodutor",
- "Always loop: ": "Repetir sempre: ",
- "Autoplay: ": "Reprodução automática: ",
- "Play next by default: ": "Sempre reproduzir próximo: ",
- "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ",
- "Listen by default: ": "Apenas áudio por padrão: ",
- "Proxy videos: ": "Usar proxy nos vídeos: ",
- "Default speed: ": "Velocidade padrão: ",
- "Preferred video quality: ": "Qualidade de vídeo preferida: ",
- "Player volume: ": "Volume de reprodução: ",
- "Default comments: ": "Preferência de comentários: ",
+ "preferences_category_player": "Preferências do reprodutor",
+ "preferences_video_loop_label": "Repetir sempre: ",
+ "preferences_autoplay_label": "Reprodução automática: ",
+ "preferences_continue_label": "Sempre reproduzir próximo: ",
+ "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ",
+ "preferences_listen_label": "Apenas áudio por padrão: ",
+ "preferences_local_label": "Usar proxy nos vídeos: ",
+ "preferences_speed_label": "Velocidade padrão: ",
+ "preferences_quality_label": "Qualidade de vídeo preferida: ",
+ "preferences_volume_label": "Volume de reprodução: ",
+ "preferences_comments_label": "Preferência de comentários: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Preferência de legendas: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Preferência de legendas: ",
"Fallback captions: ": "Legendas alternativas: ",
- "Show related videos: ": "Mostrar vídeos relacionados: ",
- "Show annotations by default: ": "Sempre mostrar anotações: ",
- "Automatically extend video description: ": "Estenda automaticamente a descrição do vídeo: ",
- "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ",
- "Visual preferences": "Preferências visuais",
- "Player style: ": "Estilo do tocador: ",
+ "preferences_related_videos_label": "Mostrar vídeos relacionados: ",
+ "preferences_annotations_label": "Sempre mostrar anotações: ",
+ "preferences_extend_desc_label": "Estenda automaticamente a descrição do vídeo: ",
+ "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ",
+ "preferences_category_visual": "Preferências visuais",
+ "preferences_player_style_label": "Estilo do tocador: ",
"Dark mode: ": "Modo escuro: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "escuro",
"light": "claro",
- "Thin mode: ": "Modo compacto: ",
- "Miscellaneous preferences": "Preferências diversas",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ",
- "Subscription preferences": "Preferências de inscrições",
- "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ",
+ "preferences_thin_mode_label": "Modo compacto: ",
+ "preferences_category_misc": "Preferências diversas",
+ "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ",
+ "preferences_category_subscription": "Preferências de inscrições",
+ "preferences_annotations_subscribed_label": "Sempre mostrar anotações dos vídeos de canais inscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ",
- "Number of videos shown in feed: ": "Número de vídeos no feed: ",
- "Sort videos by: ": "Ordenar vídeos por: ",
+ "preferences_max_results_label": "Número de vídeos no feed: ",
+ "preferences_sort_label": "Ordenar vídeos por: ",
"published": "publicado",
"published - reverse": "publicado - ordem inversa",
"alphabetically": "alfabética",
@@ -101,12 +89,12 @@
"channel name - reverse": "nome do canal - ordem inversa",
"Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ",
"Only show latest unwatched video from channel: ": "Mostrar apenas o vídeo mais recente não visualizado do canal: ",
- "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ",
- "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existentes): ",
+ "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ",
+ "preferences_notifications_only_label": "Mostrar apenas notificações (se existentes): ",
"Enable web notifications": "Ativar notificações pela web",
"`x` uploaded a video": "`x` publicou um novo vídeo",
"`x` is live": "`x` está ao vivo",
- "Data preferences": "Preferências de dados",
+ "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 senha",
@@ -114,10 +102,10 @@
"Manage tokens": "Gerenciar tokens",
"Watch history": "Histórico de reprodução",
"Delete account": "Apagar sua conta",
- "Administrator preferences": "Preferências de administrador",
- "Default homepage: ": "Página de início padrão: ",
- "Feed menu: ": "Menu do feed: ",
- "Show nickname on top: ": "Mostrar o nickname no topo: ",
+ "preferences_category_admin": "Preferências de administrador",
+ "preferences_default_home_label": "Página de início padrão: ",
+ "preferences_feed_menu_label": "Menu do feed: ",
+ "preferences_show_nick_label": "Mostrar o nickname no topo: ",
"Top enabled: ": "Habilitar destaques: ",
"CAPTCHA enabled: ": "Habilitar CAPTCHA: ",
"Login enabled: ": "Habilitar login: ",
@@ -127,22 +115,12 @@
"Subscription manager": "Gerenciador de inscrições",
"Token manager": "Gerenciador de tokens",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` inscrições",
- "": "`x` inscrições"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
- "": "Símbolos `x`"
- },
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} tokens",
"Import/export": "Importar/Exportar",
"unsubscribe": "cancelar inscrição",
"revoke": "revogar",
"Subscriptions": "Inscrições",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não visualizadas",
- "": "`x` notificações não visualizadas"
- },
"search": "Pesquisar",
"Log out": "Sair",
"Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.",
@@ -176,10 +154,6 @@
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Compartilhado `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações",
- "": "`x` visualizações"
- },
"Premieres in `x`": "Estreia em `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.": "Oi! Parece que seu JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar um pouco mais de tempo para carregar.",
@@ -213,16 +187,8 @@
"This channel does not exist.": "Este canal não existe.",
"Could not get channel info.": "Não foi possível obter as informações do canal.",
"Could not fetch comments": "Não foi possível obter os comentários",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas",
- "": "Ver `x` respostas"
- },
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos",
- "": "`x` pontos"
- },
"Could not create mix.": "Não foi possível criar o mix.",
"Empty playlist": "Lista de reprodução vazia",
"Not a playlist.": "Não é uma lista de reprodução.",
@@ -340,41 +306,27 @@
"Yiddish": "Iídiche",
"Yoruba": "Iorubá",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos",
- "": "`x` anos"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
- "": "`x` meses"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
- "": "`x` semanas"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias",
- "": "`x` dias"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
- "": "`x` horas"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
- "": "`x` minutos"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
- "": "`x` segundos"
- },
+ "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}} semana",
+ "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": "Populares",
"Search": "Procurar",
"Top": "No topo",
"About": "Sobre",
"Rating: ": "Avaliação: ",
- "Language: ": "Idioma: ",
+ "preferences_locale_label": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
"Default": "Padrão",
"Music": "Músicas",
@@ -423,5 +375,67 @@
"Current version: ": "Versão atual: ",
"next_steps_error_message": "Depois disso, você deve tentar: ",
"next_steps_error_message_refresh": "Atualizar",
- "next_steps_error_message_go_to_youtube": "Ir para o YouTube"
+ "next_steps_error_message_go_to_youtube": "Ir para o YouTube",
+ "footer_donate_page": "Doe",
+ "adminprefs_modified_source_code_url_label": "URL para repositório de código fonte modificado",
+ "long": "Longo (> 20 minutos)",
+ "short": "Curto (< 4 minutos)",
+ "footer_documentation": "Documentação",
+ "footer_source_code": "Código fonte",
+ "footer_original_source_code": "Código fonte original",
+ "footer_modfied_source_code": "Código Fonte Modificado",
+ "preferences_quality_dash_label": "Qualidade de vídeo do painel preferida: ",
+ "preferences_region_label": "País do conteúdo: ",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "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_subscribers_count": "{{count}} inscrito",
+ "generic_subscribers_count_plural": "{{count}} inscritos",
+ "generic_subscriptions_count": "{{count}} inscrição",
+ "generic_subscriptions_count_plural": "{{count}} inscrições",
+ "subscriptions_unseen_notifs_count": "{{count}} notificação não vista",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas",
+ "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",
+ "crash_page_you_found_a_bug": "Parece que você encontrou um erro no Invidious!",
+ "crash_page_before_reporting": "Antes de reportar um erro, verifique se você:",
+ "preferences_save_player_pos_label": "Salvar a posição de reprodução: ",
+ "purchased": "Comprado",
+ "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>",
+ "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>",
+ "crash_page_search_issue": "procurou por um <a href=\"`x`\">erro existente no Github</a>",
+ "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO traduza):",
+ "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas Frequentes (FAQ)</a>",
+ "generic_views_count": "{{count}} visualização",
+ "generic_views_count_plural": "{{count}} visualizações",
+ "preferences_quality_option_dash": "DASH (qualidade adaptiva)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_small": "Pequeno",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "Melhor",
+ "preferences_quality_dash_option_worst": "Pior",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "preferences_quality_option_medium": "Médio",
+ "360": "360°",
+ "none": "none",
+ "videoinfo_watch_on_youTube": "Assistir no YouTube",
+ "videoinfo_youTube_embed_link": "Embutir",
+ "videoinfo_invidious_embed_link": "Link Embutido",
+ "download_subtitles": "Legendas - `x` (.vtt)",
+ "user_created_playlists": "`x` listas de reprodução criadas",
+ "user_saved_playlists": "`x` listas de reprodução salvas",
+ "Video unavailable": "Vídeo indisponível",
+ "videoinfo_started_streaming_x_ago": "Iniciou a transmissão a `x`"
}
diff --git a/locales/pt-PT.json b/locales/pt-PT.json
index a5e4bca8..4dba553e 100644
--- a/locales/pt-PT.json
+++ b/locales/pt-PT.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores",
- "": "`x` subscritores"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videos",
- "": "`x` vídeos"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução",
- "": "`x` listas de reprodução"
- },
"LIVE": "Em direto",
"Shared `x` ago": "Partilhado `x` atrás",
"Unsubscribe": "Anular subscrição",
@@ -26,12 +14,12 @@
"Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova palavra-chave",
"New passwords must match": "As novas palavra-chaves devem corresponder",
- "Cannot change password for Google accounts": "Não é possível alterar a palavra-passe para contas do Google",
+ "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google",
"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 and Export Data": "Importar e exportar dados",
"Import": "Importar",
"Import Invidious data": "Importar dados do Invidious",
"Import YouTube subscriptions": "Importar subscrições do YouTube",
@@ -42,57 +30,57 @@
"Export subscriptions as OPML": "Exportar subscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados como JSON",
- "Delete account?": "Apagar conta?",
+ "Delete account?": "Eliminar conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
"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",
+ "Log in/register": "Iniciar sessão/registar",
"Log in with Google": "Iniciar sessão com o Google",
"User ID": "Utilizador",
"Password": "Palavra-chave",
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
"Text CAPTCHA": "Texto CAPTCHA",
"Image CAPTCHA": "Imagem CAPTCHA",
- "Sign In": "Iniciar Sessão",
+ "Sign In": "Iniciar sessão",
"Register": "Registar",
"E-mail": "E-mail",
"Google verification code": "Código de verificação do Google",
"Preferences": "Preferências",
- "Player preferences": "Preferências do reprodutor",
- "Always loop: ": "Repetir sempre: ",
- "Autoplay: ": "Reprodução automática: ",
- "Play next by default: ": "Sempre reproduzir próximo: ",
- "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ",
- "Listen by default: ": "Apenas áudio: ",
- "Proxy videos: ": "Usar proxy nos vídeos: ",
- "Default speed: ": "Velocidade preferida: ",
- "Preferred video quality: ": "Qualidade de vídeo preferida: ",
- "Player volume: ": "Volume da reprodução: ",
- "Default comments: ": "Preferência dos comentários: ",
+ "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 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 da reprodução: ",
+ "preferences_comments_label": "Preferência dos comentários: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Legendas predefinidas: ",
+ "preferences_captions_label": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ",
- "Show related videos: ": "Mostrar vídeos relacionados: ",
- "Show annotations by default: ": "Mostrar sempre anotações: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Preferências visuais",
- "Player style: ": "Estilo do reprodutor: ",
+ "preferences_related_videos_label": "Mostrar vídeos relacionados: ",
+ "preferences_annotations_label": "Mostrar anotações sempre: ",
+ "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
+ "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ",
+ "preferences_category_visual": "Preferências visuais",
+ "preferences_player_style_label": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "escuro",
"light": "claro",
- "Thin mode: ": "Modo compacto: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Preferências de subscrições",
- "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ",
+ "preferences_thin_mode_label": "Modo compacto: ",
+ "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 aos canais subscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
- "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ",
- "Sort videos by: ": "Ordenar vídeos por: ",
+ "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ",
+ "preferences_sort_label": "Ordenar vídeos por: ",
"published": "publicado",
"published - reverse": "publicado - inverso",
"alphabetically": "alfabeticamente",
@@ -101,51 +89,41 @@
"channel name - reverse": "nome do canal - inverso",
"Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ",
"Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
- "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ",
- "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ",
+ "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 pela web",
"`x` uploaded a video": "`x` publicou um novo vídeo",
"`x` is live": "`x` está em direto",
- "Data preferences": "Preferências de dados",
+ "preferences_category_data": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução",
- "Import/export data": "Importar/Exportar dados",
+ "Import/export data": "Importar / exportar dados",
"Change password": "Alterar palavra-chave",
"Manage subscriptions": "Gerir as subscrições",
"Manage tokens": "Gerir tokens",
"Watch history": "Histórico de reprodução",
- "Delete account": "Apagar conta",
- "Administrator preferences": "Preferências de administrador",
- "Default homepage: ": "Página inicial predefinida: ",
- "Feed menu: ": "Menu de subscrições: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "Top ativado: ",
+ "Delete account": "Eliminar conta",
+ "preferences_category_admin": "Preferências de administrador",
+ "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: ",
"CAPTCHA enabled: ": "CAPTCHA ativado: ",
"Login enabled: ": "Iniciar sessão ativado: ",
"Registration enabled: ": "Registar ativado: ",
"Report statistics: ": "Relatório de estatísticas: ",
- "Save preferences": "Gravar preferências",
+ "Save preferences": "Guardar preferências",
"Subscription manager": "Gerir subscrições",
"Token manager": "Gerir tokens",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições",
- "": "`x` subscrições"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
- "": "`x` tokens"
- },
- "Import/export": "Importar/Exportar",
- "unsubscribe": "Anular subscrição",
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} tokens",
+ "Import/export": "Importar / exportar",
+ "unsubscribe": "anular subscrição",
"revoke": "revogar",
"Subscriptions": "Subscrições",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas",
- "": "`x` notificações não vistas"
- },
- "search": "Pesquisar",
+ "search": "pesquisar",
"Log out": "Terminar sessão",
- "Released under the AGPLv3 on 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 do JavaScript.",
"View privacy policy.": "Ver a política de privacidade.",
@@ -155,17 +133,17 @@
"Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x` atrás",
- "Delete playlist `x`?": "Apagar a lista de reprodução 'x'?",
- "Delete playlist": "Apagar lista de reprodução",
+ "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",
"Playlist privacy": "Privacidade da lista de reprodução",
"Editing playlist `x`": "A editar lista de reprodução 'x'",
- "Show more": "",
- "Show less": "",
+ "Show more": "Mostrar mais",
+ "Show less": "Mostrar menos",
"Watch on YouTube": "Ver no YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
+ "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Broken? Try another Invidious Instance": "Falhou? Tente outra Instância do Invidious",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Género: ",
@@ -176,13 +154,9 @@
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Partilhado `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações",
- "": "`x` visualizações"
- },
"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.": "Oi! Parece que JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments": {
@@ -194,9 +168,9 @@
"Show replies": "Mostrar respostas",
"Incorrect password": "Palavra-chave incorreta",
"Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar sessão, certifique-se de que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
"Invalid TFA code": "Código TFA inválido",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a dois fatores de autenticação não está ativado para sua conta.",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).",
"Wrong answer": "Resposta errada",
"Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
@@ -209,25 +183,17 @@
"Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal:'x'",
- "Deleted or invalid channel": "Canal apagado ou inválido",
+ "Deleted or invalid channel": "Canal eliminado ou inválido",
"This channel does not exist.": "Este canal não existe.",
"Could not get channel info.": "Não foi possível obter as informações do canal.",
"Could not fetch comments": "Não foi possível obter os comentários",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas",
- "": "Ver `x` respostas"
- },
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos",
- "": "`x` pontos"
- },
- "Could not create mix.": "Não foi possível criar mistura.",
+ "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 páginas 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",
@@ -250,8 +216,8 @@
"Burmese": "Birmanês",
"Catalan": "Catalão",
"Cebuano": "Cebuano",
- "Chinese (Simplified)": "Chinês (Simplificado)",
- "Chinese (Traditional)": "Chinês (Tradicional)",
+ "Chinese (Simplified)": "Chinês (simplificado)",
+ "Chinese (Traditional)": "Chinês (tradicional)",
"Corsican": "Corso",
"Croatian": "Croata",
"Czech": "Checo",
@@ -340,88 +306,74 @@
"Yiddish": "Iídiche",
"Yoruba": "Ioruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos",
- "": "`x` anos"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses",
- "": "`x` meses"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas",
- "": "`x` semanas"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias",
- "": "`x` dias"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas",
- "": "`x` horas"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos",
- "": "`x` minutos"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos",
- "": "`x` segundos"
- },
+ "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": "",
- "Top": "Top",
+ "Search": "Pesquisar",
+ "Top": "Destaques",
"About": "Sobre",
"Rating: ": "Avaliação: ",
- "Language: ": "Idioma: ",
+ "preferences_locale_label": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
- "Default": "Predefinição",
+ "Default": "Predefinido",
"Music": "Música",
"Gaming": "Jogos",
"News": "Notícias",
"Movies": "Filmes",
- "Download": "Transferir",
- "Download as: ": "Transferir como: ",
+ "Download": "Descarregar",
+ "Download as: ": "Descarregar como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
- "YouTube comment permalink": "Hiperligação permanente ao comentário do YouTube",
- "permalink": "ligação permanente",
+ "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",
"Videos": "Vídeos",
"Playlists": "Listas de reprodução",
"Community": "Comunidade",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
+ "relevance": "Relevância",
+ "rating": "Avaliação",
+ "date": "Data de envio",
+ "views": "Visualizações",
+ "content_type": "Tipo",
+ "duration": "Duração",
+ "features": "Funcionalidades",
+ "sort": "Ordenar por",
+ "hour": "Última hora",
+ "today": "Hoje",
+ "week": "Esta semana",
+ "month": "Este mês",
+ "year": "Este ano",
+ "video": "Vídeo",
+ "channel": "Canal",
+ "playlist": "Lista de reprodução",
+ "movie": "Filme",
+ "show": "Espetáculo",
+ "hd": "HD",
+ "subtitles": "Legendas",
+ "creative_commons": "Creative Commons",
+ "3d": "3D",
+ "live": "Em direto",
+ "4k": "4K",
+ "location": "Localização",
+ "hdr": "HDR",
+ "filter": "Filtro",
"Current version: ": "Versão atual: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "next_steps_error_message": "Pode tentar as seguintes opções: ",
+ "next_steps_error_message_refresh": "Atualizar",
+ "next_steps_error_message_go_to_youtube": "Ir ao YouTube"
}
diff --git a/locales/pt.json b/locales/pt.json
new file mode 100644
index 00000000..c13c1fd5
--- /dev/null
+++ b/locales/pt.json
@@ -0,0 +1,414 @@
+{
+ "show": "Espetáculo",
+ "views": "Visualizações",
+ "date": "Data de envio",
+ "rating": "Avaliação",
+ "relevance": "Relevância",
+ "Broken? Try another Invidious Instance": "Falhou? Tente outra Instância do Invidious",
+ "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Show less": "Mostrar menos",
+ "Show more": "Mostrar mais",
+ "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.",
+ "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ",
+ "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ",
+ "preferences_category_misc": "Preferências diversas",
+ "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ",
+ "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
+ "next_steps_error_message_go_to_youtube": "Ir ao YouTube",
+ "next_steps_error_message": "Pode tentar as seguintes opções: ",
+ "next_steps_error_message_refresh": "Atualizar",
+ "filter": "Filtro",
+ "hdr": "HDR",
+ "location": "Localização",
+ "4k": "4K",
+ "live": "Em direto",
+ "3d": "3D",
+ "creative_commons": "Creative Commons",
+ "subtitles": "Legendas",
+ "hd": "HD",
+ "movie": "Filme",
+ "playlist": "Lista de reprodução",
+ "channel": "Canal",
+ "video": "Vídeo",
+ "year": "Este ano",
+ "month": "Este mês",
+ "week": "Esta semana",
+ "today": "Hoje",
+ "hour": "Última hora",
+ "sort": "Ordenar por",
+ "features": "Funcionalidades",
+ "duration": "Duração",
+ "content_type": "Tipo",
+ "permalink": "hiperligação permanente",
+ "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
+ "Download as: ": "Descarregar como: ",
+ "Download": "Descarregar",
+ "Default": "Predefinido",
+ "Top": "Destaques",
+ "Search": "Pesquisar",
+ "generic_count_years": "{{count}} segundo",
+ "generic_count_years_plural": "{{count}} segundos",
+ "generic_count_months": "{{count}} minuto",
+ "generic_count_months_plural": "{{count}} minutos",
+ "generic_count_weeks": "{{count}} hora",
+ "generic_count_weeks_plural": "{{count}} horas",
+ "generic_count_days": "{{count}} dia",
+ "generic_count_days_plural": "{{count}} dias",
+ "generic_count_hours": "{{count}} seman",
+ "generic_count_hours_plural": "{{count}} semanas",
+ "generic_count_minutes": "{{count}} mês",
+ "generic_count_minutes_plural": "{{count}} meses",
+ "generic_count_seconds": "{{count}} ano",
+ "generic_count_seconds_plural": "{{count}} anos",
+ "Chinese (Traditional)": "Chinês (tradicional)",
+ "Chinese (Simplified)": "Chinês (simplificado)",
+ "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.",
+ "Could not create mix.": "Não foi possível criar a mistura.",
+ "Deleted or invalid channel": "Canal eliminado ou inválido",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a não ter ativado na sua conta a autenticação de dois fatores (2FA).",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar a sessão, certifique-se que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
+ "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.",
+ "Delete playlist": "Eliminar lista de reprodução",
+ "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
+ "search": "pesquisar",
+ "unsubscribe": "anular subscrição",
+ "Import/export": "Importar / exportar",
+ "Save preferences": "Guardar preferências",
+ "Top enabled: ": "Destaques ativados: ",
+ "Delete account": "Eliminar conta",
+ "Import/export data": "Importar / exportar dados",
+ "preferences_annotations_label": "Mostrar anotações sempre: ",
+ "preferences_continue_label": "Reproduzir sempre o próximo: ",
+ "Sign In": "Iniciar sessão",
+ "Log in/register": "Iniciar sessão/registar",
+ "Delete account?": "Eliminar conta?",
+ "Import and Export Data": "Importar e exportar dados",
+ "Cannot change password for Google accounts": "Não é possível alterar a palavra-chave para contas do Google",
+ "Filipino": "Filipino",
+ "Estonian": "Estónio",
+ "Esperanto": "Esperanto",
+ "Dutch": "Holandês",
+ "Danish": "Dinamarquês",
+ "Czech": "Checo",
+ "Croatian": "Croata",
+ "Corsican": "Corso",
+ "Cebuano": "Cebuano",
+ "Catalan": "Catalão",
+ "Burmese": "Birmanês",
+ "Bulgarian": "Búlgaro",
+ "Bosnian": "Bósnio",
+ "Belarusian": "Bielorrusso",
+ "Basque": "Basco",
+ "Bangla": "Bangla",
+ "Azerbaijani": "Azerbaijano",
+ "Armenian": "Arménio",
+ "Arabic": "Árabe",
+ "Amharic": "Amárico",
+ "Albanian": "Albanês",
+ "Afrikaans": "Africano",
+ "English (auto-generated)": "Inglês (auto-gerado)",
+ "English": "Inglês",
+ "Token is expired, please try again": "Token expirou, tente novamente",
+ "No such user": "Utilizador inválido",
+ "Erroneous token": "Token inválido",
+ "Erroneous challenge": "Desafio inválido",
+ "Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
+ "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
+ "Playlist does not exist.": "A lista de reprodução não existe.",
+ "Not a playlist.": "Não é uma lista de reprodução.",
+ "Empty playlist": "Lista de reprodução vazia",
+ "Load more": "Carregar mais",
+ "`x` ago": "`x` atrás",
+ "Could not fetch comments": "Não foi possível obter os comentários",
+ "Could not get channel info.": "Não foi possível obter as informações do canal.",
+ "This channel does not exist.": "Este canal não existe.",
+ "channel:`x`": "canal:'x'",
+ "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
+ "Please log in": "Por favor, inicie sessão",
+ "Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
+ "Password cannot be empty": "A palavra-chave não pode estar vazia",
+ "Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'",
+ "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
+ "Password is a required field": "Palavra-chave é um campo obrigatório",
+ "User ID is a required field": "O nome de utilizador é um campo obrigatório",
+ "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
+ "Erroneous CAPTCHA": "CAPTCHA inválido",
+ "Wrong answer": "Resposta errada",
+ "Invalid TFA code": "Código TFA inválido",
+ "Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas",
+ "Incorrect password": "Palavra-chave incorreta",
+ "Show replies": "Mostrar respostas",
+ "Hide replies": "Ocultar respostas",
+ "View Reddit comments": "Ver comentários do Reddit",
+ "View `x` comments": {
+ "": "Ver `x` comentários",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários"
+ },
+ "View more comments on Reddit": "Ver mais comentários no Reddit",
+ "View YouTube comments": "Ver comentários do YouTube",
+ "Premieres `x`": "Estreias 'x'",
+ "Premieres in `x`": "Estreias em 'x'",
+ "Shared `x`": "Partilhado `x`",
+ "Blacklisted regions: ": "Regiões bloqueadas: ",
+ "Whitelisted regions: ": "Regiões permitidas: ",
+ "Engagement: ": "Compromisso: ",
+ "Wilson score: ": "Pontuação de Wilson: ",
+ "Family friendly? ": "Filtrar conteúdo impróprio: ",
+ "License: ": "Licença: ",
+ "Genre: ": "Género: ",
+ "Show annotations": "Mostrar anotações",
+ "Hide annotations": "Ocultar anotações",
+ "Watch on YouTube": "Ver no YouTube",
+ "Editing playlist `x`": "A editar lista de reprodução 'x'",
+ "Playlist privacy": "Privacidade da lista de reprodução",
+ "Title": "Título",
+ "Create playlist": "Criar lista de reprodução",
+ "Updated `x` ago": "Atualizado `x` atrás",
+ "View all playlists": "Ver todas as listas de reprodução",
+ "Private": "Privado",
+ "Unlisted": "Não listado",
+ "Public": "Público",
+ "Trending": "Tendências",
+ "View privacy policy.": "Ver a política de privacidade.",
+ "View JavaScript license information.": "Ver informações da licença do JavaScript.",
+ "Source available here.": "Código-fonte disponível aqui.",
+ "Log out": "Terminar sessão",
+ "Subscriptions": "Subscrições",
+ "revoke": "revogar",
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} tokens",
+ "Token": "Token",
+ "Token manager": "Gerir tokens",
+ "Subscription manager": "Gerir subscrições",
+ "Report statistics: ": "Relatório de estatísticas: ",
+ "Registration enabled: ": "Registar ativado: ",
+ "Login enabled: ": "Iniciar sessão ativado: ",
+ "CAPTCHA enabled: ": "CAPTCHA ativado: ",
+ "preferences_feed_menu_label": "Menu de subscrições: ",
+ "preferences_default_home_label": "Página inicial predefinida: ",
+ "preferences_category_admin": "Preferências de administrador",
+ "Watch history": "Histórico de reprodução",
+ "Manage tokens": "Gerir tokens",
+ "Manage subscriptions": "Gerir as subscrições",
+ "Change password": "Alterar palavra-chave",
+ "Clear watch history": "Limpar histórico de reprodução",
+ "preferences_category_data": "Preferências de dados",
+ "`x` is live": "`x` está em direto",
+ "`x` uploaded a video": "`x` publicou um novo vídeo",
+ "Enable web notifications": "Ativar notificações pela web",
+ "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ",
+ "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ",
+ "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
+ "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ",
+ "channel name - reverse": "nome do canal - inverso",
+ "channel name": "nome do canal",
+ "alphabetically - reverse": "alfabeticamente - inverso",
+ "alphabetically": "alfabeticamente",
+ "published - reverse": "publicado - inverso",
+ "published": "publicado",
+ "preferences_sort_label": "Ordenar vídeos por: ",
+ "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ",
+ "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
+ "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ",
+ "preferences_category_subscription": "Preferências de subscrições",
+ "preferences_thin_mode_label": "Modo compacto: ",
+ "light": "claro",
+ "dark": "escuro",
+ "preferences_dark_mode_label": "Tema: ",
+ "Dark mode: ": "Modo escuro: ",
+ "preferences_player_style_label": "Estilo do reprodutor: ",
+ "preferences_category_visual": "Preferências visuais",
+ "preferences_related_videos_label": "Mostrar vídeos relacionados: ",
+ "Fallback captions: ": "Legendas alternativas: ",
+ "preferences_captions_label": "Legendas predefinidas: ",
+ "reddit": "Reddit",
+ "youtube": "YouTube",
+ "preferences_comments_label": "Preferência dos comentários: ",
+ "preferences_volume_label": "Volume da reprodução: ",
+ "preferences_quality_label": "Qualidade de vídeo preferida: ",
+ "preferences_speed_label": "Velocidade preferida: ",
+ "preferences_local_label": "Usar proxy nos vídeos: ",
+ "preferences_listen_label": "Apenas áudio: ",
+ "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ",
+ "preferences_autoplay_label": "Reprodução automática: ",
+ "preferences_video_loop_label": "Repetir sempre: ",
+ "preferences_category_player": "Preferências do reprodutor",
+ "Preferences": "Preferências",
+ "Google verification code": "Código de verificação do Google",
+ "E-mail": "E-mail",
+ "Register": "Registar",
+ "Image CAPTCHA": "Imagem CAPTCHA",
+ "Text CAPTCHA": "Texto CAPTCHA",
+ "Time (h:mm:ss):": "Tempo (h:mm:ss):",
+ "Password": "Palavra-chave",
+ "User ID": "Utilizador",
+ "Log in with Google": "Iniciar sessão com o Google",
+ "Log in": "Iniciar sessão",
+ "source": "código-fonte",
+ "JavaScript license information": "Informação de licença do JavaScript",
+ "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
+ "History": "Histórico",
+ "Export data as JSON": "Exportar dados como JSON",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
+ "Export subscriptions as OPML": "Exportar subscrições como OPML",
+ "Export": "Exportar",
+ "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
+ "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
+ "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
+ "Import YouTube subscriptions": "Importar subscrições do YouTube",
+ "Import Invidious data": "Importar dados do Invidious",
+ "Import": "Importar",
+ "No": "Não",
+ "Yes": "Sim",
+ "Authorize token for `x`?": "Autorizar token para `x`?",
+ "Authorize token?": "Autorizar token?",
+ "New passwords must match": "As novas palavra-chaves devem corresponder",
+ "New password": "Nova palavra-chave",
+ "Clear watch history?": "Limpar histórico de reprodução?",
+ "Previous page": "Página anterior",
+ "Next page": "Próxima página",
+ "last": "últimos",
+ "Current version: ": "Versão atual: ",
+ "Community": "Comunidade",
+ "Playlists": "Listas de reprodução",
+ "Videos": "Vídeos",
+ "Video mode": "Modo de vídeo",
+ "Audio mode": "Modo de áudio",
+ "`x` marked it with a ❤": "`x` foi marcado como ❤",
+ "(edited)": "(editado)",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "Movies": "Filmes",
+ "News": "Notícias",
+ "Gaming": "Jogos",
+ "Music": "Música",
+ "View as playlist": "Ver como lista de reprodução",
+ "preferences_locale_label": "Idioma: ",
+ "Rating: ": "Avaliação: ",
+ "About": "Sobre",
+ "Popular": "Popular",
+ "Fallback comments: ": "Comentários alternativos: ",
+ "Zulu": "Zulu",
+ "Yoruba": "Ioruba",
+ "Yiddish": "Iídiche",
+ "Xhosa": "Xhosa",
+ "Western Frisian": "Frísio Ocidental",
+ "Welsh": "Galês",
+ "Vietnamese": "Vietnamita",
+ "Uzbek": "Uzbeque",
+ "Urdu": "Urdu",
+ "Ukrainian": "Ucraniano",
+ "Turkish": "Turco",
+ "Thai": "Tailandês",
+ "Telugu": "Telugu",
+ "Tamil": "Tâmil",
+ "Tajik": "Tajique",
+ "Swedish": "Sueco",
+ "Swahili": "Suaíli",
+ "Sundanese": "Sudanês",
+ "Spanish (Latin America)": "Espanhol (América Latina)",
+ "Spanish": "Espanhol",
+ "Southern Sotho": "Sotho do Sul",
+ "Somali": "Somali",
+ "Slovenian": "Esloveno",
+ "Slovak": "Eslovaco",
+ "Sinhala": "Cingalês",
+ "Sindhi": "Sindhi",
+ "Shona": "Shona",
+ "Serbian": "Sérvio",
+ "Scottish Gaelic": "Gaélico escocês",
+ "Samoan": "Samoano",
+ "Russian": "Russo",
+ "Romanian": "Romeno",
+ "Punjabi": "Punjabi",
+ "Portuguese": "Português",
+ "Polish": "Polaco",
+ "Persian": "Persa",
+ "Pashto": "Pashto",
+ "Nyanja": "Nyanja",
+ "Norwegian Bokmål": "Bokmål norueguês",
+ "Nepali": "Nepalês",
+ "Mongolian": "Mongol",
+ "Marathi": "Marathi",
+ "Maori": "Maori",
+ "Maltese": "Maltês",
+ "Malayalam": "Malaiala",
+ "Malay": "Malaio",
+ "Malagasy": "Malgaxe",
+ "Macedonian": "Macedónio",
+ "Luxembourgish": "Luxemburguês",
+ "Lithuanian": "Lituano",
+ "Latvian": "Letão",
+ "Latin": "Latim",
+ "Lao": "Laosiano",
+ "Kyrgyz": "Quirguiz",
+ "Kurdish": "Curdo",
+ "Korean": "Coreano",
+ "Khmer": "Khmer",
+ "Kazakh": "Cazaque",
+ "Kannada": "Canarim",
+ "Javanese": "Javanês",
+ "Japanese": "Japonês",
+ "Italian": "Italiano",
+ "Irish": "Irlandês",
+ "Indonesian": "Indonésio",
+ "Igbo": "Igbo",
+ "Icelandic": "Islandês",
+ "Hungarian": "Húngaro",
+ "Hmong": "Hmong",
+ "Hindi": "Hindi",
+ "Hebrew": "Hebraico",
+ "Hawaiian": "Havaiano",
+ "Hausa": "Hauçá",
+ "Haitian Creole": "Crioulo haitiano",
+ "Gujarati": "Guzerate",
+ "Greek": "Grego",
+ "German": "Alemão",
+ "Georgian": "Georgiano",
+ "Galician": "Galego",
+ "French": "Francês",
+ "Finnish": "Finlandês",
+ "popular": "popular",
+ "oldest": "mais antigos",
+ "newest": "mais recentes",
+ "View playlist on YouTube": "Ver lista de reprodução no YouTube",
+ "View channel on YouTube": "Ver canal no YouTube",
+ "Subscribe": "Subscrever",
+ "Unsubscribe": "Anular subscrição",
+ "Shared `x` ago": "Partilhado `x` atrás",
+ "LIVE": "Em direto",
+ "short": "Curto (< 4 minutos)",
+ "long": "Longo (> 20 minutos)",
+ "footer_source_code": "Código-fonte",
+ "footer_original_source_code": "Código-fonte original",
+ "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
+ "footer_documentation": "Documentação",
+ "footer_modfied_source_code": "Código-fonte alterado",
+ "footer_donate_page": "Doar",
+ "preferences_region_label": "País do conteúdo: ",
+ "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ",
+ "preferences_quality_option_small": "Baixa",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "Melhor",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "purchased": "Adquirido",
+ "360": "360°",
+ "videoinfo_invidious_embed_link": "Incorporar hiperligação",
+ "Video unavailable": "Vídeo não disponível",
+ "invidious": "Invidious",
+ "preferences_quality_option_medium": "Médio",
+ "preferences_quality_option_dash": "DASH (qualidade adaptativa)",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_480p": "480p",
+ "videoinfo_watch_on_youTube": "Ver no YouTube",
+ "preferences_quality_dash_option_worst": "Pior",
+ "none": "nenhum",
+ "videoinfo_youTube_embed_link": "Incorporar",
+ "preferences_save_player_pos_label": "Guardar o tempo atual do vídeo: "
+}
diff --git a/locales/ro.json b/locales/ro.json
index a8877853..2ea6496b 100644
--- a/locales/ro.json
+++ b/locales/ro.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonați",
- "": "`x` abonați"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoclipuri",
- "": "`x` videoclipuri"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` liste de redare",
- "": "`x` liste de redare"
- },
"LIVE": "ÎN DIRECT",
"Shared `x` ago": "Adăugat acum `x`",
"Unsubscribe": "Dezabonați-vă",
@@ -60,39 +48,35 @@
"E-mail": "E-mail",
"Google verification code": "Cod de verificare Google",
"Preferences": "Preferințe",
- "Player preferences": "Setări de redare",
- "Always loop: ": "Reluați videoclipul la nesfârșit: ",
- "Autoplay: ": "Porniți videoclipurile automat: ",
- "Play next by default: ": "Vizionați următoarele videoclipuri în mod implicit: ",
- "Autoplay next video: ": "Porniți următorul videoclip automat: ",
- "Listen by default: ": "Numai audio: ",
- "Proxy videos: ": "Redați videoclipurile printr-un proxy: ",
- "Default speed: ": "Viteza de redare implicită: ",
- "Preferred video quality: ": "Calitatea videoclipurilor: ",
- "Player volume: ": "Volumul videoclipurilor: ",
- "Default comments: ": "Sursa comentariilor: ",
+ "preferences_category_player": "Setări de redare",
+ "preferences_video_loop_label": "Reluați videoclipul la nesfârșit: ",
+ "preferences_autoplay_label": "Porniți videoclipurile automat: ",
+ "preferences_continue_label": "Vizionați următoarele videoclipuri în mod implicit: ",
+ "preferences_continue_autoplay_label": "Porniți următorul videoclip automat: ",
+ "preferences_listen_label": "Numai audio: ",
+ "preferences_local_label": "Redați videoclipurile printr-un proxy: ",
+ "preferences_speed_label": "Viteza de redare implicită: ",
+ "preferences_quality_label": "Calitatea videoclipurilor: ",
+ "preferences_volume_label": "Volumul videoclipurilor: ",
+ "preferences_comments_label": "Sursa comentariilor: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Subtitrări implicite: ",
+ "preferences_captions_label": "Subtitrări implicite: ",
"Fallback captions: ": "Subtitrări alternative: ",
- "Show related videos: ": "Afișați videoclipurile asemănătoare: ",
- "Show annotations by default: ": "Afișați adnotările în mod implicit: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Preferințele site-ului",
- "Player style: ": "Stilul player-ului : ",
+ "preferences_related_videos_label": "Afișați videoclipurile asemănătoare: ",
+ "preferences_annotations_label": "Afișați adnotările în mod implicit: ",
+ "preferences_category_visual": "Preferințele site-ului",
+ "preferences_player_style_label": "Stilul player-ului : ",
"Dark mode: ": "Modul întunecat : ",
- "Theme: ": "Tema : ",
+ "preferences_dark_mode_label": "Tema : ",
"dark": "întunecat",
"light": "luminos",
- "Thin mode: ": "Mod lejer: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Preferințele paginii de abonamente",
- "Show annotations by default for subscribed channels: ": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ",
+ "preferences_thin_mode_label": "Mod lejer: ",
+ "preferences_category_subscription": "Preferințele paginii de abonamente",
+ "preferences_annotations_subscribed_label": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ",
"Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ",
- "Number of videos shown in feed: ": "Numărul de videoclipuri afișate pe pagina de abonamente: ",
- "Sort videos by: ": "Sortați videoclipurile în funcție de: ",
+ "preferences_max_results_label": "Numărul de videoclipuri afișate pe pagina de abonamente: ",
+ "preferences_sort_label": "Sortați videoclipurile în funcție de: ",
"published": "data publicării",
"published - reverse": "data publicării - inversată",
"alphabetically": "în ordine alfabetică",
@@ -101,12 +85,12 @@
"channel name - reverse": "numele canalului - inversat",
"Only show latest video from channel: ": "Afișați numai cel mai recent videoclip publicat de canalele la care v-ați abonat: ",
"Only show latest unwatched video from channel: ": "Afișați numai cel mai recent videoclip nevizionat publicat de canalele la care v-ați abonat: ",
- "Only show unwatched: ": "Afișați numai videoclipurile nevizionate: ",
- "Only show notifications (if there are any): ": "Afișați numai notificările (dacă există): ",
+ "preferences_unseen_only_label": "Afișați numai videoclipurile nevizionate: ",
+ "preferences_notifications_only_label": "Afișați numai notificările (dacă există): ",
"Enable web notifications": "Activați notificările web",
"`x` uploaded a video": "`x` a publicat un videoclip",
"`x` is live": "`x` este în direct",
- "Data preferences": "Preferințe legate de date",
+ "preferences_category_data": "Preferințe legate de date",
"Clear watch history": "Ștergeți istoricul videoclipurilor vizionate",
"Import/export data": "Importați/exportați datele",
"Change password": "Schimbați parola",
@@ -114,10 +98,9 @@
"Manage tokens": "Gestionați tokenele",
"Watch history": "Istoricul videoclipurilor vizionate",
"Delete account": "Ștergeți contul",
- "Administrator preferences": "Preferințele Administratorului",
- "Default homepage: ": "Pagina principală implicită: ",
- "Feed menu: ": "Preferințe legate de pagina de abonamente: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Preferințele Administratorului",
+ "preferences_default_home_label": "Pagina principală implicită: ",
+ "preferences_feed_menu_label": "Preferințe legate de pagina de abonamente: ",
"Top enabled: ": "Top activat: ",
"CAPTCHA enabled: ": "CAPTCHA activat : ",
"Login enabled: ": "Autentificare activată : ",
@@ -127,25 +110,12 @@
"Subscription manager": "Gestionați abonamentele",
"Token manager": "Manager de Tokene",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonamente",
- "": "`x` abonamente"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens",
- "": "`x` tokens"
- },
"Import/export": "Importați/Exportați",
"unsubscribe": "dezabonați-vă",
"revoke": "revocați",
"Subscriptions": "Abonamente",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificări nevăzute",
- "": "`x` notificări nevăzute"
- },
"search": "căutați",
"Log out": "Deconectați-vă",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Codul sursă este disponibil aici.",
"View JavaScript license information.": "Informații legate de licența JavaScript.",
"View privacy policy.": "Politica de confidențialitate.",
@@ -161,11 +131,7 @@
"Title": "Titlu",
"Playlist privacy": "Parametrii de confidențialitate ai listei de redare",
"Editing playlist `x`": "Modificați lista de redare `x`",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "Urmăriți videoclipul pe YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Ascundeți adnotările",
"Show annotations": "Afișați adnotările",
"Genre: ": "Categorie: ",
@@ -176,10 +142,6 @@
"Whitelisted regions: ": "Regiunile de pe lista albă: ",
"Blacklisted regions: ": "Regiunile de pe lista neagră: ",
"Shared `x`": "Publicat pe `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vizionări",
- "": "`x` vizionări"
- },
"Premieres in `x`": "Premiera în `x`",
"Premieres `x`": "Premiera pe `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.": "Se pare că ați dezactivat JavaScript. Apăsați aici pentru a vizualiza comentariile. Țineți minte faptul că încărcarea lor ar putea să dureze puțin mai mult.",
@@ -213,16 +175,8 @@
"This channel does not exist.": "Acest canal nu există.",
"Could not get channel info.": "Nu am putut primi informații despre acest canal.",
"Could not fetch comments": "Încărcarea comentariilor a eșuat.",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Afișați `x` replici",
- "": "Afișați `x` replici"
- },
"`x` ago": "acum `x`",
"Load more": "Vedeți mai mult",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puncte",
- "": "`x` puncte"
- },
"Could not create mix.": "Nu am putut crea această listă de redare.",
"Empty playlist": "Lista de redare este goală",
"Not a playlist.": "Lista de redare este invalidă.",
@@ -340,41 +294,12 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zoulou",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ani",
- "": "`x` ani"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` luni",
- "": "`x` luni"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` săptămâni",
- "": "`x` săptămâni"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` zile",
- "": "`x` zile"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ore",
- "": "`x` ore"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute",
- "": "`x` minute"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secunde",
- "": "`x` secunde"
- },
"Fallback comments: ": "Comentarii alternative: ",
"Popular": "Popular",
- "Search": "",
"Top": "Top",
"About": "Despre",
"Rating: ": "Evaluare: ",
- "Language: ": "Limbă: ",
+ "preferences_locale_label": "Limbă: ",
"View as playlist": "Vizualizați ca listă de redare",
"Default": "Implicit",
"Music": "Muzică",
@@ -393,35 +318,5 @@
"Videos": "Videoclipuri",
"Playlists": "Liste de redare",
"Community": "Comunitate",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "Versiunea actuală: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "Versiunea actuală: "
}
diff --git a/locales/ru.json b/locales/ru.json
index d26cd058..809f7187 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` подписчиков",
- "": "`x` подписчиков"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` видео",
- "": "`x` видео"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` плейлистов",
- "": "`x` плейлистов"
- },
"LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться",
@@ -60,39 +48,39 @@
"E-mail": "Электронная почта",
"Google verification code": "Код подтверждения Google",
"Preferences": "Настройки",
- "Player preferences": "Настройки проигрывателя",
- "Always loop: ": "Всегда повторять: ",
- "Autoplay: ": "Автовоспроизведение: ",
- "Play next by default: ": "Всегда включать следующее видео? ",
- "Autoplay next video: ": "Автопроигрывание следующего видео: ",
- "Listen by default: ": "Режим «только аудио» по умолчанию: ",
- "Proxy videos: ": "Проигрывать видео через прокси? ",
- "Default speed: ": "Скорость видео по умолчанию: ",
- "Preferred video quality: ": "Предпочтительное качество видео: ",
- "Player volume: ": "Громкость видео: ",
- "Default comments: ": "Источник комментариев: ",
+ "preferences_category_player": "Настройки проигрывателя",
+ "preferences_video_loop_label": "Всегда повторять: ",
+ "preferences_autoplay_label": "Автовоспроизведение: ",
+ "preferences_continue_label": "Всегда включать следующее видео? ",
+ "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
+ "preferences_listen_label": "Режим «только аудио» по умолчанию: ",
+ "preferences_local_label": "Проигрывать видео через прокси? ",
+ "preferences_speed_label": "Скорость видео по умолчанию: ",
+ "preferences_quality_label": "Предпочтительное качество видео: ",
+ "preferences_volume_label": "Громкость видео: ",
+ "preferences_comments_label": "Источник комментариев: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Основной язык субтитров: ",
+ "preferences_captions_label": "Основной язык субтитров: ",
"Fallback captions: ": "Дополнительный язык субтитров: ",
- "Show related videos: ": "Показывать похожие видео? ",
- "Show annotations by default: ": "Всегда показывать аннотации? ",
- "Automatically extend video description: ": "Автоматически раскрывать описание видео: ",
- "Interactive 360 degree videos: ": "Интерактивные 360-градусные видео: ",
- "Visual preferences": "Настройки сайта",
- "Player style: ": "Стиль проигрывателя: ",
+ "preferences_related_videos_label": "Показывать похожие видео? ",
+ "preferences_annotations_label": "Всегда показывать аннотации? ",
+ "preferences_extend_desc_label": "Автоматически раскрывать описание видео: ",
+ "preferences_vr_mode_label": "Интерактивные 360-градусные видео: ",
+ "preferences_category_visual": "Настройки сайта",
+ "preferences_player_style_label": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ",
- "Theme: ": "Тема: ",
+ "preferences_dark_mode_label": "Тема: ",
"dark": "темная",
"light": "светлая",
- "Thin mode: ": "Облегчённое оформление: ",
- "Miscellaneous preferences": "Прочие предпочтения",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ",
- "Subscription preferences": "Настройки подписок",
- "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
+ "preferences_thin_mode_label": "Облегчённое оформление: ",
+ "preferences_category_misc": "Прочие предпочтения",
+ "preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ",
+ "preferences_category_subscription": "Настройки подписок",
+ "preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
- "Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ",
- "Sort videos by: ": "Сортировать видео: ",
+ "preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ",
+ "preferences_sort_label": "Сортировать видео: ",
"published": "по дате публикации",
"published - reverse": "по дате публикации в обратном порядке",
"alphabetically": "по алфавиту",
@@ -101,12 +89,12 @@
"channel name - reverse": "по названию канала в обратном порядке",
"Only show latest video from channel: ": "Показывать только последние видео с каналов: ",
"Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ",
- "Only show unwatched: ": "Показывать только непросмотренные видео: ",
- "Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ",
+ "preferences_unseen_only_label": "Показывать только непросмотренные видео: ",
+ "preferences_notifications_only_label": "Показывать только оповещения, если они есть: ",
"Enable web notifications": "Включить уведомления в браузере",
"`x` uploaded a video": "`x` разместил видео",
"`x` is live": "`x` в прямом эфире",
- "Data preferences": "Настройки данных",
+ "preferences_category_data": "Настройки данных",
"Clear watch history": "Очистить историю просмотров",
"Import/export data": "Импорт/Экспорт данных",
"Change password": "Изменить пароль",
@@ -114,10 +102,10 @@
"Manage tokens": "Управлять токенами",
"Watch history": "История просмотров",
"Delete account": "Удалить аккаунт",
- "Administrator preferences": "Администраторские настройки",
- "Default homepage: ": "Главная страница по умолчанию: ",
- "Feed menu: ": "Меню ленты видео: ",
- "Show nickname on top: ": "Показать ник вверху: ",
+ "preferences_category_admin": "Администраторские настройки",
+ "preferences_default_home_label": "Главная страница по умолчанию: ",
+ "preferences_feed_menu_label": "Меню ленты видео: ",
+ "preferences_show_nick_label": "Показать ник вверху: ",
"Top enabled: ": "Включить топ видео? ",
"CAPTCHA enabled: ": "Включить капчу? ",
"Login enabled: ": "Включить авторизацию? ",
@@ -127,25 +115,13 @@
"Subscription manager": "Менеджер подписок",
"Token manager": "Менеджер токенов",
"Token": "Токен",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` подписок",
- "": "`x` подписок"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` токенов",
- "": "`x` токенов"
- },
"Import/export": "Импорт и экспорт",
"unsubscribe": "отписаться",
"revoke": "отозвать",
"Subscriptions": "Подписки",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` непросмотренных оповещений",
- "": "`x` непросмотренных оповещений"
- },
"search": "поиск",
"Log out": "Выйти",
- "Released under the AGPLv3 on Github.": "",
+ "Released under the AGPLv3 on Github.": "Выпущено под лицензией AGPLv3 на Github.",
"Source available here.": "Исходный код доступен здесь.",
"View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.",
"View privacy policy.": "Посмотреть политику конфиденциальности.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Доступно в регионах: ",
"Blacklisted regions: ": "Недоступно в регионах: ",
"Shared `x`": "Опубликовано `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` просмотров",
- "": "`x` просмотров"
- },
"Premieres in `x`": "Премьера через `x`",
"Premieres `x`": "Премьера `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Такого канала не существует.",
"Could not get channel info.": "Не удаётся получить информацию об этом канале.",
"Could not fetch comments": "Не удаётся загрузить комментарии",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Показать `x` ответов",
- "": "Показать `x` ответов"
- },
"`x` ago": "`x` назад",
"Load more": "Загрузить больше",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` очков",
- "": "`x` очков"
- },
"Could not create mix.": "Не удаётся создать микс.",
"Empty playlist": "Плейлист пуст",
"Not a playlist.": "Некорректный плейлист.",
@@ -340,41 +304,13 @@
"Yiddish": "Идиш",
"Yoruba": "Йоруба",
"Zulu": "Зулусский",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` лет",
- "": "`x` лет"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` месяцев",
- "": "`x` месяцев"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` недель",
- "": "`x` недель"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` дней",
- "": "`x` дней"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` часов",
- "": "`x` часов"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` минут",
- "": "`x` минут"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` секунд",
- "": "`x` секунд"
- },
"Fallback comments: ": "Резервные комментарии: ",
"Popular": "Популярное",
"Search": "Поиск",
"Top": "Топ",
"About": "О сайте",
"Rating: ": "Рейтинг: ",
- "Language: ": "Язык: ",
+ "preferences_locale_label": "Язык: ",
"View as playlist": "Смотреть как плейлист",
"Default": "По-умолчанию",
"Music": "Музыка",
@@ -399,7 +335,7 @@
"views": "Просмотры",
"content_type": "Тип",
"duration": "Длительность",
- "features": "",
+ "features": "Функции",
"sort": "Сортировать по",
"hour": "Последний час",
"today": "Сегодня",
@@ -423,5 +359,7 @@
"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",
+ "short": "Короткие (< 4 минут)",
+ "long": "Длинные (> 20 минут)"
}
diff --git a/locales/si.json b/locales/si.json
deleted file mode 100644
index f38c56b7..00000000
--- a/locales/si.json
+++ /dev/null
@@ -1,427 +0,0 @@
-{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "LIVE": "සජීව",
- "Shared `x` ago": "",
- "Unsubscribe": "",
- "Subscribe": "",
- "View channel on YouTube": "",
- "View playlist on YouTube": "",
- "newest": "",
- "oldest": "",
- "popular": "ජනප්‍රිය",
- "last": "",
- "Next page": "",
- "Previous page": "",
- "Clear watch history?": "",
- "New password": "",
- "New passwords must match": "",
- "Cannot change password for Google accounts": "",
- "Authorize token?": "",
- "Authorize token for `x`?": "",
- "Yes": "",
- "No": "",
- "Import and Export Data": "",
- "Import": "",
- "Import Invidious data": "",
- "Import YouTube subscriptions": "",
- "Import FreeTube subscriptions (.db)": "",
- "Import NewPipe subscriptions (.json)": "",
- "Import NewPipe data (.zip)": "",
- "Export": "",
- "Export subscriptions as OPML": "",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "",
- "Export data as JSON": "",
- "Delete account?": "",
- "History": "",
- "An alternative front-end to YouTube": "",
- "JavaScript license information": "",
- "source": "",
- "Log in": "",
- "Log in/register": "",
- "Log in with Google": "",
- "User ID": "",
- "Password": "",
- "Time (h:mm:ss):": "",
- "Text CAPTCHA": "",
- "Image CAPTCHA": "",
- "Sign In": "",
- "Register": "",
- "E-mail": "",
- "Google verification code": "",
- "Preferences": "",
- "Player preferences": "",
- "Always loop: ": "",
- "Autoplay: ": "",
- "Play next by default: ": "",
- "Autoplay next video: ": "",
- "Listen by default: ": "",
- "Proxy videos: ": "",
- "Default speed: ": "",
- "Preferred video quality: ": "",
- "Player volume: ": "",
- "Default comments: ": "",
- "youtube": "",
- "reddit": "",
- "Default captions: ": "",
- "Fallback captions: ": "",
- "Show related videos: ": "",
- "Show annotations by default: ": "",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "",
- "Player style: ": "",
- "Dark mode: ": "",
- "Theme: ": "",
- "dark": "",
- "light": "",
- "Thin mode: ": "",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "",
- "Show annotations by default for subscribed channels: ": "",
- "Redirect homepage to feed: ": "",
- "Number of videos shown in feed: ": "",
- "Sort videos by: ": "",
- "published": "",
- "published - reverse": "",
- "alphabetically": "",
- "alphabetically - reverse": "",
- "channel name": "",
- "channel name - reverse": "",
- "Only show latest video from channel: ": "",
- "Only show latest unwatched video from channel: ": "",
- "Only show unwatched: ": "",
- "Only show notifications (if there are any): ": "",
- "Enable web notifications": "",
- "`x` uploaded a video": "",
- "`x` is live": "",
- "Data preferences": "",
- "Clear watch history": "",
- "Import/export data": "",
- "Change password": "",
- "Manage subscriptions": "",
- "Manage tokens": "",
- "Watch history": "",
- "Delete account": "",
- "Administrator preferences": "",
- "Default homepage: ": "",
- "Feed menu: ": "",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
- "Save preferences": "",
- "Subscription manager": "",
- "Token manager": "",
- "Token": "",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "search": "",
- "Log out": "",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "",
- "View JavaScript license information.": "",
- "View privacy policy.": "",
- "Trending": "",
- "Public": "",
- "Unlisted": "",
- "Private": "",
- "View all playlists": "",
- "Updated `x` ago": "",
- "Delete playlist `x`?": "",
- "Delete playlist": "",
- "Create playlist": "",
- "Title": "",
- "Playlist privacy": "",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
- "Genre: ": "",
- "License: ": "",
- "Family friendly? ": "",
- "Wilson score: ": "",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
-}
diff --git a/locales/sk.json b/locales/sk.json
index cdeca6c0..f20ad75a 100644
--- a/locales/sk.json
+++ b/locales/sk.json
@@ -1,22 +1,8 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` odberateľov"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"LIVE": "NAŽIVO",
- "Shared `x` ago": "",
"Unsubscribe": "Zrušiť odber",
"Subscribe": "Odoberať",
"View channel on YouTube": "Zobraziť kanál na YouTube",
- "View playlist on YouTube": "",
"newest": "najnovšie",
"oldest": "najstaršie",
"popular": "populárne",
@@ -28,7 +14,6 @@
"New passwords must match": "Nové heslá sa musia zhodovať",
"Cannot change password for Google accounts": "Heslo pre účty Google sa nedá zmeniť",
"Authorize token?": "Autorizovať token?",
- "Authorize token for `x`?": "",
"Yes": "Áno",
"No": "Nie",
"Import and Export Data": "Import a Export údajov",
@@ -60,39 +45,34 @@
"E-mail": "E-mail",
"Google verification code": "Overovací kód Google",
"Preferences": "Nastavenia",
- "Player preferences": "Nastavenia prehrávača",
- "Always loop: ": "Vždy opakovať: ",
- "Autoplay: ": "Automatické prehrávanie: ",
- "Play next by default: ": "",
- "Autoplay next video: ": "Automatické prehrávanie nasledujúceho videa: ",
- "Listen by default: ": "Predvolene počúvať: ",
- "Proxy videos: ": "Proxy videá: ",
- "Default speed: ": "Predvolená rýchlosť: ",
- "Preferred video quality: ": "Preferovaná kvalita videa: ",
- "Player volume: ": "Hlasitosť prehrávača: ",
- "Default comments: ": "Predvolené komentáre: ",
+ "preferences_category_player": "Nastavenia prehrávača",
+ "preferences_video_loop_label": "Vždy opakovať: ",
+ "preferences_autoplay_label": "Automatické prehrávanie: ",
+ "preferences_continue_autoplay_label": "Automatické prehrávanie nasledujúceho videa: ",
+ "preferences_listen_label": "Predvolene počúvať: ",
+ "preferences_local_label": "Proxy videá: ",
+ "preferences_speed_label": "Predvolená rýchlosť: ",
+ "preferences_quality_label": "Preferovaná kvalita videa: ",
+ "preferences_volume_label": "Hlasitosť prehrávača: ",
+ "preferences_comments_label": "Predvolené komentáre: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Predvolené titulky: ",
+ "preferences_captions_label": "Predvolené titulky: ",
"Fallback captions: ": "Náhradné titulky: ",
- "Show related videos: ": "Zobraziť súvisiace videá: ",
- "Show annotations by default: ": "Predvolene zobraziť anotácie: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Vizuálne nastavenia",
- "Player style: ": "Štýl prehrávača: ",
+ "preferences_related_videos_label": "Zobraziť súvisiace videá: ",
+ "preferences_annotations_label": "Predvolene zobraziť anotácie: ",
+ "preferences_category_visual": "Vizuálne nastavenia",
+ "preferences_player_style_label": "Štýl prehrávača: ",
"Dark mode: ": "Tmavý režim: ",
- "Theme: ": "Téma: ",
+ "preferences_dark_mode_label": "Téma: ",
"dark": "tmavá",
"light": "svetlá",
- "Thin mode: ": "Tenký režim: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Nastavenia predplatného",
- "Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ",
+ "preferences_thin_mode_label": "Tenký režim: ",
+ "preferences_category_subscription": "Nastavenia predplatného",
+ "preferences_annotations_subscribed_label": "Predvolene zobraziť anotácie odoberaných kanálov: ",
"Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ",
- "Number of videos shown in feed: ": "Počet videí zobrazených v informačnom kanáli: ",
- "Sort videos by: ": "Zoradiť videá podľa: ",
+ "preferences_max_results_label": "Počet videí zobrazených v informačnom kanáli: ",
+ "preferences_sort_label": "Zoradiť videá podľa: ",
"published": "zverejnené (od najnovších)",
"published - reverse": "zverejnené (od najstarších)",
"alphabetically": "abecedne (A-Z)",
@@ -101,327 +81,8 @@
"channel name - reverse": "názov kanála (Z-A)",
"Only show latest video from channel: ": "Zobraziť iba najnovšie video z kanála: ",
"Only show latest unwatched video from channel: ": "Zobraziť iba najnovšie neprehrané video z kanála: ",
- "Only show unwatched: ": "Zobraziť iba neprehrané: ",
- "Only show notifications (if there are any): ": "Zobraziť iba upozornenia (ak existujú): ",
+ "preferences_unseen_only_label": "Zobraziť iba neprehrané: ",
+ "preferences_notifications_only_label": "Zobraziť iba upozornenia (ak existujú): ",
"Enable web notifications": "Povoliť webové upozornenia",
- "`x` uploaded a video": "`x` nahral(a) video",
- "`x` is live": "",
- "Data preferences": "",
- "Clear watch history": "",
- "Import/export data": "",
- "Change password": "",
- "Manage subscriptions": "",
- "Manage tokens": "",
- "Watch history": "",
- "Delete account": "",
- "Administrator preferences": "",
- "Default homepage: ": "",
- "Feed menu: ": "",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
- "Save preferences": "",
- "Subscription manager": "",
- "Token manager": "",
- "Token": "",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "search": "",
- "Log out": "",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "",
- "View JavaScript license information.": "",
- "View privacy policy.": "",
- "Trending": "",
- "Public": "",
- "Unlisted": "",
- "Private": "",
- "View all playlists": "",
- "Updated `x` ago": "",
- "Delete playlist `x`?": "",
- "Delete playlist": "",
- "Create playlist": "",
- "Title": "",
- "Playlist privacy": "",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
- "Genre: ": "",
- "License: ": "",
- "Family friendly? ": "",
- "Wilson score: ": "",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "`x` uploaded a video": "`x` nahral(a) video"
}
diff --git a/locales/sq.json b/locales/sq.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/locales/sq.json
@@ -0,0 +1 @@
+{}
diff --git a/locales/sr.json b/locales/sr.json
index 314f0367..40e53231 100644
--- a/locales/sr.json
+++ b/locales/sr.json
@@ -1,427 +1,373 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` пратилаца",
- "": "`x` пратилаца"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` видео записа",
- "": "`x` видео записа"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` списака извођења",
- "": "`x` списака извођења"
- },
- "LIVE": "УЖИВО",
- "Shared `x` ago": "Подељено пре `x`",
- "Unsubscribe": "Прекини праћење",
- "Subscribe": "Прати",
- "View channel on YouTube": "Погледај канал на YouTube-у",
- "View playlist on YouTube": "Погледај списак извођења на YouTube-у",
- "newest": "најновије",
- "oldest": "најстарије",
- "popular": "гласовито",
- "last": "последње",
- "Next page": "Следећа страница",
- "Previous page": "Претходна страница",
- "Clear watch history?": "Избрисати повест прегледања?",
- "New password": "Нова запорка",
- "New passwords must match": "Нове запорке морају бити истоветне",
- "Cannot change password for Google accounts": "Није могуће променити запорку за Google налоге",
- "Authorize token?": "Овласти токен?",
- "Authorize token for `x`?": "Овласти токен за `x`?",
- "Yes": "Да",
- "No": "Не",
- "Import and Export Data": "Увоз и извоз података",
- "Import": "Увези",
- "Import Invidious data": "Увези податке са Invidious-а",
- "Import YouTube subscriptions": "Увези праћења са YouTube-а",
- "Import FreeTube subscriptions (.db)": "Увези праћења са FreeTube-а (.db)",
- "Import NewPipe subscriptions (.json)": "Увези праћења са NewPipe-а (.json)",
- "Import NewPipe data (.zip)": "Увези податке са NewPipe-а (.zip)",
- "Export": "Извези",
- "Export subscriptions as OPML": "Извези праћења као OPML датотеку",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као OPML датотеку (за NewPipe и FreeTube)",
- "Export data as JSON": "Извези податке као JSON датотеку",
- "Delete account?": "Избрисати рачун?",
- "History": "Повест",
- "An alternative front-end to YouTube": "Заменски кориснички слој за YouTube",
- "JavaScript license information": "Извештај о JavaScript одобрењу",
- "source": "извор",
- "Log in": "Пријави се",
- "Log in/register": "Пријави се/Отвори налог",
- "Log in with Google": "Пријави се помоћу Google-а",
- "User ID": "Кориснички ИД",
- "Password": "Запорка",
- "Time (h:mm:ss):": "Време (ч:мм:сс):",
- "Text CAPTCHA": "Знаковни CAPTCHA",
- "Image CAPTCHA": "Сликовни CAPTCHA",
- "Sign In": "Пријава",
- "Register": "Отвори налог",
- "E-mail": "Е-пошта",
- "Google verification code": "Google-ов оверни кôд",
- "Preferences": "Подешавања",
- "Player preferences": "Подешавања репродуктора",
- "Always loop: ": "Увек понављај: ",
- "Autoplay: ": "Самопуштање: ",
- "Play next by default: ": "Увек подразумевано пуштај следеће: ",
- "Autoplay next video: ": "Самопуштање следећег видео записа: ",
- "Listen by default: ": "Увек подразумевано укључен само звук: ",
- "Proxy videos: ": "Приказ видео записа преко посредника: ",
- "Default speed: ": "",
- "Preferred video quality: ": "",
- "Player volume: ": "",
- "Default comments: ": "",
- "youtube": "",
- "reddit": "",
- "Default captions: ": "",
- "Fallback captions: ": "",
- "Show related videos: ": "",
- "Show annotations by default: ": "",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "",
- "Player style: ": "",
- "Dark mode: ": "",
- "Theme: ": "",
- "dark": "",
- "light": "",
- "Thin mode: ": "",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "",
- "Show annotations by default for subscribed channels: ": "",
- "Redirect homepage to feed: ": "",
- "Number of videos shown in feed: ": "",
- "Sort videos by: ": "",
- "published": "",
- "published - reverse": "",
- "alphabetically": "",
- "alphabetically - reverse": "",
- "channel name": "",
- "channel name - reverse": "",
- "Only show latest video from channel: ": "",
- "Only show latest unwatched video from channel: ": "",
- "Only show unwatched: ": "",
- "Only show notifications (if there are any): ": "",
- "Enable web notifications": "",
- "`x` uploaded a video": "",
- "`x` is live": "",
- "Data preferences": "",
- "Clear watch history": "",
- "Import/export data": "",
- "Change password": "",
- "Manage subscriptions": "",
- "Manage tokens": "",
- "Watch history": "",
- "Delete account": "",
- "Administrator preferences": "",
- "Default homepage: ": "",
- "Feed menu: ": "",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "",
- "Login enabled: ": "",
- "Registration enabled: ": "",
- "Report statistics: ": "",
- "Save preferences": "",
- "Subscription manager": "",
- "Token manager": "",
- "Token": "",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "search": "",
- "Log out": "",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "",
- "View JavaScript license information.": "",
- "View privacy policy.": "",
- "Trending": "",
- "Public": "",
- "Unlisted": "",
- "Private": "",
- "View all playlists": "",
- "Updated `x` ago": "",
- "Delete playlist `x`?": "",
- "Delete playlist": "",
- "Create playlist": "",
- "Title": "",
- "Playlist privacy": "",
- "Editing playlist `x`": "",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "",
- "Show annotations": "",
- "Genre: ": "",
- "License: ": "",
- "Family friendly? ": "",
- "Wilson score: ": "",
- "Engagement: ": "",
- "Whitelisted regions: ": "",
- "Blacklisted regions: ": "",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
+ "LIVE": "UŽIVO",
+ "Shared `x` ago": "Podeljeno pre `x`",
+ "Unsubscribe": "Prekini praćenje",
+ "Subscribe": "Prati",
+ "View channel on YouTube": "Pogledaj kanal na YouTube-u",
+ "View playlist on YouTube": "Pogledaj spisak izvođenja na YouTube-u",
+ "newest": "najnovije",
+ "oldest": "najstarije",
+ "popular": "popularno",
+ "last": "poslednje",
+ "Next page": "Sledeća stranica",
+ "Previous page": "Prethodna stranica",
+ "Clear watch history?": "Izbrisati povest pregledanja?",
+ "New password": "Nova lozinka",
+ "New passwords must match": "Nove lozinke moraju biti istovetne",
+ "Cannot change password for Google accounts": "Nije moguće promeniti lozinku za Google naloge",
+ "Authorize token?": "Ovlasti žeton?",
+ "Authorize token for `x`?": "Ovlasti žeton za `x`?",
+ "Yes": "Da",
+ "No": "Ne",
+ "Import and Export Data": "Uvoz i Izvoz Podataka",
+ "Import": "Uvezi",
+ "Import Invidious data": "Uvezi podatke sa Invidious-a",
+ "Import YouTube subscriptions": "Uvezi praćenja sa YouTube-a",
+ "Import FreeTube subscriptions (.db)": "Uvezi praćenja sa FreeTube-a (.db)",
+ "Import NewPipe subscriptions (.json)": "Uvezi praćenja sa NewPipe-a (.json)",
+ "Import NewPipe data (.zip)": "Uvezi podatke sa NewPipe-a (.zip)",
+ "Export": "Izvezi",
+ "Export subscriptions as OPML": "Izvezi praćenja kao OPML datoteku",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi praćenja kao OPML datoteku (za NewPipe i FreeTube)",
+ "Export data as JSON": "Izvezi podatke kao JSON datoteku",
+ "Delete account?": "Izbrišite nalog?",
+ "History": "Istorija",
+ "An alternative front-end to YouTube": "Zamenski korisnički sloj za YouTube",
+ "JavaScript license information": "Izveštaj o JavaScript odobrenju",
+ "source": "izvor",
+ "Log in": "Prijavi se",
+ "Log in/register": "Prijavi se/Otvori nalog",
+ "Log in with Google": "Prijavi se pomoću Google-a",
+ "User ID": "Korisnički ID",
+ "Password": "Lozinka",
+ "Time (h:mm:ss):": "Vreme (č:mm:ss):",
+ "Text CAPTCHA": "Znakovni CAPTCHA",
+ "Image CAPTCHA": "Slikovni CAPTCHA",
+ "Sign In": "Prijava",
+ "Register": "Otvori nalog",
+ "E-mail": "E-pošta",
+ "Google verification code": "Google-ova overna koda",
+ "Preferences": "Podešavanja",
+ "preferences_category_player": "Podešavanja reproduktora",
+ "preferences_video_loop_label": "Uvek ponavljaj: ",
+ "preferences_autoplay_label": "Samopuštanje: ",
+ "preferences_continue_label": "Uvek podrazumevano puštaj sledeće: ",
+ "preferences_continue_autoplay_label": "Samopuštanje sledećeg video zapisa: ",
+ "preferences_listen_label": "Uvek podrazumevano uključen samo zvuk: ",
+ "preferences_local_label": "Prikaz video zapisa preko posrednika: ",
+ "Playlist privacy": "Podešavanja privatnosti plej liste",
+ "Editing playlist `x`": "Izmena plej liste `x`",
+ "Please sign in using 'Log in with Google'": "Molimo Vas da se prijavite pomoću 'Log in with Google'",
+ "Playlist does not exist.": "Nepostojeća plej lista.",
+ "Erroneous challenge": "Pogrešan izazov",
+ "Maltese": "Malteški",
+ "Download": "Preuzmi",
+ "Download as: ": "Preuzmi kao: ",
+ "Quota exceeded, try again in a few hours": "Kvota je premašena, molimo vas da pokušate ponovo za par sati",
+ "Bangla": "Bangla/Bengalski",
+ "preferences_quality_dash_label": "Preferirani kvalitet DASH video formata: ",
+ "Token manager": "Upravljanje žetonima",
+ "Token": "Žeton",
+ "Import/export": "Uvezi/Izvezi",
+ "revoke": "opozovi",
+ "search": "pretraga",
+ "Log out": "Odjava",
+ "Source available here.": "Izvorna koda je ovde dostupna.",
+ "Trending": "U trendu",
+ "Updated `x` ago": "Ažurirano pre `x`",
+ "Delete playlist `x`?": "Obriši plej listu `x`?",
+ "Create playlist": "Napravi plej listu",
+ "Show less": "Prikaži manje",
+ "Switch Invidious Instance": "Promeni Invidious instancu",
+ "Hide annotations": "Sakrij napomene",
+ "User ID is a required field": "Korisnički ID je obavezno polje",
+ "Wrong username or password": "Pogrešno korisničko ime ili lozinka",
+ "Please log in": "Molimo vas da se prijavite",
+ "channel:`x`": "kanal:`x`",
+ "Could not fetch comments": "Uzimanje komentara nije uspelo",
+ "Could not create mix.": "Pravljenje miksa nije uspelo.",
+ "Empty playlist": "Prazna plej lista",
+ "Not a playlist.": "Nije plej lista.",
+ "Could not pull trending pages.": "Učitavanje 'U toku' stranica nije uspelo.",
+ "Token is expired, please try again": "Žeton je istekao, molimo vas da pokušate ponovo",
+ "English (auto-generated)": "Engleski (automatski generisano)",
+ "Afrikaans": "Afrikans",
+ "Albanian": "Albanski",
+ "Armenian": "Jermenski",
+ "Azerbaijani": "Azerbejdžanski",
+ "Basque": "Baskijski",
+ "Bosnian": "Bosanski",
+ "Bulgarian": "Bugarski",
+ "Burmese": "Burmanski",
+ "Catalan": "Katalonski",
+ "Cebuano": "Sebuano",
+ "Chinese (Traditional)": "Kineski (Tradicionalni)",
+ "Corsican": "Korzikanski",
+ "Danish": "Danski",
+ "Kannada": "Kanada (Jezik)",
+ "Kazakh": "Kazaški",
+ "Russian": "Ruski",
+ "Scottish Gaelic": "Škotski Gelski",
+ "Sinhala": "Sinhaleški",
+ "Slovak": "Slovački",
+ "Spanish": "Španski",
+ "Spanish (Latin America)": "Španski (Južna Amerika)",
+ "Sundanese": "Sundski",
+ "Swedish": "Švedski",
+ "Tajik": "Tadžički",
+ "Telugu": "Telugu",
+ "Turkish": "Turski",
+ "Ukrainian": "Ukrajinski",
+ "Urdu": "Urdu",
+ "Uzbek": "Uzbečki",
+ "Vietnamese": "Vijetnamski",
+ "Rating: ": "Ocena/e: ",
+ "View as playlist": "Pogledaj kao plej listu",
+ "Default": "Podrazumevan/o",
+ "Gaming": "Igrice",
+ "Movies": "Filmovi",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "(edited)": "(izmenjeno)",
+ "YouTube comment permalink": "YouTube komentar trajna veza",
+ "Audio mode": "Audio mod",
+ "Playlists": "Plej liste",
+ "relevance": "Relevantnost",
+ "rating": "Ocene",
+ "date": "Datum otpremanja",
+ "views": "Broj pregleda",
+ "`x` marked it with a ❤": "`x` je označio/la ovo sa ❤",
+ "duration": "Trajanje",
+ "features": "Karakteristike",
+ "hour": "Poslednji sat",
+ "week": "Ove sedmice",
+ "month": "Ovaj mesec",
+ "year": "Ove godine",
+ "video": "Video",
+ "playlist": "Plej lista",
+ "movie": "Film",
+ "long": "Dugo (> 20 minuta)",
+ "hd": "HD",
+ "creative_commons": "Creative Commons (Licenca)",
+ "3d": "3D",
+ "hdr": "Video Visoke Rezolucije",
+ "filter": "Filter",
+ "next_steps_error_message": "Nakon čega bi trebali probati: ",
+ "next_steps_error_message_go_to_youtube": "Idi na YouTube",
+ "footer_documentation": "Dokumentacija",
+ "preferences_region_label": "Država porekla sadržaja: ",
+ "preferences_player_style_label": "Stil plejera: ",
+ "preferences_dark_mode_label": "Izgled/Tema: ",
+ "light": "svetlo",
+ "preferences_thin_mode_label": "Kompaktni režim: ",
+ "preferences_category_misc": "Ostala podešavanja",
+ "preferences_automatic_instance_redirect_label": "Automatsko prebacivanje na drugu instancu u slučaju otkazivanja (preči će nazad na redirect.invidious.io): ",
+ "alphabetically - reverse": "po alfabetu - obrnuto",
+ "Enable web notifications": "Omogući obaveštenja u veb pretraživaču",
+ "`x` is live": "`x` prenosi uživo",
+ "Manage tokens": "Upravljaj žetonima",
+ "Watch history": "Istorija gledanja",
+ "preferences_feed_menu_label": "Dovodna stranica: ",
+ "preferences_show_nick_label": "Prikaži nadimke na vrhu: ",
+ "CAPTCHA enabled: ": "CAPTCHA omogućena: ",
+ "Registration enabled: ": "Registracija omogućena: ",
+ "Subscription manager": "Upravljanje praćenjima",
+ "Wilson score: ": "Wilsonova ocena: ",
+ "Engagement: ": "Angažovanje: ",
+ "Whitelisted regions: ": "Dozvoljene oblasti: ",
+ "Shared `x`": "Podeljeno `x`",
+ "Premieres in `x`": "Premera u `x`",
+ "Premieres `x`": "Premere u `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.": "Hej! Izgleda da ste onemogućili JavaScript. Kliknite ovde da vidite komentare, čuvajte na umu da ovo može da potraje duže dok se ne učitaju.",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "",
- "Hide replies": "",
- "Show replies": "",
- "Incorrect password": "",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentar",
+ "": "Prikaži `x` komentara"
},
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "View Reddit comments": "Prikaži Reddit komentare",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Neuspešna prijava, proverite da li ste upalili dvofaktornu autentikaciju (Autentikator ili SMS).",
+ "CAPTCHA is a required field": "CAPTCHA je obavezno polje",
+ "Croatian": "Hrvatski",
+ "Estonian": "Estonski",
+ "Filipino": "Filipino",
+ "French": "Francuski",
+ "Galician": "Galicijski",
+ "German": "Nemački",
+ "Greek": "Grčki",
+ "Hausa": "Hausa",
+ "Italian": "Talijanski",
+ "Khmer": "Kmerski",
+ "Kurdish": "Kurdski",
+ "Kyrgyz": "Kirgiski",
+ "Latvian": "Letonski",
+ "Lithuanian": "Litvanski",
+ "Macedonian": "Makedonski",
+ "Malagasy": "Malgaški",
+ "Malay": "Malajski",
+ "Marathi": "Marathi",
+ "Mongolian": "Mongolski",
+ "Norwegian Bokmål": "Norveški Bokmal",
+ "Nyanja": "Čeva",
+ "Pashto": "Paštunski",
+ "Persian": "Persijski",
+ "Punjabi": "Pundžabi",
+ "Romanian": "Rumunski",
+ "Welsh": "Velški",
+ "Western Frisian": "Zapadnofrizijski",
+ "Fallback comments: ": "Komentari u slučaju otkazivanja: ",
+ "Popular": "Popularno",
+ "Search": "Pretraga",
+ "About": "O programu",
+ "footer_source_code": "Izvorna Koda",
+ "footer_original_source_code": "Originalna Izvorna Koda",
+ "preferences_related_videos_label": "Prikaži slične video klipove: ",
+ "preferences_annotations_label": "Prikaži napomene podrazumevano: ",
+ "preferences_extend_desc_label": "Automatski prikaži ceo opis videa: ",
+ "preferences_vr_mode_label": "Interaktivni video klipovi u 360 stepeni: ",
+ "preferences_category_visual": "Vizuelne preference",
+ "preferences_captions_label": "Podrazumevani titl: ",
+ "Music": "Muzika",
+ "content_type": "Tip",
+ "Broken? Try another Invidious Instance": "Ne funkcioniše ispravno? Probajte drugu Invidious instancu",
+ "Tamil": "Tamilski",
+ "Save preferences": "Sačuvaj podešavanja",
+ "Only show latest unwatched video from channel: ": "Prikaži samo poslednje video klipove koji nisu pogledani sa kanala: ",
+ "Xhosa": "Kosa (Jezik)",
+ "channel": "Kanal",
+ "Hungarian": "Mađarski",
+ "Maori": "Maori (Jezik)",
+ "Manage subscriptions": "Upravljaj zapisima",
+ "Hindi": "Hindi",
+ "`x` ago": "pre `x`",
+ "Import/export data": "Uvezi/Izvezi podatke",
+ "`x` uploaded a video": "`x` je otpremio/la video klip",
+ "Delete account": "Obriši nalog",
+ "preferences_default_home_label": "Podrazumevana početna stranica: ",
+ "Serbian": "Srpski",
+ "License: ": "Licenca: ",
+ "live": "Uživo",
+ "Report statistics: ": "Izveštavaj o statistici: ",
+ "Only show latest video from channel: ": "Prikazuj poslednje video klipove samo sa kanala: ",
+ "channel name - reverse": "ime kanala - obrnuto",
+ "Could not get channel info.": "Uzimanje podataka o kanalu nije uspelo.",
+ "View privacy policy.": "Pogledaj izveštaj o privatnosti.",
+ "Change password": "Promeni lozinku",
+ "Malayalam": "Malajalam",
+ "View more comments on Reddit": "Prikaži više komentara na Reddit-u",
+ "Portuguese": "Portugalski",
+ "View YouTube comments": "Prikaži YouTube komentare",
+ "published - reverse": "objavljeno - obrnuto",
+ "Dutch": "Holandski",
+ "preferences_volume_label": "Jačina zvuka: ",
+ "preferences_locale_label": "Jezik: ",
+ "adminprefs_modified_source_code_url_label": "URL veza do skladišta sa Izmenjenom Izvornom Kodom",
+ "Community": "Zajednica",
+ "Video mode": "Video mod",
+ "Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ",
+ "Private": "Privatno",
+ "alphabetically": "po alfabetu",
+ "No such user": "Nepostojeći korisnik",
+ "Subscriptions": "Praćenja",
+ "today": "Danas",
+ "Finnish": "Finski",
+ "Lao": "Laoski",
+ "Login enabled: ": "Prijava omogućena: ",
+ "Shona": "Šona",
+ "location": "Lokacija",
+ "Load more": "Učitaj više",
+ "Released under the AGPLv3 on Github.": "Izbačeno pod licencom AGPLv3 na Github-u.",
+ "Slovenian": "Slovenački",
+ "View JavaScript license information.": "Pogledaj informacije licence vezane za JavaScript.",
+ "Chinese (Simplified)": "Kineski (Pojednostavljeni)",
+ "preferences_comments_label": "Podrazumevani komentari: ",
+ "Incorrect password": "Netačna lozinka",
+ "Show replies": "Prikaži odgovore",
+ "Invidious Private Feed for `x`": "Invidious Privatni Dovod za `x`",
+ "Watch on YouTube": "Gledaj na YouTube-u",
+ "Wrong answer": "Pogrešan odgovor",
+ "preferences_quality_label": "Preferirani video kvalitet: ",
+ "Hide replies": "Sakrij odgovore",
+ "Invalid TFA code": "Nevažeća TFA koda",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Neuspešna prijava! Ovo se možda dešava jer dvofaktorna autentikacija nije omogućena na vašem nalogu.",
+ "Erroneous CAPTCHA": "Pogrešna CAPTCHA",
+ "Erroneous token": "Pogrešan žeton",
+ "Czech": "Češki",
+ "Latin": "Latinski",
+ "Videos": "Video klipovi",
+ "4k": "4К",
+ "footer_donate_page": "Doniraj",
+ "English": "Engleski",
+ "Arabic": "Arapski",
+ "Unlisted": "Nenavedeno",
+ "Hidden field \"challenge\" is a required field": "Sakriveno \"challenge\" polje je obavezno",
+ "Hidden field \"token\" is a required field": "Sakriveno \"token\" polje je obavezno",
+ "Georgian": "Gruzijski",
+ "Hawaiian": "Havajski",
+ "Hebrew": "Hebrejski",
+ "Icelandic": "Islandski",
+ "Igbo": "Igbo",
+ "Japanese": "Japanski",
+ "Javanese": "Javanski",
+ "Sindhi": "Sindi",
+ "Swahili": "Svahili",
+ "Yiddish": "Jidiš",
+ "Zulu": "Zulu",
+ "subtitles": "Titl/Prevod",
+ "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 karaktera",
+ "This channel does not exist.": "Ovaj kanal ne postoji.",
+ "Belarusian": "Beloruski",
+ "Gujarati": "Gudžarati",
+ "Haitian Creole": "Haićanski Kreolski",
+ "Somali": "Somalijski",
+ "Top": "Vrh",
+ "footer_modfied_source_code": "Izmenjena Izvorna Koda",
+ "preferences_category_subscription": "Podešavanja praćenja",
+ "preferences_annotations_subscribed_label": "Podrazumevano prikazati napomene za kanale koje pratite? ",
+ "preferences_max_results_label": "Broj video klipova prikazanih u dovodnoj listi: ",
+ "preferences_sort_label": "Sortiraj video klipove po: ",
+ "preferences_unseen_only_label": "Prikaži samo video klipove koji nisu pogledani: ",
+ "preferences_notifications_only_label": "Prikaži samo obaveštenja (ako ih uopšte ima): ",
+ "preferences_category_data": "Podešavanja podataka",
+ "Clear watch history": "Obriši istoriju gledanja",
+ "preferences_category_admin": "Administratorska podešavanja",
+ "published": "objavljeno",
+ "sort": "Poredaj prema",
+ "show": "Emisija",
+ "short": "Kratko (< 4 minute)",
+ "Current version: ": "Trenutna verzija: ",
+ "Top enabled: ": "Vrh omogućen: ",
+ "Public": "Javno",
+ "Delete playlist": "Obriši plej listu",
+ "Title": "Naslov",
+ "Show annotations": "Prikaži napomene",
+ "Password cannot be empty": "Lozinka ne može biti prazna",
+ "Deleted or invalid channel": "Obrisan ili nepostojeći kanal",
+ "Esperanto": "Esperanto",
+ "Hmong": "Hmong",
+ "Luxembourgish": "Luksemburški",
+ "Nepali": "Nepalski",
+ "Samoan": "Samoanski",
+ "News": "Vesti",
+ "permalink": "trajna veza",
+ "Password is a required field": "Lozinka je obavezno polje",
+ "Amharic": "Amharski",
+ "Indonesian": "Indonežanski",
+ "Irish": "Irski",
+ "Korean": "Korejski",
+ "Southern Sotho": "Južni Soto",
+ "Thai": "Tajski",
+ "preferences_speed_label": "Podrazumevana brzina: ",
+ "Dark mode: ": "Tamni režim: ",
+ "dark": "tamno",
+ "Redirect homepage to feed: ": "Prebaci sa početne stranice na dovodnu listu: ",
+ "channel name": "ime kanala",
+ "View all playlists": "Pregledaj sve plej liste",
+ "Show more": "Prikaži više",
+ "Genre: ": "Žanr: ",
+ "Family friendly? ": "Pogodno za porodicu? ",
+ "next_steps_error_message_refresh": "Osveži stranicu",
+ "youtube": "YouTube",
+ "reddit": "Reddit",
+ "unsubscribe": "prekini sa praćenjem",
+ "Blacklisted regions: ": "Zabranjene oblasti: ",
+ "Polish": "Poljski",
+ "Yoruba": "Joruba"
}
diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json
index 056b79cb..40c50674 100644
--- a/locales/sr_Cyrl.json
+++ b/locales/sr_Cyrl.json
@@ -1,427 +1,373 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` пратилац"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` видеа"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` плејлиста/е"
- },
"LIVE": "УЖИВО",
- "Shared `x` ago": "Објављено пре `x`",
- "Unsubscribe": "Прекините праћење",
- "Subscribe": "Пратите",
- "View channel on YouTube": "Погледајте канал на YouTube-у",
- "View playlist on YouTube": "Погледајте плејлисту на YouTube-у",
+ "Shared `x` ago": "Подељено пре `x`",
+ "Unsubscribe": "Прекини праћење",
+ "Subscribe": "Прати",
+ "View channel on YouTube": "Погледај канал на YouTube-у",
+ "View playlist on YouTube": "Погледај списак извођења на YоуТубе-у",
"newest": "најновије",
"oldest": "најстарије",
"popular": "популарно",
"last": "последње",
"Next page": "Следећа страна",
"Previous page": "Претходна страна",
- "Clear watch history?": "Обришите историју прегледања?",
+ "Clear watch history?": "Избрисати повест прегледања?",
"New password": "Нова лозинка",
- "New passwords must match": "Нове лозинке се морају поклапати",
+ "New passwords must match": "Нове лозинке морају бити истоветне",
"Cannot change password for Google accounts": "Није могуће променити лозинку за Google налоге",
- "Authorize token?": "Овластите токен?",
- "Authorize token for `x`?": "Овластите токен за `x`?",
+ "Authorize token?": "Овласти жетон?",
+ "Authorize token for `x`?": "Овласти жетон за `x`?",
"Yes": "Да",
"No": "Не",
"Import and Export Data": "Увоз и извоз података",
- "Import": "Увезите",
- "Import Invidious data": "Увезите Invidious податке",
- "Import YouTube subscriptions": "Увезите праћења са YouTube-а",
- "Import FreeTube subscriptions (.db)": "Увезите праћења са FreeTube-а (.db)",
- "Import NewPipe subscriptions (.json)": "Увезите праћења са NewPipe-а (.json)",
- "Import NewPipe data (.zip)": "Увезите NewPipe податке (.zip)",
- "Export": "Извезите",
- "Export subscriptions as OPML": "Извезите праћења у OPML формату",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извезите праћења у OPML формату (за NewPipe и FreeTube )",
- "Export data as JSON": "Изветизе податке у JSON формату",
+ "Import": "Увези",
+ "Import Invidious data": "Увези податке са Individious-а",
+ "Import YouTube subscriptions": "Увези праћења са YouTube-а",
+ "Import FreeTube subscriptions (.db)": "Увези праћења са FreeTube-а (.db)",
+ "Import NewPipe subscriptions (.json)": "Увези праћења са NewPipe-а (.json)",
+ "Import NewPipe data (.zip)": "Увези податке са NewPipe-a (.zip)",
+ "Export": "Извези",
+ "Export subscriptions as OPML": "Извези праћења као ОПМЛ датотеку",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као ОПМЛ датотеку (за NewPipe и FreeTube)",
+ "Export data as JSON": "Извези податке као JSON датотеку",
"Delete account?": "Избришите налог?",
"History": "Историја",
- "An alternative front-end to YouTube": "Алтернативни фронтенд за YouTube",
- "JavaScript license information": "Извештај о JavaScript лиценци",
+ "An alternative front-end to YouTube": "Заменски кориснички слој за YouTube",
+ "JavaScript license information": "Извештај о JavaScript одобрењу",
"source": "извор",
- "Log in": "Пријавите се",
- "Log in/register": "Пријавите се/направите налог",
- "Log in with Google": "Пријавите се помоћу Google-а",
- "User ID": "ИД корисника",
+ "Log in": "Пријави се",
+ "Log in/register": "Пријави се/Отворите налог",
+ "Log in with Google": "Пријави се помоћу Google-а",
+ "User ID": "Кориснички ИД",
"Password": "Лозинка",
- "Time (h:mm:ss):": "Колико је сати? (ч:мм:сс):",
- "Text CAPTCHA": "Текстуална CAPTCHA",
- "Image CAPTCHA": "Сликовна CAPTCHA",
- "Sign In": "Пријавите се",
- "Register": "Направите налог",
+ "Time (h:mm:ss):": "Време (ч:мм:сс):",
+ "Text CAPTCHA": "Знаковни ЦАПТЧА",
+ "Image CAPTCHA": "Сликовни CAPTCHA",
+ "Sign In": "Пријава",
+ "Register": "Отвори налог",
"E-mail": "Е-пошта",
- "Google verification code": "Google верификациони кôд",
+ "Google verification code": "Google-ова оверна кода",
"Preferences": "Подешавања",
- "Player preferences": "Подешавања видео плејера",
- "Always loop: ": "Увек понављај: ",
- "Autoplay: ": "Аутоматско пуштање: ",
- "Play next by default: ": "Увек пуштај следеће: ",
- "Autoplay next video: ": "Аутоматско пуштање следећег видеа: ",
- "Listen by default: ": "Режим слушања као подразумевано: ",
- "Proxy videos: ": "Пуштање видеа кроз прокси сервер: ",
- "Default speed: ": "Подразумевана брзина репродукције: ",
- "Preferred video quality: ": "Претпостављени квалитет видеа: ",
- "Player volume: ": "Јачина звука: ",
- "Default comments: ": "Подразумевани коментари: ",
- "youtube": "са YouTube-а",
- "reddit": "са редита",
- "Default captions: ": "Подразумевани титлови: ",
- "Fallback captions: ": "Алтернативни титлови: ",
- "Show related videos: ": "Прикажи сличне видее: ",
- "Show annotations by default: ": "Увек приказуј анотације: ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Подешавања изгледа",
- "Player style: ": "Стил плејера: ",
+ "preferences_category_player": "Подешавања репродуктора",
+ "preferences_video_loop_label": "Увек понављај: ",
+ "preferences_autoplay_label": "Самопуштање: ",
+ "preferences_continue_label": "Увек подразумевано пуштај следеће: ",
+ "preferences_continue_autoplay_label": "Самопуштање следећег видео записа: ",
+ "preferences_listen_label": "Увек подразумевано укључен само звук: ",
+ "preferences_local_label": "Приказ видео записа преко посредника: ",
+ "preferences_speed_label": "Подразумевана брзина: ",
+ "preferences_quality_label": "Преферирани видео квалитет: ",
+ "preferences_volume_label": "Јачина звука: ",
+ "preferences_comments_label": "Подразумевани коментари: ",
+ "youtube": "YouTube",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Подразумевани титл: ",
+ "Fallback captions: ": "Титл у случају да главни није доступан: ",
+ "preferences_related_videos_label": "Прикажи сличне видео клипове: ",
+ "preferences_annotations_label": "Прикажи напомене подразумевано: ",
+ "preferences_category_visual": "Визуелне преференце",
+ "preferences_player_style_label": "Стил плејера: ",
"Dark mode: ": "Тамни режим: ",
- "Theme: ": "Тема: ",
- "dark": "тамна",
- "light": "светла",
- "Thin mode: ": "Узани режим: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Подешавања о праћењима",
- "Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ",
- "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ",
- "Number of videos shown in feed: ": "Количина приказаних видеа на доводу: ",
- "Sort videos by: ": "Сортирај према: ",
- "published": "датуму објављивања",
- "published - reverse": "датуму објављивања - обрнуто",
- "alphabetically": "алфабету",
- "alphabetically - reverse": "алфабету - обрнуто",
- "channel name": "називу канала",
- "channel name - reverse": "називу канала - обрнуто",
- "Only show latest video from channel: ": "Прикажи само најновији видео са канала: ",
- "Only show latest unwatched video from channel: ": "Прикажи само најновији негледани видео са канала: ",
- "Only show unwatched: ": "Прикажи само негледано: ",
- "Only show notifications (if there are any): ": "Прикажи само обавештења (ако их има): ",
- "Enable web notifications": "Укључи обавештења преко претраживача",
- "`x` uploaded a video": "`x`је објавио/ла видео",
- "`x` is live": "`x` емитује уживо",
- "Data preferences": "Подешавања о подацима",
- "Clear watch history": "Обришите историју прегледања",
- "Import/export data": "Увезите или извезите податке",
- "Change password": "Промените лозинку",
- "Manage subscriptions": "Управљајте праћењима",
- "Manage tokens": "Управљајте токенима",
- "Watch history": "Историја прегледања",
- "Delete account": "Избришите налог",
- "Administrator preferences": "Подешавања администратора",
- "Default homepage: ": "Подразумевана главна страница: ",
- "Feed menu: ": "Мени довода: ",
- "Show nickname on top: ": "",
- "Top enabled: ": "",
- "CAPTCHA enabled: ": "CAPTCHA укључена?: ",
- "Login enabled: ": "Пријава укључена?: ",
- "Registration enabled: ": "Регистрација укључена?: ",
- "Report statistics: ": "",
+ "preferences_dark_mode_label": "Изглед/Тема: ",
+ "dark": "тамно",
+ "light": "светло",
+ "preferences_thin_mode_label": "Компактни режим: ",
+ "preferences_category_subscription": "Подешавања праћења",
+ "preferences_annotations_subscribed_label": "Подразумевано приказати напомене за канале које пратите? ",
+ "Redirect homepage to feed: ": "Пребаци са почетне странице на доводну листу: ",
+ "preferences_max_results_label": "Број видео клипова приказаних у доводној листи: ",
+ "preferences_sort_label": "Сортирај видео клипове по: ",
+ "published": "објављено",
+ "published - reverse": "објављено - обрнуто",
+ "alphabetically": "по алфабету",
+ "alphabetically - reverse": "по алфабету - обрнуто",
+ "channel name": "име канала",
+ "channel name - reverse": "име канала - обрнуто",
+ "Only show latest video from channel: ": "Приказуј последње видео клипове само са канала: ",
+ "Only show latest unwatched video from channel: ": "Прикажи само последње видео клипове који нису погледани са канала: ",
+ "preferences_unseen_only_label": "Прикажи само видео клипове који нису погледани: ",
+ "preferences_notifications_only_label": "Прикажи само обавештења (ако их уопште има): ",
+ "Enable web notifications": "Омогући обавештења у веб претраживачу",
+ "`x` uploaded a video": "`x` је отпремио/ла видео клип",
+ "`x` is live": "`x` преноси уживо",
+ "preferences_category_data": "Подешавања података",
+ "Clear watch history": "Обриши историју гледања",
+ "Import/export data": "Увези/Извези податке",
+ "Change password": "Промени лозинку",
+ "Manage subscriptions": "Управљај записима",
+ "Manage tokens": "Управљај жетонима",
+ "Watch history": "Историја гледања",
+ "Delete account": "Обриши налог",
+ "preferences_category_admin": "Администраторска подешавања",
+ "preferences_default_home_label": "Подразумевана почетна страница: ",
+ "preferences_feed_menu_label": "Доводна страница: ",
+ "CAPTCHA enabled: ": "CAPTCHA омогућена: ",
+ "Login enabled: ": "Пријава омогућена: ",
+ "Registration enabled: ": "Регистрација омогућена: ",
"Save preferences": "Сачувај подешавања",
"Subscription manager": "Управљање праћењима",
- "Token manager": "Управљање токенима",
- "Token": "Токен",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x`праћења"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x`токена"
- },
- "Import/export": "Увези/извези",
- "unsubscribe": "укини праћење",
+ "Token manager": "Управљање жетонима",
+ "Token": "Жетон",
+ "Import/export": "Увези/Извези",
+ "unsubscribe": "прекини са праћењем",
"revoke": "опозови",
"Subscriptions": "Праћења",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` непрочитаних обавештења"
- },
"search": "претрага",
- "Log out": "Одјавите се",
- "Released under the AGPLv3 on Github.": "",
- "Source available here.": "Изворни код доступан овде.",
- "View JavaScript license information.": "Прикажи информације о JavaScript лиценци.",
- "View privacy policy.": "Прикажи извештај о приватности.",
+ "Log out": "Одјава",
+ "Source available here.": "Изворна кода је овде доступна.",
+ "View JavaScript license information.": "Погледај информације лиценце везане за JavaScript.",
+ "View privacy policy.": "Погледај извештај о приватности.",
"Trending": "У тренду",
"Public": "Јавно",
- "Unlisted": "По позиву",
+ "Unlisted": "Ненаведено",
"Private": "Приватно",
- "View all playlists": "Прикажи све плејлисте",
+ "View all playlists": "Прегледај све плеј листе",
"Updated `x` ago": "Ажурирано пре `x`",
- "Delete playlist `x`?": "Избриши плејлисту `x`?",
- "Delete playlist": "Избриши плејлисту",
- "Create playlist": "Направи плејлисту",
+ "Delete playlist `x`?": "Обриши плеј листу `x`?",
+ "Delete playlist": "Обриши плеј листу",
+ "Create playlist": "Направи плеј листу",
"Title": "Наслов",
- "Playlist privacy": "Видљивост плејлисте",
- "Editing playlist `x`": "Уређујете плејлисту `x`",
- "Show more": "",
- "Show less": "",
- "Watch on YouTube": "Гледајте на YouTube-у",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
- "Hide annotations": "Сакриј анотације",
- "Show annotations": "Прикажи анотације",
+ "Playlist privacy": "Подешавања приватности плеј листе",
+ "Editing playlist `x`": "Измена плеј листе `x`",
+ "Watch on YouTube": "Гледај на YouTube-у",
+ "Hide annotations": "Сакриј напомене",
+ "Show annotations": "Прикажи напомене",
"Genre: ": "Жанр: ",
"License: ": "Лиценца: ",
- "Family friendly? ": "",
- "Wilson score: ": "",
"Engagement: ": "Ангажовање: ",
"Whitelisted regions: ": "Дозвољене области: ",
"Blacklisted regions: ": "Забрањене области: ",
- "Shared `x`": "",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": "`x` прегледа"
- },
- "Premieres in `x`": "Емитује се уживо за `x`",
- "Premieres `x`": "",
- "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Здраво! Изгледа да је искључен JavaScript. Кликните овде да бисте приказали коментаре. Требаће мало дуже да се учитају.",
- "View YouTube comments": "Прикажи коментаре са YouTube-а",
- "View more comments on Reddit": "Прикажи још коментара на Reddit-у",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "View Reddit comments": "Прикажи коментаре са Reddit-а",
+ "Premieres in `x`": "Премера у `x`",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Хеј! Изгледа да сте онемогућили JavaScript. Кликните овде да видите коментаре, чувајте на уму да ово може да потраје дуже док се не учитају.",
+ "View YouTube comments": "Прикажи YouTube коментаре",
+ "View more comments on Reddit": "Прикажи више коментара на Reddit-у",
+ "View Reddit comments": "Прикажи Reddit коментаре",
"Hide replies": "Сакриј одговоре",
"Show replies": "Прикажи одговоре",
- "Incorrect password": "Неисправна лозинка",
- "Quota exceeded, try again in a few hours": "",
- "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
- "Invalid TFA code": "",
- "Login failed. This may be because two-factor authentication is not turned on for your account.": "",
- "Wrong answer": "",
- "Erroneous CAPTCHA": "",
- "CAPTCHA is a required field": "",
- "User ID is a required field": "",
- "Password is a required field": "",
- "Wrong username or password": "",
- "Please sign in using 'Log in with Google'": "",
- "Password cannot be empty": "",
- "Password cannot be longer than 55 characters": "",
- "Please log in": "",
- "Invidious Private Feed for `x`": "",
- "channel:`x`": "",
- "Deleted or invalid channel": "",
- "This channel does not exist.": "",
- "Could not get channel info.": "",
- "Could not fetch comments": "",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Could not create mix.": "",
- "Empty playlist": "",
- "Not a playlist.": "",
- "Playlist does not exist.": "",
- "Could not pull trending pages.": "",
- "Hidden field \"challenge\" is a required field": "",
- "Hidden field \"token\" is a required field": "",
- "Erroneous challenge": "",
- "Erroneous token": "",
- "No such user": "",
- "Token is expired, please try again": "",
- "English": "",
- "English (auto-generated)": "",
- "Afrikaans": "",
- "Albanian": "",
- "Amharic": "",
- "Arabic": "",
- "Armenian": "",
- "Azerbaijani": "",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian Bokmål": "",
- "Nyanja": "",
- "Pashto": "",
- "Persian": "",
- "Polish": "",
- "Portuguese": "",
- "Punjabi": "",
- "Romanian": "",
- "Russian": "",
- "Samoan": "",
- "Scottish Gaelic": "",
- "Serbian": "",
- "Shona": "",
- "Sindhi": "",
- "Sinhala": "",
- "Slovak": "",
- "Slovenian": "",
- "Somali": "",
- "Southern Sotho": "",
- "Spanish": "",
- "Spanish (Latin America)": "",
- "Sundanese": "",
- "Swahili": "",
- "Swedish": "",
- "Tajik": "",
- "Tamil": "",
- "Telugu": "",
- "Thai": "",
- "Turkish": "",
- "Ukrainian": "",
- "Urdu": "",
- "Uzbek": "",
- "Vietnamese": "",
- "Welsh": "",
- "Western Frisian": "",
- "Xhosa": "",
- "Yiddish": "",
- "Yoruba": "",
- "Zulu": "",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Fallback comments: ": "",
- "Popular": "",
- "Search": "",
- "Top": "",
- "About": "",
- "Rating: ": "",
- "Language: ": "",
- "View as playlist": "",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "YouTube comment permalink": "",
- "permalink": "",
- "`x` marked it with a ❤": "",
- "Audio mode": "",
- "Video mode": "",
- "Videos": "",
- "Playlists": "",
- "Community": "",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
+ "Incorrect password": "Нетачна лозинка",
"Current version: ": "Тренутна верзија: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Wilson score: ": "Wилсонова оцена: ",
+ "Burmese": "Бурмански",
+ "preferences_quality_dash_label": "Преферирани квалитет DASH видео формата: ",
+ "Erroneous token": "Погрешан жетон",
+ "Quota exceeded, try again in a few hours": "Квота је премашена, молимо вас да покушате поново за пар сати",
+ "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Неуспешна пријава, проверите да ли сте упалили двофакторну аутентикацију (Аутентикатор или СМС).",
+ "CAPTCHA is a required field": "CAPTCHA је обавезно поље",
+ "No such user": "Непостојећи корисник",
+ "Chinese (Traditional)": "Кинески (Традиционални)",
+ "adminprefs_modified_source_code_url_label": "УРЛ веза до складишта са Измењеном Изворном Кодом",
+ "Lao": "Лаоски",
+ "Czech": "Чешки",
+ "Kannada": "Канада (Језик)",
+ "Polish": "Пољски",
+ "Cebuano": "Себуано",
+ "preferences_show_nick_label": "Прикажи надимке на врху: ",
+ "Report statistics: ": "Извештавај о статистици: ",
+ "Show more": "Прикажи више",
+ "Login failed. This may be because two-factor authentication is not turned on for your account.": "Неуспешна пријава! Ово се можда дешава јер двофакторна аутентикација није омогућена на vашем налогу.",
+ "Wrong answer": "Погрешан одговор",
+ "Hidden field \"token\" is a required field": "Сакривено \"token\" поље је обавезно",
+ "English": "Енглески",
+ "Albanian": "Албански",
+ "Amharic": "Амхарски",
+ "Azerbaijani": "Азербејџански",
+ "Basque": "Баскијски",
+ "Belarusian": "Белоруски",
+ "Chinese (Simplified)": "Кинески (Поједностављени)",
+ "Croatian": "Хрватски",
+ "Dutch": "Холандски",
+ "Esperanto": "Есперанто",
+ "Finnish": "Фински",
+ "French": "Француски",
+ "Georgian": "Грузијски",
+ "Greek": "Грчки",
+ "Hausa": "Хауса",
+ "video": "Видео",
+ "playlist": "Плеј листа",
+ "movie": "Филм",
+ "long": "Дуго (> 20 минута)",
+ "creative_commons": "Creative Commons (Лиценца)",
+ "live": "Уживо",
+ "location": "Локација",
+ "filter": "Филтер",
+ "next_steps_error_message": "Након чега би требали пробати: ",
+ "footer_donate_page": "Донирај",
+ "footer_documentation": "Документација",
+ "footer_modfied_source_code": "Измењена Изворна Кода",
+ "preferences_region_label": "Држава порекла садржаја: ",
+ "preferences_category_misc": "Остала подешавања",
+ "User ID is a required field": "Кориснички ИД је обавезно поље",
+ "Password is a required field": "Лозинка је обавезно поље",
+ "Wrong username or password": "Погрешно корисничко име или лозинка",
+ "Please sign in using 'Log in with Google'": "Молимо Вас да се пријавите помоћу 'Log in with Google'",
+ "Password cannot be empty": "Лозинка не може бити празна",
+ "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 карактера",
+ "Invidious Private Feed for `x`": "Инвидиоус Приватни Довод за `x`",
+ "Deleted or invalid channel": "Обрисан или непостојећи канал",
+ "This channel does not exist.": "Овај канал не постоји.",
+ "Could not create mix.": "Прављење микса није успело.",
+ "Empty playlist": "Празна плеј листа",
+ "Not a playlist.": "Није плеј листа.",
+ "Playlist does not exist.": "Непостојећа плеј листа.",
+ "Could not pull trending pages.": "Учитавање 'У току' страница није успело.",
+ "Hidden field \"challenge\" is a required field": "Сакривено \"challenge\" поље је обавезно",
+ "Telugu": "Телугу",
+ "Turkish": "Турски",
+ "Urdu": "Урду",
+ "Western Frisian": "Западнофрисијски",
+ "Xhosa": "Коса (Језик)",
+ "Yiddish": "Јидиш",
+ "Hawaiian": "Хавајски",
+ "Hmong": "Хмонг",
+ "Hungarian": "Мађарски",
+ "Igbo": "Игбо",
+ "Javanese": "Јавански",
+ "Khmer": "Кмерски",
+ "Kyrgyz": "Киргиски",
+ "Macedonian": "Македонски",
+ "Maori": "Маори (Језик)",
+ "Marathi": "Маратхи",
+ "Nepali": "Непалски",
+ "Norwegian Bokmål": "Норвешки Бокмал",
+ "Nyanja": "Чева",
+ "Russian": "Руски",
+ "Scottish Gaelic": "Шкотски Гелски",
+ "Shona": "Шона",
+ "Slovak": "Словачки",
+ "Spanish (Latin America)": "Шпански (Јужна Америка)",
+ "Sundanese": "Сундски",
+ "Swahili": "Свахили",
+ "Tajik": "Таџички",
+ "Search": "Претрага",
+ "Rating: ": "Ocena/e: ",
+ "Default": "Подразумеван/о",
+ "News": "Вести",
+ "Download": "Преузми",
+ "(edited)": "(измењено)",
+ "`x` marked it with a ❤": "`x` је означио/ла ово са ❤",
+ "Audio mode": "Аудио мод",
+ "Videos": "Видео клипови",
+ "views": "Број прегледа",
+ "features": "Карактеристике",
+ "today": "Данас",
+ "%A %B %-d, %Y": "%A %B %-d, %Y",
+ "preferences_locale_label": "Језик: ",
+ "Persian": "Перзијски",
+ "View `x` comments": {
+ "": "Прикажи `x` коментара",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Прикажи `x` коментар"
+ },
+ "channel": "Канал",
+ "Haitian Creole": "Хаићански Креолски",
+ "Armenian": "Јерменски",
+ "next_steps_error_message_go_to_youtube": "Иди на YouTube",
+ "Indonesian": "Индонежански",
+ "preferences_vr_mode_label": "Интерактивни видео клипови у 360 степени: ",
+ "Switch Invidious Instance": "Промени Invidious инстанцу",
+ "Portuguese": "Португалски",
+ "week": "Ове седмице",
+ "show": "Емисија",
+ "Fallback comments: ": "Коментари у случају отказивања: ",
+ "hdr": "Видео Високе Резолуције",
+ "About": "О програму",
+ "Kazakh": "Казашки",
+ "Shared `x`": "Подељено `x`",
+ "Playlists": "Плеј листе",
+ "Yoruba": "Јоруба",
+ "Erroneous challenge": "Погрешан изазов",
+ "Danish": "Дански",
+ "Could not get channel info.": "Узимање података о каналу није успело.",
+ "hd": "HD",
+ "Slovenian": "Словеначки",
+ "Load more": "Учитај више",
+ "German": "Немачки",
+ "Luxembourgish": "Луксембуршки",
+ "Mongolian": "Монголски",
+ "Latvian": "Летонски",
+ "channel:`x`": "kanal:`x`",
+ "Southern Sotho": "Јужни Сото",
+ "Popular": "Популарно",
+ "Gujarati": "Гуџарати",
+ "year": "Ове године",
+ "Irish": "Ирски",
+ "YouTube comment permalink": "YouTube коментар трајна веза",
+ "Malagasy": "Малгашки",
+ "Token is expired, please try again": "Жетон је истекао, молимо вас да покушате поново",
+ "short": "Кратко (< 4 минуте)",
+ "Samoan": "Самоански",
+ "Tamil": "Тамилски",
+ "Ukrainian": "Украјински",
+ "permalink": "трајна веза",
+ "Pashto": "Паштунски",
+ "Community": "Заједница",
+ "Sindhi": "Синди",
+ "Could not fetch comments": "Узимање коментара није успело",
+ "Bangla": "Бангла/Бенгалски",
+ "Uzbek": "Узбечки",
+ "Lithuanian": "Литвански",
+ "Icelandic": "Исландски",
+ "Thai": "Тајски",
+ "month": "Овај месец",
+ "content_type": "Тип",
+ "hour": "Последњи сат",
+ "Spanish": "Шпански",
+ "date": "Датум отпремања",
+ "View as playlist": "Погледај као плеј листу",
+ "relevance": "Релевантност",
+ "Estonian": "Естонски",
+ "Sinhala": "Синхалешки",
+ "Corsican": "Корзикански",
+ "Filipino": "Филипино",
+ "Gaming": "Игрице",
+ "Movies": "Филмови",
+ "rating": "Оцене",
+ "Top enabled: ": "Врх омогућен: ",
+ "Released under the AGPLv3 on Github.": "Избачено под лиценцом AGPLv3 на Github-у.",
+ "Afrikaans": "Африканс",
+ "preferences_automatic_instance_redirect_label": "Аутоматско пребацивање на другу инстанцу у случају отказивања (пречи ће назад на редирецт.инвидиоус.ио): ",
+ "Invalid TFA code": "Неважећа TFA кода",
+ "Please log in": "Молимо вас да се пријавите",
+ "English (auto-generated)": "Енглески (аутоматски генерисано)",
+ "Hindi": "Хинди",
+ "Italian": "Талијански",
+ "Malayalam": "Малајалам",
+ "Punjabi": "Пунџаби",
+ "Somali": "Сомалијски",
+ "Vietnamese": "Вијетнамски",
+ "Welsh": "Велшки",
+ "Zulu": "Зулу",
+ "Maltese": "Малтешки",
+ "Swedish": "Шведски",
+ "Music": "Музика",
+ "Download as: ": "Преузми као: ",
+ "duration": "Трајање",
+ "sort": "Поредај према",
+ "subtitles": "Титл/Превод",
+ "preferences_extend_desc_label": "Аутоматски прикажи цео опис видеа: ",
+ "Show less": "Прикажи мање",
+ "Broken? Try another Invidious Instance": "Не функционише исправно? Пробајте другу Invidious инстанцу",
+ "Family friendly? ": "Погодно за породицу? ",
+ "Premieres `x`": "Премерe у `x`",
+ "Bosnian": "Босански",
+ "Catalan": "Каталонски",
+ "Japanese": "Јапански",
+ "Latin": "Латински",
+ "next_steps_error_message_refresh": "Освежи страницу",
+ "footer_original_source_code": "Оригинална Изворна Кода",
+ "Romanian": "Румунски",
+ "Serbian": "Српски",
+ "Top": "Врх",
+ "Video mode": "Видео мод",
+ "footer_source_code": "Изворна Кода",
+ "3d": "3D",
+ "4k": "4K",
+ "Erroneous CAPTCHA": "Погрешна CAPTCHA",
+ "`x` ago": "пре `x`",
+ "Arabic": "Арапски",
+ "Bulgarian": "Бугарски",
+ "Galician": "Галицијски",
+ "Hebrew": "Хебрејски",
+ "Korean": "Корејски",
+ "Kurdish": "Курдски",
+ "Malay": "Малајски"
}
diff --git a/locales/sv-SE.json b/locales/sv-SE.json
index ae8e6fc4..98c24cc3 100644
--- a/locales/sv-SE.json
+++ b/locales/sv-SE.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumeranter",
- "": "`x` prenumeranter"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videor",
- "": "`x` videor"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spellistor",
- "": "`x` spellistor"
- },
"LIVE": "LIVE",
"Shared `x` ago": "Delad `x` sedan",
"Unsubscribe": "Avprenumerera",
@@ -60,39 +48,38 @@
"E-mail": "E-post",
"Google verification code": "Google-bekräftelsekod",
"Preferences": "Inställningar",
- "Player preferences": "Spelarinställningar",
- "Always loop: ": "Loopa alltid: ",
- "Autoplay: ": "Autouppspelning: ",
- "Play next by default: ": "Spela nästa som förval: ",
- "Autoplay next video: ": "Autouppspela nästa video: ",
- "Listen by default: ": "Lyssna som förval: ",
- "Proxy videos: ": "Proxy:a videor: ",
- "Default speed: ": "Förvald hastighet: ",
- "Preferred video quality: ": "Föredragen videokvalitet: ",
- "Player volume: ": "Volym: ",
- "Default comments: ": "Förvalda kommentarer: ",
+ "preferences_category_player": "Spelarinställningar",
+ "preferences_video_loop_label": "Loopa alltid: ",
+ "preferences_autoplay_label": "Autouppspelning: ",
+ "preferences_continue_label": "Spela nästa som förval: ",
+ "preferences_continue_autoplay_label": "Autouppspela nästa video: ",
+ "preferences_listen_label": "Lyssna som förval: ",
+ "preferences_local_label": "Proxy:a videor: ",
+ "preferences_speed_label": "Förvald hastighet: ",
+ "preferences_quality_label": "Föredragen videokvalitet: ",
+ "preferences_volume_label": "Volym: ",
+ "preferences_comments_label": "Förvalda kommentarer: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Förvalda undertexter: ",
+ "preferences_captions_label": "Förvalda undertexter: ",
"Fallback captions: ": "Ersättningsundertexter: ",
- "Show related videos: ": "Visa relaterade videor? ",
- "Show annotations by default: ": "Visa länkar-i-videon som förval? ",
- "Automatically extend video description: ": "Förläng videobeskrivning automatiskt: ",
- "Interactive 360 degree videos: ": "Interaktiva 360-gradervideos: ",
- "Visual preferences": "Visuella inställningar",
- "Player style: ": "Spelarstil: ",
+ "preferences_related_videos_label": "Visa relaterade videor? ",
+ "preferences_annotations_label": "Visa länkar-i-videon som förval? ",
+ "preferences_extend_desc_label": "Förläng videobeskrivning automatiskt: ",
+ "preferences_vr_mode_label": "Interaktiva 360-gradervideos: ",
+ "preferences_category_visual": "Visuella inställningar",
+ "preferences_player_style_label": "Spelarstil: ",
"Dark mode: ": "Mörkt läge: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "Mörkt",
"light": "Ljust",
- "Thin mode: ": "Lättviktigt läge: ",
- "Miscellaneous preferences": "Övriga inställningar",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Prenumerationsinställningar",
- "Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ",
+ "preferences_thin_mode_label": "Lättviktigt läge: ",
+ "preferences_category_misc": "Övriga inställningar",
+ "preferences_category_subscription": "Prenumerationsinställningar",
+ "preferences_annotations_subscribed_label": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ",
"Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ",
- "Number of videos shown in feed: ": "Antal videor att visa i flödet: ",
- "Sort videos by: ": "Sortera videor: ",
+ "preferences_max_results_label": "Antal videor att visa i flödet: ",
+ "preferences_sort_label": "Sortera videor: ",
"published": "publicering",
"published - reverse": "publicering - omvänd",
"alphabetically": "alfabetiskt",
@@ -101,12 +88,12 @@
"channel name - reverse": "kanalnamn - omvänd",
"Only show latest video from channel: ": "Visa bara senaste videon från kanal: ",
"Only show latest unwatched video from channel: ": "Visa bara senaste osedda videon från kanal: ",
- "Only show unwatched: ": "Visa bara osedda: ",
- "Only show notifications (if there are any): ": "Visa endast aviseringar (om det finns några): ",
+ "preferences_unseen_only_label": "Visa bara osedda: ",
+ "preferences_notifications_only_label": "Visa endast aviseringar (om det finns några): ",
"Enable web notifications": "Slå på aviseringar",
"`x` uploaded a video": "`x` laddade upp en video",
"`x` is live": "`x` sänder live",
- "Data preferences": "Datainställningar",
+ "preferences_category_data": "Datainställningar",
"Clear watch history": "Töm visningshistorik",
"Import/export data": "Importera/Exportera data",
"Change password": "Byt lösenord",
@@ -114,10 +101,10 @@
"Manage tokens": "Hantera åtkomst-tokens",
"Watch history": "Visningshistorik",
"Delete account": "Radera konto",
- "Administrator preferences": "Administratörsinställningar",
- "Default homepage: ": "Förvald hemsida: ",
- "Feed menu: ": "Flödesmeny: ",
- "Show nickname on top: ": "Visa smeknamn överst: ",
+ "preferences_category_admin": "Administratörsinställningar",
+ "preferences_default_home_label": "Förvald hemsida: ",
+ "preferences_feed_menu_label": "Flödesmeny: ",
+ "preferences_show_nick_label": "Visa smeknamn överst: ",
"Top enabled: ": "Topp påslaget? ",
"CAPTCHA enabled: ": "CAPTCHA påslaget? ",
"Login enabled: ": "Inloggning påslaget? ",
@@ -127,25 +114,12 @@
"Subscription manager": "Prenumerationshanterare",
"Token manager": "Åtkomst-token-hanterare",
"Token": "Åtkomst-token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumerationer",
- "": "`x` prenumerationer"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` åtkomst-token",
- "": "`x` åtkomst-token"
- },
"Import/export": "Importera/exportera",
"unsubscribe": "avprenumerera",
"revoke": "återkalla",
"Subscriptions": "Prenumerationer",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` osedda aviseringar",
- "": "`x` osedda aviseringar"
- },
"search": "sök",
"Log out": "Logga ut",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Källkod tillgänglig här.",
"View JavaScript license information.": "Visa JavaScript-licensinformation.",
"View privacy policy.": "Visa privatlivspolicy.",
@@ -176,10 +150,6 @@
"Whitelisted regions: ": "Vitlistade regioner: ",
"Blacklisted regions: ": "Svartlistade regioner: ",
"Shared `x`": "Delade `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visningar",
- "": "`x` visningar"
- },
"Premieres in `x`": "Premiär om `x`",
"Premieres `x`": "Premiär av `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.": "Hej. Det ser ut som att du har JavaScript avstängt. Klicka här för att visa kommentarer, ha i åtanke att nedladdning tar längre tid.",
@@ -213,16 +183,8 @@
"This channel does not exist.": "Denna kanal finns inte.",
"Could not get channel info.": "Kunde inte hämta kanalinfo.",
"Could not fetch comments": "Kunde inte hämta kommentarer",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` svar",
- "": "Visa `x` svar"
- },
"`x` ago": "`x` sedan",
"Load more": "Ladda fler",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poäng",
- "": "`x` poäng"
- },
"Could not create mix.": "Kunde inte skapa mix.",
"Empty playlist": "Spellistan är tom",
"Not a playlist.": "Ogiltig spellista.",
@@ -340,41 +302,13 @@
"Yiddish": "Jiddisch",
"Yoruba": "Yoruba",
"Zulu": "Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` år",
- "": "`x` år"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` månader",
- "": "`x` månader"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` veckor",
- "": "`x` veckor"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagar",
- "": "`x` dagar"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` timmar",
- "": "`x` timmar"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuter",
- "": "`x` minuter"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder",
- "": "`x` sekunder"
- },
"Fallback comments: ": "Fallback-kommentarer: ",
"Popular": "Populärt",
"Search": "Sök",
"Top": "Topp",
"About": "Om",
"Rating: ": "Betyg: ",
- "Language: ": "Språk: ",
+ "preferences_locale_label": "Språk: ",
"View as playlist": "Visa som spellista",
"Default": "Förvalt",
"Music": "Musik",
@@ -421,7 +355,11 @@
"hdr": "hdr",
"filter": "Filter",
"Current version: ": "Nuvarande version: ",
- "next_steps_error_message": "",
"next_steps_error_message_refresh": "Uppdatera",
- "next_steps_error_message_go_to_youtube": "Gå till Youtube"
+ "next_steps_error_message_go_to_youtube": "Gå till Youtube",
+ "Released under the AGPLv3 on Github.": "Publicerad under AGPLv3 på Github.",
+ "footer_source_code": "Källkod",
+ "long": "Lång (> 20 minuter)",
+ "footer_documentation": "Dokumentation",
+ "short": "Kort (< 4 minuter)"
}
diff --git a/locales/tr.json b/locales/tr.json
index 493f1295..5c3102c5 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abone",
- "": "`x` abone"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": "`x` video"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` oynatma listesi",
- "": "`x` oynatma listesi"
- },
"LIVE": "CANLI",
"Shared `x` ago": "`x` önce paylaşıldı",
"Unsubscribe": "Abonelikten çık",
@@ -60,39 +48,39 @@
"E-mail": "E-posta",
"Google verification code": "Google doğrulama kodu",
"Preferences": "Tercihler",
- "Player preferences": "Oynatıcı tercihleri",
- "Always loop: ": "Sürekli döngü: ",
- "Autoplay: ": "Otomatik oynat: ",
- "Play next by default: ": "Öntanımlı olarak sonrakini oynat: ",
- "Autoplay next video: ": "Sonraki videoyu otomatik oynat: ",
- "Listen by default: ": "Öntanımlı olarak dinle: ",
- "Proxy videos: ": "Videoları proxy'le: ",
- "Default speed: ": "Öntanımlı hız: ",
- "Preferred video quality: ": "Tercih edilen video kalitesi: ",
- "Player volume: ": "Oynatıcı ses seviyesi: ",
- "Default comments: ": "Öntanımlı yorumlar: ",
+ "preferences_category_player": "Oynatıcı tercihleri",
+ "preferences_video_loop_label": "Sürekli döngü: ",
+ "preferences_autoplay_label": "Otomatik oynat: ",
+ "preferences_continue_label": "Öntanımlı olarak sonrakini oynat: ",
+ "preferences_continue_autoplay_label": "Sonraki videoyu otomatik oynat: ",
+ "preferences_listen_label": "Öntanımlı olarak dinle: ",
+ "preferences_local_label": "Videoları proxy'le: ",
+ "preferences_speed_label": "Öntanımlı hız: ",
+ "preferences_quality_label": "Tercih edilen video kalitesi: ",
+ "preferences_volume_label": "Oynatıcı ses seviyesi: ",
+ "preferences_comments_label": "Öntanımlı yorumlar: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "Öntanımlı altyazılar: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "Öntanımlı altyazılar: ",
"Fallback captions: ": "Yedek altyazılar: ",
- "Show related videos: ": "İlgili videoları göster: ",
- "Show annotations by default: ": "Öntanımlı olarak ek açıklamaları göster: ",
- "Automatically extend video description: ": "Video açıklamasını otomatik olarak genişlet: ",
- "Interactive 360 degree videos: ": "Etkileşimli 360 derece videolar: ",
- "Visual preferences": "Görsel tercihler",
- "Player style: ": "Oynatıcı biçimi: ",
+ "preferences_related_videos_label": "İlgili videoları göster: ",
+ "preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ",
+ "preferences_extend_desc_label": "Video açıklamasını otomatik olarak genişlet: ",
+ "preferences_vr_mode_label": "Etkileşimli 360 derece videolar: ",
+ "preferences_category_visual": "Görsel tercihler",
+ "preferences_player_style_label": "Oynatıcı biçimi: ",
"Dark mode: ": "Karanlık mod: ",
- "Theme: ": "Tema: ",
+ "preferences_dark_mode_label": "Tema: ",
"dark": "karanlık",
"light": "aydınlık",
- "Thin mode: ": "İnce mod: ",
- "Miscellaneous preferences": "Çeşitli tercihler",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ",
- "Subscription preferences": "Abonelik tercihleri",
- "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ",
+ "preferences_thin_mode_label": "İnce mod: ",
+ "preferences_category_misc": "Çeşitli tercihler",
+ "preferences_automatic_instance_redirect_label": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ",
+ "preferences_category_subscription": "Abonelik tercihleri",
+ "preferences_annotations_subscribed_label": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ",
"Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ",
- "Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ",
- "Sort videos by: ": "Videoları sıralama kriteri: ",
+ "preferences_max_results_label": "Akışta gösterilen video sayısı: ",
+ "preferences_sort_label": "Videoları sıralama kriteri: ",
"published": "yayınlandı",
"published - reverse": "yayınlandı - ters",
"alphabetically": "alfabetik olarak",
@@ -101,12 +89,12 @@
"channel name - reverse": "kanal adı - ters",
"Only show latest video from channel: ": "Sadece kanaldaki en son videoyu göster: ",
"Only show latest unwatched video from channel: ": "Sadece kanaldaki en son izlenmemiş videoyu göster: ",
- "Only show unwatched: ": "Sadece izlenmemişleri göster: ",
- "Only show notifications (if there are any): ": "Sadece bildirimleri göster (eğer varsa): ",
+ "preferences_unseen_only_label": "Sadece izlenmemişleri göster: ",
+ "preferences_notifications_only_label": "Sadece bildirimleri göster (eğer varsa): ",
"Enable web notifications": "Ağ bildirimlerini etkinleştir",
"`x` uploaded a video": "`x` bir video yükledi",
"`x` is live": "`x` canlı yayında",
- "Data preferences": "Veri tercihleri",
+ "preferences_category_data": "Veri tercihleri",
"Clear watch history": "İzleme geçmişini temizle",
"Import/export data": "Verileri içe/dışa aktar",
"Change password": "Parolayı değiştir",
@@ -114,10 +102,10 @@
"Manage tokens": "Belirteçleri yönet",
"Watch history": "İzleme geçmişi",
"Delete account": "Hesap silme",
- "Administrator preferences": "Yönetici tercihleri",
- "Default homepage: ": "Öntanımlı ana sayfa: ",
- "Feed menu: ": "Akış menüsü: ",
- "Show nickname on top: ": "Takma adı üstte göster: ",
+ "preferences_category_admin": "Yönetici tercihleri",
+ "preferences_default_home_label": "Öntanımlı ana sayfa: ",
+ "preferences_feed_menu_label": "Akış menüsü: ",
+ "preferences_show_nick_label": "Takma adı üstte göster: ",
"Top enabled: ": "Top etkin: ",
"CAPTCHA enabled: ": "CAPTCHA etkin: ",
"Login enabled: ": "Oturum açma etkin: ",
@@ -127,22 +115,10 @@
"Subscription manager": "Abonelik yöneticisi",
"Token manager": "Belirteç yöneticisi",
"Token": "Belirteç",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonelik",
- "": "`x` abonelik"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` belirteç",
- "": "`x` belirteç"
- },
"Import/export": "İçe/dışa aktar",
"unsubscribe": "abonelikten çık",
"revoke": "geri al",
"Subscriptions": "Abonelikler",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` okunmamış bildirim",
- "": "`x` okunmamış bildirim"
- },
"search": "ara",
"Log out": "Çıkış yap",
"Released under the AGPLv3 on Github.": "Github'da AGPLv3 altında yayınlandı.",
@@ -176,10 +152,6 @@
"Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ",
"Blacklisted regions: ": "Kara listeye alınan bölgeler: ",
"Shared `x`": "`x` paylaşıldı",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme",
- "": "`x` görüntüleme"
- },
"Premieres in `x`": "`x`içinde ilk gösterim",
"Premieres `x`": "`x` ilk gösterim",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Merhaba! JavaScript'i kapatmış gibi görünüyorsun. Yorumları görüntülemek için buraya tıkla, yüklenmelerinin biraz uzun sürebileceğini unutma.",
@@ -213,16 +185,8 @@
"This channel does not exist.": "Bu kanal mevcut değil.",
"Could not get channel info.": "Kanal bilgisi alınamadı.",
"Could not fetch comments": "Yorumlar alınamadı",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle",
- "": "`x` yanıtı görüntüle"
- },
"`x` ago": "`x` önce",
"Load more": "Daha fazla yükle",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan",
- "": "`x` puan"
- },
"Could not create mix.": "Mix oluşturulamadı.",
"Empty playlist": "Boş oynatma listesi",
"Not a playlist.": "Oynatma listesi değil.",
@@ -256,7 +220,7 @@
"Croatian": "Hırvatça",
"Czech": "Çekçe",
"Danish": "Danca",
- "Dutch": "Flemenkçe",
+ "Dutch": "Felemenkçe",
"Esperanto": "Esperanto",
"Estonian": "Estonca",
"Filipino": "Filipince",
@@ -340,41 +304,13 @@
"Yiddish": "Yiddiş",
"Yoruba": "Yoruba dili",
"Zulu": "Zuluca",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl",
- "": "`x` yıl"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay",
- "": "`x` ay"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta",
- "": "`x` hafta"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün",
- "": "`x` gün"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat",
- "": "`x` saat"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika",
- "": "`x` dakika"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye",
- "": "`x` saniye"
- },
"Fallback comments: ": "Yedek yorumlar: ",
"Popular": "Popüler",
"Search": "Ara",
"Top": "Enler",
"About": "Hakkında",
"Rating: ": "Değerlendirme: ",
- "Language: ": "Dil: ",
+ "preferences_locale_label": "Dil: ",
"View as playlist": "Oynatma listesi olarak görüntüle",
"Default": "Öntanımlı",
"Music": "Müzik",
@@ -423,5 +359,83 @@
"Current version: ": "Şu anki sürüm: ",
"next_steps_error_message": "Bundan sonra şunları denemelisiniz: ",
"next_steps_error_message_refresh": "Yenile",
- "next_steps_error_message_go_to_youtube": "YouTube'a git"
+ "next_steps_error_message_go_to_youtube": "YouTube'a git",
+ "short": "Kısa (4 dakikadan az)",
+ "long": "Uzun (20 dakikadan fazla)",
+ "footer_documentation": "Belgelendirme",
+ "footer_source_code": "Kaynak kodları",
+ "footer_original_source_code": "Orijinal kaynak kodları",
+ "footer_modfied_source_code": "Değiştirilmiş kaynak kodları",
+ "adminprefs_modified_source_code_url_label": "Değiştirilmiş kaynak kodları deposunun URL'si",
+ "footer_donate_page": "Bağış yap",
+ "preferences_region_label": "İçerik ülkesi: ",
+ "preferences_quality_dash_label": "Tercih edilen DASH video kalitesi: ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_best": "En iyi",
+ "preferences_quality_dash_option_worst": "En kötü",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "none": "yok",
+ "videoinfo_started_streaming_x_ago": "`x` önce yayına başladı",
+ "videoinfo_youTube_embed_link": "Göm",
+ "videoinfo_invidious_embed_link": "Bağlantıyı Göm",
+ "user_created_playlists": "`x` oluşturulan oynatma listeleri",
+ "user_saved_playlists": "`x` kaydedilen oynatma listeleri",
+ "preferences_quality_option_small": "Küçük",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_option_medium": "Orta",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "Video unavailable": "Video kullanılamıyor",
+ "preferences_quality_option_dash": "DASH (uyarlanabilir kalite)",
+ "preferences_quality_dash_option_auto": "Otomatik",
+ "purchased": "Satın alınan",
+ "360": "360°",
+ "videoinfo_watch_on_youTube": "YouTube'da izle",
+ "download_subtitles": "Alt yazılar - `x` (.vtt)",
+ "preferences_save_player_pos_label": "Oynatma konumunu kaydet: ",
+ "generic_views_count": "{{count}} görüntüleme",
+ "generic_views_count_plural": "{{count}} görüntüleme",
+ "generic_subscribers_count": "{{count}} abone",
+ "generic_subscribers_count_plural": "{{count}} abone",
+ "generic_subscriptions_count": "{{count}} abonelik",
+ "generic_subscriptions_count_plural": "{{count}} abonelik",
+ "subscriptions_unseen_notifs_count": "{{count}} okunmamış bildirim",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} okunmamış bildirim",
+ "comments_points_count": "{{count}} puan",
+ "comments_points_count_plural": "{{count}} puan",
+ "generic_count_hours": "{{count}} saat",
+ "generic_count_hours_plural": "{{count}} saat",
+ "generic_count_minutes": "{{count}} dakika",
+ "generic_count_minutes_plural": "{{count}} dakika",
+ "generic_count_seconds": "{{count}} saniye",
+ "generic_count_seconds_plural": "{{count}} saniye",
+ "generic_playlists_count": "{{count}} oynatma listesi",
+ "generic_playlists_count_plural": "{{count}} oynatma listesi",
+ "tokens_count": "{{count}} belirteç",
+ "tokens_count_plural": "{{count}} belirteç",
+ "comments_view_x_replies": "{{count}} yanıtı görüntüle",
+ "comments_view_x_replies_plural": "{{count}} yanıtı görüntüle",
+ "generic_count_years": "{{count}} yıl",
+ "generic_count_years_plural": "{{count}} yıl",
+ "generic_count_months": "{{count}} ay",
+ "generic_count_months_plural": "{{count}} ay",
+ "generic_count_days": "{{count}} gün",
+ "generic_count_days_plural": "{{count}} gün",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} video",
+ "generic_count_weeks": "{{count}} hafta",
+ "generic_count_weeks_plural": "{{count}} hafta",
+ "crash_page_you_found_a_bug": "Görünüşe göre Invidious'ta bir hata buldunuz!",
+ "crash_page_before_reporting": "Bir hatayı bildirmeden önce, şunları yaptığınızdan emin olun:",
+ "crash_page_refresh": "<a href=\"`x`\">sayfayı yenilemeye</a> çalıştınız",
+ "crash_page_switch_instance": "<a href=\"`x`\">başka bir örnek kullanmaya</a> çalıştınız",
+ "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> okudunuz",
+ "crash_page_search_issue": "<a href=\"`x`\">Github'daki sorunlarda</a> aradınız",
+ "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (bu metni ÇEVİRMEYİN):"
}
diff --git a/locales/uk.json b/locales/uk.json
index 5100206c..097752d9 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -1,16 +1,4 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` підписників",
- "": "`x` підписників"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` відео",
- "": "`x` відео"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "списки відтворення `x`",
- "": "списки відтворення `x`"
- },
"LIVE": "ПРЯМИЙ ЕФІР",
"Shared `x` ago": "Розміщено `x` назад",
"Unsubscribe": "Відписатися",
@@ -60,39 +48,35 @@
"E-mail": "Електронна пошта",
"Google verification code": "Код підтвердження Google",
"Preferences": "Налаштування",
- "Player preferences": "Налаштування програвача",
- "Always loop: ": "Завжди повторювати: ",
- "Autoplay: ": "Автовідтворення: ",
- "Play next by default: ": "Завжди вмикати наступне відео: ",
- "Autoplay next video: ": "Автовідтворення наступного відео: ",
- "Listen by default: ": "Режим «тільки звук» як усталений: ",
- "Proxy videos: ": "Програвати відео через проксі? ",
- "Default speed: ": "Усталена швидкість відео: ",
- "Preferred video quality: ": "Пріорітетна якість відео: ",
- "Player volume: ": "Гучність відео: ",
- "Default comments: ": "Джерело коментарів: ",
+ "preferences_category_player": "Налаштування програвача",
+ "preferences_video_loop_label": "Завжди повторювати: ",
+ "preferences_autoplay_label": "Автовідтворення: ",
+ "preferences_continue_label": "Завжди вмикати наступне відео: ",
+ "preferences_continue_autoplay_label": "Автовідтворення наступного відео: ",
+ "preferences_listen_label": "Режим «тільки звук» як усталений: ",
+ "preferences_local_label": "Програвати відео через проксі? ",
+ "preferences_speed_label": "Усталена швидкість відео: ",
+ "preferences_quality_label": "Пріорітетна якість відео: ",
+ "preferences_volume_label": "Гучність відео: ",
+ "preferences_comments_label": "Джерело коментарів: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "Основна мова субтитрів: ",
+ "preferences_captions_label": "Основна мова субтитрів: ",
"Fallback captions: ": "Запасна мова субтитрів: ",
- "Show related videos: ": "Показувати схожі відео? ",
- "Show annotations by default: ": "Завжди показувати анотації? ",
- "Automatically extend video description: ": "",
- "Interactive 360 degree videos: ": "",
- "Visual preferences": "Налаштування сайту",
- "Player style: ": "Стиль програвача: ",
+ "preferences_related_videos_label": "Показувати схожі відео? ",
+ "preferences_annotations_label": "Завжди показувати анотації? ",
+ "preferences_category_visual": "Налаштування сайту",
+ "preferences_player_style_label": "Стиль програвача: ",
"Dark mode: ": "Темне оформлення: ",
- "Theme: ": "Тема: ",
+ "preferences_dark_mode_label": "Тема: ",
"dark": "темна",
"light": "Світла",
- "Thin mode: ": "Полегшене оформлення: ",
- "Miscellaneous preferences": "",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "",
- "Subscription preferences": "Налаштування підписок",
- "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
+ "preferences_thin_mode_label": "Полегшене оформлення: ",
+ "preferences_category_subscription": "Налаштування підписок",
+ "preferences_annotations_subscribed_label": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
"Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ",
- "Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ",
- "Sort videos by: ": "Сортувати відео: ",
+ "preferences_max_results_label": "Кількість відео з каналів, на які підписані, у потоці: ",
+ "preferences_sort_label": "Сортувати відео: ",
"published": "за датою розміщення",
"published - reverse": "за датою розміщення в зворотному порядку",
"alphabetically": "за абеткою",
@@ -101,12 +85,12 @@
"channel name - reverse": "за назвою каналу в зворотному порядку",
"Only show latest video from channel: ": "Показувати тільки останнє відео з каналів: ",
"Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ",
- "Only show unwatched: ": "Показувати тільки непереглянуті відео: ",
- "Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ",
+ "preferences_unseen_only_label": "Показувати тільки непереглянуті відео: ",
+ "preferences_notifications_only_label": "Показувати лише сповіщення, якщо вони є: ",
"Enable web notifications": "Ввімкнути сповіщення в браузері",
"`x` uploaded a video": "`x` розмістив відео",
"`x` is live": "`x` у прямому ефірі",
- "Data preferences": "Налаштування даних",
+ "preferences_category_data": "Налаштування даних",
"Clear watch history": "Очистити історію переглядів",
"Import/export data": "Імпорт і експорт даних",
"Change password": "Змінити пароль",
@@ -114,10 +98,9 @@
"Manage tokens": "Керувати токенами",
"Watch history": "Історія переглядів",
"Delete account": "Видалити обліківку",
- "Administrator preferences": "Адміністраторські налаштування",
- "Default homepage: ": "Усталена домашня сторінка: ",
- "Feed menu: ": "Меню потоку з відео: ",
- "Show nickname on top: ": "",
+ "preferences_category_admin": "Адміністраторські налаштування",
+ "preferences_default_home_label": "Усталена домашня сторінка: ",
+ "preferences_feed_menu_label": "Меню потоку з відео: ",
"Top enabled: ": "Увімкнути топ відео? ",
"CAPTCHA enabled: ": "Увімкнути капчу? ",
"Login enabled: ": "Увімкнути авторизацію? ",
@@ -127,25 +110,12 @@
"Subscription manager": "Менеджер підписок",
"Token manager": "Менеджер токенів",
"Token": "Токен",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` підписка / підписок / підписки",
- "": "`x` підписка / підписок / підписки"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` токенів",
- "": "`x` токенів"
- },
"Import/export": "Імпорт і експорт",
"unsubscribe": "відписатися",
"revoke": "скасувати",
"Subscriptions": "Підписки",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` непереглянуте сповіщення / непереглянутих сповіщень / непереглянутих сповіщення",
- "": "`x` непереглянуте сповіщення / непереглянутих сповіщень / непереглянутих сповіщення"
- },
"search": "пошук",
"Log out": "Вийти",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Програмний код доступний тут.",
"View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.",
"View privacy policy.": "Переглянути політику приватності.",
@@ -161,11 +131,7 @@
"Title": "Заголовок",
"Playlist privacy": "Конфіденційність списку відтворення",
"Editing playlist `x`": "Редагування списку відтворення \"x\"",
- "Show more": "",
- "Show less": "",
"Watch on YouTube": "Дивитися на YouTube",
- "Switch Invidious Instance": "",
- "Broken? Try another Invidious Instance": "",
"Hide annotations": "Приховати анотації",
"Show annotations": "Показати анотації",
"Genre: ": "Жанр: ",
@@ -176,10 +142,6 @@
"Whitelisted regions: ": "Доступно у регіонах: ",
"Blacklisted regions: ": "Недоступно у регіонах: ",
"Shared `x`": "Розміщено `x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` переглядів",
- "": "`x` переглядів"
- },
"Premieres in `x`": "Прем’єра через `x`",
"Premieres `x`": "Прем’єра `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.",
@@ -213,16 +175,8 @@
"This channel does not exist.": "Такого каналу не існує.",
"Could not get channel info.": "Не вдається отримати інформацію щодо цього каналу.",
"Could not fetch comments": "Не вдається завантажити коментарі",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Переглянути `x` відповідь / відповідей / відповіді",
- "": "Переглянути `x` відповідь / відповідей / відповіді"
- },
"`x` ago": "`x` тому",
"Load more": "Завантажити більше",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` очко / очок / очка",
- "": "`x` очко / очок / очка"
- },
"Could not create mix.": "Не вдається створити мікс.",
"Empty playlist": "Плейлист порожній",
"Not a playlist.": "Недійсний плейлист.",
@@ -340,41 +294,12 @@
"Yiddish": "Їдиш",
"Yoruba": "Йоруба",
"Zulu": "Зулу",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` років",
- "": "`x` років"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` місяців",
- "": "`x` місяців"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` тижнів",
- "": "`x` тижнів"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` днів",
- "": "`x` днів"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` годин",
- "": "`x` годин"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` хвилин",
- "": "`x` хвилин"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` секунд",
- "": "`x` секунд"
- },
"Fallback comments: ": "Резервні коментарі: ",
"Popular": "Популярне",
- "Search": "",
"Top": "Топ",
"About": "Про сайт",
"Rating: ": "Рейтинг: ",
- "Language: ": "Мова: ",
+ "preferences_locale_label": "Мова: ",
"View as playlist": "Дивитися як плейлист",
"Default": "Усталено",
"Music": "Музика",
@@ -393,35 +318,5 @@
"Videos": "Відео",
"Playlists": "Плейлисти",
"Community": "Спільнота",
- "relevance": "",
- "rating": "",
- "date": "",
- "views": "",
- "content_type": "",
- "duration": "",
- "features": "",
- "sort": "",
- "hour": "",
- "today": "",
- "week": "",
- "month": "",
- "year": "",
- "video": "",
- "channel": "",
- "playlist": "",
- "movie": "",
- "show": "",
- "hd": "",
- "subtitles": "",
- "creative_commons": "",
- "3d": "",
- "live": "",
- "4k": "",
- "location": "",
- "hdr": "",
- "filter": "",
- "Current version: ": "Поточна версія: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "Поточна версія: "
}
diff --git a/locales/vi.json b/locales/vi.json
index 5a2812f7..a8550686 100644
--- a/locales/vi.json
+++ b/locales/vi.json
@@ -1,16 +1,6 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscribers",
- "": "`x` subscribers"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
- "": ""
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
+ "generic_videos_count_0": "{{count}} video",
+ "generic_subscribers_count_0": "{{count}} subscribers",
"LIVE": "TRỰC TIẾP",
"Shared `x` ago": "Đã chia sẻ` x` trước",
"Unsubscribe": "Hủy đăng ký",
@@ -60,39 +50,39 @@
"E-mail": "E-mail",
"Google verification code": "Mã xác minh của Google",
"Preferences": "Sở thích",
- "Player preferences": "Tùy chọn người chơi",
- "Always loop: ": "Luôn lặp lại: ",
- "Autoplay: ": "Tự chạy: ",
- "Play next by default: ": "Phát tiếp theo theo mặc định: ",
- "Autoplay next video: ": "Tự động phát video tiếp theo: ",
- "Listen by default: ": "Nghe theo mặc định: ",
- "Proxy videos: ": "Video proxy: ",
- "Default speed: ": "Tốc độ mặc định: ",
- "Preferred video quality: ": "Chất lượng video ưa thích: ",
- "Player volume: ": "Khối lượng trình phát: ",
- "Default comments: ": "Nhận xét mặc định: ",
+ "preferences_category_player": "Tùy chọn người chơi",
+ "preferences_video_loop_label": "Luôn lặp lại: ",
+ "preferences_autoplay_label": "Tự chạy: ",
+ "preferences_continue_label": "Phát tiếp theo theo mặc định: ",
+ "preferences_continue_autoplay_label": "Tự động phát video tiếp theo: ",
+ "preferences_listen_label": "Nghe theo mặc định: ",
+ "preferences_local_label": "Video proxy: ",
+ "preferences_speed_label": "Tốc độ mặc định: ",
+ "preferences_quality_label": "Chất lượng video ưa thích: ",
+ "preferences_volume_label": "Khối lượng trình phát: ",
+ "preferences_comments_label": "Nhận xét mặc định: ",
"youtube": "YouTube",
"reddit": "reddit",
- "Default captions: ": "Phụ đề mặc định: ",
+ "preferences_captions_label": "Phụ đề mặc định: ",
"Fallback captions: ": "Phụ đề dự phòng: ",
- "Show related videos: ": "Hiển thị các video có liên quan: ",
- "Show annotations by default: ": "Hiển thị chú thích theo mặc định: ",
- "Automatically extend video description: ": "Tự động mở rộng mô tả video: ",
- "Interactive 360 degree videos: ": "Video 360 độ tương tác: ",
- "Visual preferences": "Tùy chọn hình ảnh",
- "Player style: ": "Phong cách người chơi: ",
+ "preferences_related_videos_label": "Hiển thị các video có liên quan: ",
+ "preferences_annotations_label": "Hiển thị chú thích theo mặc định: ",
+ "preferences_extend_desc_label": "Tự động mở rộng mô tả video: ",
+ "preferences_vr_mode_label": "Video 360 độ tương tác: ",
+ "preferences_category_visual": "Tùy chọn hình ảnh",
+ "preferences_player_style_label": "Phong cách người chơi: ",
"Dark mode: ": "Chế độ tối: ",
- "Theme: ": "Chủ đề: ",
+ "preferences_dark_mode_label": "Chủ đề: ",
"dark": "tối",
"light": "ánh sáng",
- "Thin mode: ": "Chế độ mỏng: ",
- "Miscellaneous preferences": "Tùy chọn khác",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ",
- "Subscription preferences": "Tùy chọn đăng ký",
- "Show annotations by default for subscribed channels: ": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ",
+ "preferences_thin_mode_label": "Chế độ mỏng: ",
+ "preferences_category_misc": "Tùy chọn khác",
+ "preferences_automatic_instance_redirect_label": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ",
+ "preferences_category_subscription": "Tùy chọn đăng ký",
+ "preferences_annotations_subscribed_label": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ",
"Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ",
- "Number of videos shown in feed: ": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ",
- "Sort videos by: ": "Sắp xếp video theo: ",
+ "preferences_max_results_label": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ",
+ "preferences_sort_label": "Sắp xếp video theo: ",
"published": "được phát hành",
"published - reverse": "đã xuất bản - đảo ngược",
"alphabetically": "theo thứ tự bảng chữ cái",
@@ -101,12 +91,12 @@
"channel name - reverse": "tên kênh - đảo ngược",
"Only show latest video from channel: ": "Chỉ hiển thị video mới nhất từ kênh: ",
"Only show latest unwatched video from channel: ": "Chỉ hiển thị video chưa xem mới nhất từ kênh: ",
- "Only show unwatched: ": "Chỉ hiển thị chưa xem: ",
- "Only show notifications (if there are any): ": "Chỉ hiển thị thông báo (nếu có): ",
+ "preferences_unseen_only_label": "Chỉ hiển thị chưa xem: ",
+ "preferences_notifications_only_label": "Chỉ hiển thị thông báo (nếu có): ",
"Enable web notifications": "Bật thông báo web",
"`x` uploaded a video": "` x` đã tải lên một video",
"`x` is live": "` x` đang phát trực tiếp",
- "Data preferences": "Tùy chọn dữ liệu",
+ "preferences_category_data": "Tùy chọn dữ liệu",
"Clear watch history": "Xóa lịch sử xem",
"Import/export data": "Nhập / xuất dữ liệu",
"Change password": "Đổi mật khẩu",
@@ -114,10 +104,10 @@
"Manage tokens": "Quản lý mã thông báo",
"Watch history": "Lịch sử xem",
"Delete account": "Xóa tài khoản",
- "Administrator preferences": "Tùy chọn quản trị viên",
- "Default homepage: ": "Trang chủ mặc định: ",
- "Feed menu: ": "Menu nguồn cấp dữ liệu: ",
- "Show nickname on top: ": "Hiển thị biệt hiệu ở trên cùng: ",
+ "preferences_category_admin": "Tùy chọn quản trị viên",
+ "preferences_default_home_label": "Trang chủ mặc định: ",
+ "preferences_feed_menu_label": "Menu nguồn cấp dữ liệu: ",
+ "preferences_show_nick_label": "Hiển thị biệt hiệu ở trên cùng: ",
"Top enabled: ": "Đã bật hàng đầu: ",
"CAPTCHA enabled: ": "Đã bật CAPTCHA: ",
"Login enabled: ": "Đã bật đăng nhập: ",
@@ -127,25 +117,8 @@
"Subscription manager": "Người quản lý đăng ký",
"Token manager": "Trình quản lý mã thông báo",
"Token": "Mã thông báo",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Import/export": "",
- "unsubscribe": "",
- "revoke": "",
- "Subscriptions": "",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"search": "Tìm kiếm",
"Log out": "Đăng xuất",
- "Released under the AGPLv3 on Github.": "",
"Source available here.": "Nguồn có sẵn ở đây.",
"View JavaScript license information.": "Xem thông tin giấy phép JavaScript.",
"View privacy policy.": "Xem chính sách bảo mật.",
@@ -176,19 +149,6 @@
"Whitelisted regions: ": "Các vùng nằm trong danh sách trắng: ",
"Blacklisted regions: ": "Khu vực nằm trong danh sách đen: ",
"Shared `x`": "Chia sẻ` x`",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "Premieres in `x`": "",
- "Premieres `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.": "",
- "View YouTube comments": "",
- "View more comments on Reddit": "",
- "View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"View Reddit comments": "Xem nhận xét trên Reddit",
"Hide replies": "Ẩn câu trả lời",
"Show replies": "Hiển thị câu trả lời",
@@ -213,16 +173,6 @@
"This channel does not exist.": "Kênh này không tồn tại.",
"Could not get channel info.": "Không thể tải thông tin kênh.",
"Could not fetch comments": "Không thể tìm nạp nhận xét",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` ago": "",
- "Load more": "",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"Could not create mix.": "Không thể tạo kết hợp.",
"Empty playlist": "Danh sách phát trống",
"Not a playlist.": "Không phải danh sách phát.",
@@ -340,41 +290,13 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Tiếng Zulu",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "",
- "": ""
- },
"Fallback comments: ": "Nhận xét dự phòng: ",
"Popular": "Phổ biến",
"Search": "Tìm kiếm",
"Top": "Hàng đầu",
"About": "Trong khoảng",
"Rating: ": "Xếp hạng: ",
- "Language: ": "Ngôn ngữ: ",
+ "preferences_locale_label": "Ngôn ngữ: ",
"View as playlist": "Xem dưới dạng danh sách phát",
"Default": "Mặc định",
"Music": "Âm nhạc",
@@ -420,8 +342,5 @@
"location": "vị trí",
"hdr": "hdr",
"filter": "bộ lọc",
- "Current version: ": "Phiên bản hiện tại: ",
- "next_steps_error_message": "",
- "next_steps_error_message_refresh": "",
- "next_steps_error_message_go_to_youtube": ""
+ "Current version: ": "Phiên bản hiện tại: "
}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 5f89f964..521545bc 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -1,16 +1,9 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 位订阅者",
- "": "`x` 位订阅者"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个视频",
- "": "`x` 个视频"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个播放列表",
- "": "`x` 个播放列表"
- },
+ "generic_views_count_0": "{{count}} 播放",
+ "generic_videos_count_0": "{{count}} 个视频",
+ "generic_playlists_count_0": "{{count}} 个播放列表",
+ "generic_subscribers_count_0": "{{count}} 位订阅者",
+ "generic_subscriptions_count_0": "{{count}} 个订阅",
"LIVE": "直播",
"Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消订阅",
@@ -60,39 +53,39 @@
"E-mail": "E-mail",
"Google verification code": "Google 验证代码",
"Preferences": "偏好设置",
- "Player preferences": "播放器偏好设置",
- "Always loop: ": "始终循环: ",
- "Autoplay: ": "自动播放: ",
- "Play next by default: ": "默认自动播放下一个视频: ",
- "Autoplay next video: ": "自动播放下一个视频: ",
- "Listen by default: ": "默认只听声音: ",
- "Proxy videos: ": "是否代理视频: ",
- "Default speed: ": "默认速度: ",
- "Preferred video quality: ": "视频质量偏好: ",
- "Player volume: ": "播放器音量: ",
- "Default comments: ": "默认评论源: ",
+ "preferences_category_player": "播放器偏好设置",
+ "preferences_video_loop_label": "始终循环: ",
+ "preferences_autoplay_label": "自动播放: ",
+ "preferences_continue_label": "默认自动播放下一个视频: ",
+ "preferences_continue_autoplay_label": "自动播放下一个视频: ",
+ "preferences_listen_label": "默认只听声音: ",
+ "preferences_local_label": "是否代理视频: ",
+ "preferences_speed_label": "默认速度: ",
+ "preferences_quality_label": "视频质量偏好: ",
+ "preferences_volume_label": "播放器音量: ",
+ "preferences_comments_label": "默认评论源: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "Default captions: ": "默认字幕语言: ",
+ "preferences_captions_label": "默认字幕语言: ",
"Fallback captions: ": "后备字幕语言: ",
- "Show related videos: ": "是否显示相关视频: ",
- "Show annotations by default: ": "是否默认显示视频注释: ",
- "Automatically extend video description: ": "自动展开视频描述: ",
- "Interactive 360 degree videos: ": "互动式 360 度视频: ",
- "Visual preferences": "视觉选项",
- "Player style: ": "播放器样式: ",
+ "preferences_related_videos_label": "是否显示相关视频: ",
+ "preferences_annotations_label": "是否默认显示视频注释: ",
+ "preferences_extend_desc_label": "自动展开视频描述: ",
+ "preferences_vr_mode_label": "互动式 360 度视频: ",
+ "preferences_category_visual": "视觉选项",
+ "preferences_player_style_label": "播放器样式: ",
"Dark mode: ": "深色模式: ",
- "Theme: ": "主题: ",
+ "preferences_dark_mode_label": "主题: ",
"dark": "暗色",
"light": "亮色",
- "Thin mode: ": "窄页模式: ",
- "Miscellaneous preferences": "其他选项",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向 (回退到redirect.invidious.io): ",
- "Subscription preferences": "订阅设置",
- "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ",
+ "preferences_thin_mode_label": "窄页模式: ",
+ "preferences_category_misc": "其他选项",
+ "preferences_automatic_instance_redirect_label": "自动实例重定向 (回退到redirect.invidious.io): ",
+ "preferences_category_subscription": "订阅设置",
+ "preferences_annotations_subscribed_label": "默认情况下显示已订阅频道的注释: ",
"Redirect homepage to feed: ": "跳转主页到 feed: ",
- "Number of videos shown in feed: ": "Feed 中显示的视频数量: ",
- "Sort videos by: ": "视频排序方式: ",
+ "preferences_max_results_label": "Feed 中显示的视频数量: ",
+ "preferences_sort_label": "视频排序方式: ",
"published": "发布时间",
"published - reverse": "发布时间(反向)",
"alphabetically": "字母序",
@@ -101,12 +94,12 @@
"channel name - reverse": "频道名称(反向)",
"Only show latest video from channel: ": "只显示频道的最新视频: ",
"Only show latest unwatched video from channel: ": "只显示频道的最新未看过视频: ",
- "Only show unwatched: ": "只显示未看过的视频: ",
- "Only show notifications (if there are any): ": "只显示通知 (如果有的话): ",
+ "preferences_unseen_only_label": "只显示未看过的视频: ",
+ "preferences_notifications_only_label": "只显示通知 (如果有的话): ",
"Enable web notifications": "启用浏览器通知",
"`x` uploaded a video": "`x` 上传了视频",
"`x` is live": "`x` 正在直播",
- "Data preferences": "数据选项",
+ "preferences_category_data": "数据选项",
"Clear watch history": "清除观看历史",
"Import/export data": "导入/导出数据",
"Change password": "更改密码",
@@ -114,10 +107,10 @@
"Manage tokens": "管理令牌",
"Watch history": "观看历史",
"Delete account": "删除账户",
- "Administrator preferences": "管理员选项",
- "Default homepage: ": "默认主页: ",
- "Feed menu: ": "Feed 菜单: ",
- "Show nickname on top: ": "在顶部显示昵称: ",
+ "preferences_category_admin": "管理员选项",
+ "preferences_default_home_label": "默认主页: ",
+ "preferences_feed_menu_label": "Feed 菜单: ",
+ "preferences_show_nick_label": "在顶部显示昵称: ",
"Top enabled: ": "是否启用“热门视频”页: ",
"CAPTCHA enabled: ": "是否启用验证码: ",
"Login enabled: ": "是否启用登录: ",
@@ -127,22 +120,12 @@
"Subscription manager": "订阅管理器",
"Token manager": "令牌管理器",
"Token": "令牌",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个订阅",
- "": "`x` 个订阅"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个令牌",
- "": "`x` 个令牌"
- },
+ "tokens_count_0": "{{count}} 个令牌",
"Import/export": "导入/导出",
"unsubscribe": "取消订阅",
"revoke": "吊销",
"Subscriptions": "订阅",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 条未读通知",
- "": "`x` 条未读通知"
- },
+ "subscriptions_unseen_notifs_count_0": "{{count}} 条未读通知",
"search": "搜索",
"Log out": "登出",
"Released under the AGPLv3 on Github.": "依据 AGPLv3 许可证发布于 Github。",
@@ -176,10 +159,6 @@
"Whitelisted regions: ": "白名单地区: ",
"Blacklisted regions: ": "黑名单地区: ",
"Shared `x`": "`x`发布",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放",
- "": "`x` 次观看"
- },
"Premieres in `x`": "首映于 `x` 后",
"Premieres `x`": "首映于 `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "你好!看起来你关闭了 JavaScript。点击这里阅读评论。注意它们加载的时间可能会稍长。",
@@ -213,16 +192,10 @@
"This channel does not exist.": "频道不存在。",
"Could not get channel info.": "无法获取频道信息。",
"Could not fetch comments": "无法获取评论",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条回复",
- "": "查看 `x` 条回复"
- },
+ "comments_view_x_replies_0": "查看 {{count}} 条回复",
"`x` ago": "`x` 前",
"Load more": "加载更多",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分",
- "": "`x` 分"
- },
+ "comments_points_count_0": "{{count}} 分",
"Could not create mix.": "无法创建合集。",
"Empty playlist": "空播放列表",
"Not a playlist.": "非播放列表。",
@@ -340,41 +313,20 @@
"Yiddish": "意第绪语",
"Yoruba": "约鲁巴语",
"Zulu": "祖鲁语",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年",
- "": "`x` 年"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月",
- "": "`x` 个月"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 周",
- "": "`x` 周"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
- "": "`x` 天"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小时",
- "": "`x` 小时"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分钟",
- "": "`x` 分钟"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒",
- "": "`x` 秒"
- },
+ "generic_count_years_0": "{{count}} 年",
+ "generic_count_months_0": "{{count}} 月",
+ "generic_count_weeks_0": "{{count}} 周",
+ "generic_count_days_0": "{{count}} 天",
+ "generic_count_hours_0": "{{count}} 小时",
+ "generic_count_minutes_0": "{{count}} 分钟",
+ "generic_count_seconds_0": "{{count}} 秒",
"Fallback comments: ": "后备评论: ",
"Popular": "热门频道",
"Search": "搜索",
"Top": "热门视频",
"About": "关于",
"Rating: ": "评分: ",
- "Language: ": "语言: ",
+ "preferences_locale_label": "语言: ",
"View as playlist": "作为播放列表查看",
"Default": "默认",
"Music": "音乐",
@@ -423,5 +375,51 @@
"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",
+ "short": "短(少于4分钟)",
+ "long": "长(多于 20 分钟)",
+ "footer_documentation": "文档",
+ "footer_source_code": "源代码",
+ "footer_modfied_source_code": "修改的源代码",
+ "adminprefs_modified_source_code_url_label": "更改的源代码仓库网址",
+ "footer_original_source_code": "原始源代码",
+ "footer_donate_page": "捐赠",
+ "preferences_region_label": "内容国家: ",
+ "preferences_quality_dash_label": "首选 DASH 视频分辨率: ",
+ "crash_page_you_found_a_bug": "你似乎找到了 Invidious 的一个 bug!",
+ "crash_page_before_reporting": "报告 bug 之前,请确保你已经:",
+ "crash_page_refresh": "试着 <a href=\"`x`\">刷新页面</a>",
+ "crash_page_switch_instance": "试着<a href=\"`x`\">使用另一个实例</a>",
+ "crash_page_read_the_faq": "阅读<a href=\"`x`\">常见问题</a>",
+ "crash_page_search_issue": "搜索过 <a href=\"`x`\">Github 上的现有 issue</a>",
+ "crash_page_report_issue": "如果以上这些都没用的话,请<a href=\"`x`\">在 Github 上新开一个 issue</a>(最好用英语撰写),并在你的消息中包含以下文本(不要翻译该文本):",
+ "videoinfo_invidious_embed_link": "嵌入链接",
+ "download_subtitles": "字幕 - `x` (.vtt)",
+ "preferences_quality_dash_option_360p": "360p",
+ "videoinfo_watch_on_youTube": "在 YouTube 上观看",
+ "videoinfo_youTube_embed_link": "嵌入的",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_worst": "最差",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_quality_option_medium": "中等",
+ "preferences_quality_option_small": "小",
+ "preferences_quality_dash_option_auto": "自动",
+ "preferences_quality_option_dash": "DASH (自适应画质)",
+ "preferences_quality_dash_option_best": "最佳",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "invidious": "Invidious",
+ "videoinfo_started_streaming_x_ago": "`x` 前开始播放",
+ "user_created_playlists": "`x` 创建了播放列表",
+ "user_saved_playlists": "`x` 保存了播放列表",
+ "Video unavailable": "视频不可用",
+ "purchased": "已购买",
+ "360": "360°",
+ "none": "无",
+ "preferences_save_player_pos_label": "保存播放位置: "
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 96e04594..8c9133c6 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -1,16 +1,9 @@
{
- "`x` subscribers": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者",
- "": "`x` 個訂閱者"
- },
- "`x` videos": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片",
- "": "`x` 部影片"
- },
- "`x` playlists": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放清單",
- "": "`x` 播放清單"
- },
+ "generic_views_count_0": "{{count}} 次檢視",
+ "generic_videos_count_0": "{{count}} 部影片",
+ "generic_playlists_count_0": "{{count}} 播放清單",
+ "generic_subscribers_count_0": "{{count}} 個訂閱者",
+ "generic_subscriptions_count_0": "{{count}} 個訂閱",
"LIVE": "直播",
"Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消訂閱",
@@ -60,39 +53,39 @@
"E-mail": "電子郵件",
"Google verification code": "Google 驗證碼",
"Preferences": "偏好設定",
- "Player preferences": "播放器偏好設定",
- "Always loop: ": "總是循環播放: ",
- "Autoplay: ": "自動播放: ",
- "Play next by default: ": "預設播放下一部: ",
- "Autoplay next video: ": "自動播放下一部影片: ",
- "Listen by default: ": "預設聆聽: ",
- "Proxy videos: ": "代理影片: ",
- "Default speed: ": "預設速度: ",
- "Preferred video quality: ": "偏好的影片畫質: ",
- "Player volume: ": "播放器音量: ",
- "Default comments: ": "預設留言: ",
+ "preferences_category_player": "播放器偏好設定",
+ "preferences_video_loop_label": "總是循環播放: ",
+ "preferences_autoplay_label": "自動播放: ",
+ "preferences_continue_label": "預設播放下一部: ",
+ "preferences_continue_autoplay_label": "自動播放下一部影片: ",
+ "preferences_listen_label": "預設聆聽: ",
+ "preferences_local_label": "代理影片: ",
+ "preferences_speed_label": "預設速度: ",
+ "preferences_quality_label": "偏好的影片畫質: ",
+ "preferences_volume_label": "播放器音量: ",
+ "preferences_comments_label": "預設留言: ",
"youtube": "YouTube",
- "reddit": "reddit",
- "Default captions: ": "預設字幕: ",
+ "reddit": "Reddit",
+ "preferences_captions_label": "預設字幕: ",
"Fallback captions: ": "汰退字幕: ",
- "Show related videos: ": "顯示相關的影片: ",
- "Show annotations by default: ": "預設顯示註釋: ",
- "Automatically extend video description: ": "自動展開影片描述: ",
- "Interactive 360 degree videos: ": "互動式 360 度影片: ",
- "Visual preferences": "視覺偏好設定",
- "Player style: ": "播放器樣式: ",
+ "preferences_related_videos_label": "顯示相關的影片: ",
+ "preferences_annotations_label": "預設顯示註釋: ",
+ "preferences_extend_desc_label": "自動展開影片描述: ",
+ "preferences_vr_mode_label": "互動式 360 度影片: ",
+ "preferences_category_visual": "視覺偏好設定",
+ "preferences_player_style_label": "播放器樣式: ",
"Dark mode: ": "深色模式: ",
- "Theme: ": "佈景主題: ",
+ "preferences_dark_mode_label": "佈景主題: ",
"dark": "深色",
"light": "淺色",
- "Thin mode: ": "精簡模式: ",
- "Miscellaneous preferences": "其他偏好設定",
- "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動站台重新導向(汰退至 redirect.invidious.io): ",
- "Subscription preferences": "訂閱偏好設定",
- "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ",
+ "preferences_thin_mode_label": "精簡模式: ",
+ "preferences_category_misc": "其他偏好設定",
+ "preferences_automatic_instance_redirect_label": "自動站台重新導向(汰退至 redirect.invidious.io): ",
+ "preferences_category_subscription": "訂閱偏好設定",
+ "preferences_annotations_subscribed_label": "預設為已訂閱的頻道顯示註釋: ",
"Redirect homepage to feed: ": "重新導向首頁至 feed: ",
- "Number of videos shown in feed: ": "顯示在 feed 中的影片數量: ",
- "Sort videos by: ": "以此種方式排序影片: ",
+ "preferences_max_results_label": "顯示在 feed 中的影片數量: ",
+ "preferences_sort_label": "以此種方式排序影片: ",
"published": "已發佈",
"published - reverse": "已發佈 - 反向",
"alphabetically": "字母",
@@ -101,12 +94,12 @@
"channel name - reverse": "頻道名稱 - 反向",
"Only show latest video from channel: ": "僅顯示從頻道而來的最新影片: ",
"Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片: ",
- "Only show unwatched: ": "僅顯示未觀看的: ",
- "Only show notifications (if there are any): ": "僅顯示通知(如果有的話): ",
+ "preferences_unseen_only_label": "僅顯示未觀看的: ",
+ "preferences_notifications_only_label": "僅顯示通知(如果有的話): ",
"Enable web notifications": "啟用網路通知",
"`x` uploaded a video": "`x` 上傳了一部影片",
"`x` is live": "`x` 正在直播",
- "Data preferences": "資料偏好設定",
+ "preferences_category_data": "資料偏好設定",
"Clear watch history": "清除觀看歷史",
"Import/export data": "匯入/匯出資料",
"Change password": "變更密碼",
@@ -114,10 +107,10 @@
"Manage tokens": "管理 tokens",
"Watch history": "觀看歷史",
"Delete account": "刪除帳號",
- "Administrator preferences": "管理員偏好設定",
- "Default homepage: ": "預設首頁: ",
- "Feed menu: ": "Feed 選單: ",
- "Show nickname on top: ": "在頂部顯示暱稱: ",
+ "preferences_category_admin": "管理員偏好設定",
+ "preferences_default_home_label": "預設首頁: ",
+ "preferences_feed_menu_label": "Feed 選單: ",
+ "preferences_show_nick_label": "在頂部顯示暱稱: ",
"Top enabled: ": "頂部啟用: ",
"CAPTCHA enabled: ": "CAPTCHA 啟用: ",
"Login enabled: ": "啟用登入: ",
@@ -127,22 +120,12 @@
"Subscription manager": "訂閱管理員",
"Token manager": "Token 管理員",
"Token": "Token",
- "`x` subscriptions": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱",
- "": "`x` 個訂閱"
- },
- "`x` tokens": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
- "": "`x` 個存取金鑰"
- },
+ "tokens_count_0": "{{count}} 個存取金鑰",
"Import/export": "匯入/匯出",
"unsubscribe": "取消訂閱",
"revoke": "撤銷",
"Subscriptions": "訂閱",
- "`x` unseen notifications": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知",
- "": "`x` 個未讀的通知"
- },
+ "subscriptions_unseen_notifs_count_0": "{{count}} 個未讀的通知",
"search": "搜尋",
"Log out": "登出",
"Released under the AGPLv3 on Github.": "在 GitHub 上以 AGPLv3 釋出。",
@@ -176,10 +159,6 @@
"Whitelisted regions: ": "白名單區域: ",
"Blacklisted regions: ": "黑名單區域: ",
"Shared `x`": "`x` 發佈",
- "`x` views": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視",
- "": "`x` 次檢視"
- },
"Premieres in `x`": "首映於 `x`",
"Premieres `x`": "首映於 `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "嗨!看來您將 JavaScript 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。",
@@ -213,16 +192,10 @@
"This channel does not exist.": "此頻道不存在。",
"Could not get channel info.": "無法取得頻道資訊。",
"Could not fetch comments": "無法擷取留言",
- "View `x` replies": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆",
- "": "檢視 `x` 則回覆"
- },
+ "comments_view_x_replies_0": "檢視 {{count}} 則回覆",
"`x` ago": "`x` 以前",
"Load more": "載入更多",
- "`x` points": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點",
- "": "`x` 點"
- },
+ "comments_points_count_0": "{{count}} 點",
"Could not create mix.": "無法建立混合。",
"Empty playlist": "空的播放清單",
"Not a playlist.": "不是播放清單。",
@@ -340,41 +313,20 @@
"Yiddish": "意第緒語",
"Yoruba": "約魯巴語",
"Zulu": "祖魯語",
- "`x` years": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年",
- "": "`x` 年"
- },
- "`x` months": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月",
- "": "`x` 月"
- },
- "`x` weeks": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週",
- "": "`x` 週"
- },
- "`x` days": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
- "": "`x` 天"
- },
- "`x` hours": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時",
- "": "`x` 小時"
- },
- "`x` minutes": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
- "": "`x` 分鐘"
- },
- "`x` seconds": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒",
- "": "`x` 秒"
- },
+ "generic_count_years_0": "{{count}} 年",
+ "generic_count_months_0": "{{count}} 月",
+ "generic_count_weeks_0": "{{count}} 週",
+ "generic_count_days_0": "{{count}} 天",
+ "generic_count_hours_0": "{{count}} 小時",
+ "generic_count_minutes_0": "{{count}} 分鐘",
+ "generic_count_seconds_0": "{{count}} 秒",
"Fallback comments: ": "汰退留言: ",
"Popular": "熱門頻道",
"Search": "搜尋",
"Top": "熱門影片",
"About": "關於",
"Rating: ": "評分: ",
- "Language: ": "語言: ",
+ "preferences_locale_label": "語言: ",
"View as playlist": "以播放清單檢視",
"Default": "預設值",
"Music": "音樂",
@@ -423,5 +375,51 @@
"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",
+ "short": "短(小於4分鐘)",
+ "long": "長(多於20分鐘)",
+ "footer_documentation": "文件",
+ "footer_source_code": "原始碼",
+ "footer_original_source_code": "原本的原始碼",
+ "footer_modfied_source_code": "修改後的原始碼",
+ "adminprefs_modified_source_code_url_label": "修改後的原始碼倉庫 URL",
+ "footer_donate_page": "捐款",
+ "preferences_region_label": "內容國家: ",
+ "preferences_quality_dash_label": "偏好的 DASH 影片品質: ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_dash_option_worst": "最差",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "purchased": "已購買",
+ "360": "360°",
+ "none": "無",
+ "videoinfo_started_streaming_x_ago": "`x` 前開始串流",
+ "videoinfo_watch_on_youTube": "在 YouTube 上觀看",
+ "videoinfo_youTube_embed_link": "嵌入",
+ "videoinfo_invidious_embed_link": "嵌入連結",
+ "download_subtitles": "字幕 - `x` (.vtt)",
+ "user_created_playlists": "`x` 已建立的播放清單",
+ "user_saved_playlists": "`x` 已儲存的播放清單",
+ "Video unavailable": "影片不可用",
+ "preferences_quality_option_small": "小",
+ "preferences_quality_option_dash": "DASH(主動調整品質)",
+ "preferences_quality_option_medium": "中等",
+ "preferences_quality_dash_option_auto": "自動",
+ "preferences_quality_dash_option_best": "最佳",
+ "preferences_save_player_pos_label": "儲存播放位置: ",
+ "crash_page_you_found_a_bug": "看來您在 Invidious 中發現了一隻臭蟲!",
+ "crash_page_refresh": "嘗試過<a href=\"`x`\">重新整理頁面</a>",
+ "crash_page_switch_instance": "嘗試<a href=\"`x`\">使用其他站台</a>",
+ "crash_page_read_the_faq": "閱讀<a href=\"`x`\">常見問題解答 (FAQ)</a>",
+ "crash_page_search_issue": "搜尋 <a href=\"`x`\">GitHub 上既有的問題</a>",
+ "crash_page_report_issue": "若以上的動作都沒有幫到忙,請<a href=\"`x`\">在 GitHub 上開啟新的議題</a>(請盡量使用英文)並在您的訊息中包含以下文字(不要翻譯文字):",
+ "crash_page_before_reporting": "在回報臭蟲之前,請確保您有:"
}
diff --git a/shard.lock b/shard.lock
index 21ea9919..be4333c1 100644
--- a/shard.lock
+++ b/shard.lock
@@ -40,7 +40,14 @@ shards:
git: https://github.com/luislavena/radix.git
version: 0.4.1
+ spectator:
+ git: https://github.com/icy-arctic-fox/spectator.git
+ version: 0.10.4
+
sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0
+ ameba:
+ git: https://github.com/crystal-ameba/ameba.git
+ version: 0.14.3
diff --git a/shard.yml b/shard.yml
index b32054e6..bf382ec3 100644
--- a/shard.yml
+++ b/shard.yml
@@ -29,6 +29,14 @@ dependencies:
github: athena-framework/negotiation
version: ~> 0.1.1
+development_dependencies:
+ spectator:
+ github: icy-arctic-fox/spectator
+ version: ~> 0.10.4
+ ameba:
+ github: crystal-ameba/ameba
+ version: ~> 0.14.3
+
crystal: ">= 1.0.0, < 2.0.0"
license: AGPLv3
diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr
deleted file mode 100644
index ada5b28f..00000000
--- a/spec/helpers_spec.cr
+++ /dev/null
@@ -1,141 +0,0 @@
-require "kemal"
-require "openssl/hmac"
-require "pg"
-require "protodec/utils"
-require "spec"
-require "yaml"
-require "../src/invidious/helpers/*"
-require "../src/invidious/channels/*"
-require "../src/invidious/comments"
-require "../src/invidious/playlists"
-require "../src/invidious/search"
-require "../src/invidious/trending"
-require "../src/invidious/users"
-
-CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
-
-describe "Helper" do
- describe "#produce_channel_videos_url" do
- it "correctly produces url for requesting page `x` of a channel's videos" do
- produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw").should eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en")
-
- produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular").should eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en")
-
- produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20).should eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en")
-
- produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular").should eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en")
- end
- end
-
- describe "#produce_channel_search_continuation" do
- it "correctly produces token for searching a specific channel" do
- produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100).should eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
-
- produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0).should eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
- end
- end
-
- describe "#produce_channel_playlists_url" do
- it "correctly produces a /browse_ajax URL with the given UCID and cursor" do
- produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW").should eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en")
- end
- end
-
- describe "#produce_playlist_continuation" do
- it "correctly produces ctoken for requesting index `x` of a playlist" do
- produce_playlist_continuation("UUCla9fZca4I7KagBtgRGnOw", 100).should eq("4qmFsgJNEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhhVVUNsYTlmWmNhNEk3S2FnQnRnUkduT3c%3D")
-
- produce_playlist_continuation("UCCla9fZca4I7KagBtgRGnOw", 200).should eq("4qmFsgJLEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoSQ0FKNkIxQlVPa05OWjBJJTNEmgIYVVVDbGE5ZlpjYTRJN0thZ0J0Z1JHbk93")
-
- produce_playlist_continuation("PL55713C70BA91BD6E", 100).should eq("4qmFsgJBEhRWTFBMNTU3MTNDNzBCQTkxQkQ2RRoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhJQTDU1NzEzQzcwQkE5MUJENkU%3D")
- end
- end
-
- describe "#produce_search_params" do
- it "correctly produces token for searching with specified filters" do
- produce_search_params.should eq("CAASAhABSAA%3D")
-
- produce_search_params(sort: "upload_date", content_type: "video").should eq("CAISAhABSAA%3D")
-
- produce_search_params(content_type: "playlist").should eq("CAASAhADSAA%3D")
-
- produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"]).should eq("CAISCxABIAEwAUgByAEBSAA%3D")
-
- produce_search_params(content_type: "channel").should eq("CAASAhACSAA%3D")
- end
- end
-
- describe "#produce_comment_continuation" do
- it "correctly produces a continuation token for comments" do
- produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA").should eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
-
- produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI").should eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
-
- produce_comment_continuation("29-q7YnyUmY", "").should eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D")
-
- produce_comment_continuation("CvFH_6DNRCY", "").should eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D")
- end
- end
-
- describe "#produce_comment_reply_continuation" do
- it "correctly produces a continuation token for replies to a given comment" do
- produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugx1IP_wGVv3WtGWcdV4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd4MUlQX3dHVnYzV3RHV2NkVjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D")
-
- produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugza62y_TlmTu9o2RfF4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd6YTYyeV9UbG1UdTlvMlJmRjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D")
-
- produce_comment_reply_continuation("_cE8xSu6swE", "UC1AZY74-dGVPe6bfxFwwEMg", "UgyBUaRGHB9Jmt1dsUZ4AaABAg").should eq("EiYSC19jRTh4U3U2c3dFwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd5QlVhUkdIQjlKbXQxZHNVWjRBYUFCQWciAggAKhhVQzFBWlk3NC1kR1ZQZTZiZnhGd3dFTWcyC19jRTh4U3U2c3dFQAFICg%3D%3D")
- end
- end
-
- describe "#produce_channel_community_continuation" do
- it "correctly produces a continuation token for a channel community" do
- produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4").should eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")
- produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D").should eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE")
-
- produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D").should eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE")
- produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D").should eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D")
- end
- end
-
- describe "#extract_channel_community_cursor" do
- it "correctly extracts a community cursor from a given continuation" do
- extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D").should eq("Egljb21tdW5pdHk=")
- extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D").should eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=")
-
- extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D").should eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=")
- extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE").should eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==")
- end
- end
-
- describe "#extract_plid" do
- it "correctly extracts playlist ID from trending URL" do
- extract_plid("/feed/trending?bp=4gIuCggvbS8wNHJsZhIiUExGZ3F1TG5MNTlhbVBud2pLbmNhZUp3MDYzZlU1M3Q0cA%3D%3D").should eq("PLFgquLnL59amPnwjKncaeJw063fU53t4p")
- extract_plid("/feed/trending?bp=4gIvCgkvbS8wYnp2bTISIlBMaUN2Vkp6QnVwS2tDaFNnUDdGWFhDclo2aEp4NmtlTm0%3D").should eq("PLiCvVJzBupKkChSgP7FXXCrZ6hJx6keNm")
- extract_plid("/feed/trending?bp=4gIuCggvbS8wNWpoZxIiUEwzWlE1Q3BOdWxRbUtPUDNJekdsYWN0V1c4dklYX0hFUA%3D%3D").should eq("PL3ZQ5CpNulQmKOP3IzGlactWW8vIX_HEP")
- extract_plid("/feed/trending?bp=4gIuCggvbS8wMnZ4bhIiUEx6akZiYUZ6c21NUnFhdEJnVTdPeGNGTkZhQ2hqTkVERA%3D%3D").should eq("PLzjFbaFzsmMRqatBgU7OxcFNFaChjNEDD")
- end
- end
-
- describe "#sign_token" do
- it "correctly signs a given hash" do
- token = {
- "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
- "expires" => 1554680038,
- "scopes" => [
- ":notifications",
- ":subscriptions/*",
- "GET:tokens*",
- ],
- "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=",
- }
- sign_token("SECRET_KEY", token).should eq(token["signature"])
-
- token = {
- "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
- "scopes" => [":notifications", "POST:subscriptions/*"],
- "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=",
- }
- sign_token("SECRET_KEY", token).should eq(token["signature"])
- end
- end
-end
diff --git a/spec/i18next_plurals_spec.cr b/spec/i18next_plurals_spec.cr
new file mode 100644
index 00000000..ee9ff394
--- /dev/null
+++ b/spec/i18next_plurals_spec.cr
@@ -0,0 +1,214 @@
+require "spectator"
+require "../src/invidious/helpers/i18next.cr"
+
+Spectator.configure do |config|
+ config.fail_blank
+ config.randomize
+end
+
+def resolver
+ I18next::Plurals::RESOLVER
+end
+
+FORM_TESTS = {
+ "ach" => I18next::Plurals::PluralForms::Single_gt_one,
+ "ar" => I18next::Plurals::PluralForms::Special_Arabic,
+ "be" => I18next::Plurals::PluralForms::Dual_Slavic,
+ "cy" => I18next::Plurals::PluralForms::Special_Welsh,
+ "en" => I18next::Plurals::PluralForms::Single_not_one,
+ "fr" => I18next::Plurals::PluralForms::Single_gt_one,
+ "ga" => I18next::Plurals::PluralForms::Special_Irish,
+ "gd" => I18next::Plurals::PluralForms::Special_Scottish_Gaelic,
+ "he" => I18next::Plurals::PluralForms::Special_Hebrew,
+ "is" => I18next::Plurals::PluralForms::Special_Icelandic,
+ "jv" => I18next::Plurals::PluralForms::Special_Javanese,
+ "kw" => I18next::Plurals::PluralForms::Special_Cornish,
+ "lt" => I18next::Plurals::PluralForms::Special_Lithuanian,
+ "lv" => I18next::Plurals::PluralForms::Special_Latvian,
+ "mk" => I18next::Plurals::PluralForms::Special_Macedonian,
+ "mnk" => I18next::Plurals::PluralForms::Special_Mandinka,
+ "mt" => I18next::Plurals::PluralForms::Special_Maltese,
+ "or" => I18next::Plurals::PluralForms::Special_Odia,
+ "pl" => I18next::Plurals::PluralForms::Special_Polish_Kashubian,
+ "pt" => I18next::Plurals::PluralForms::Single_gt_one,
+ "pt-PT" => I18next::Plurals::PluralForms::Single_not_one,
+ "pt-BR" => I18next::Plurals::PluralForms::Single_gt_one,
+ "ro" => I18next::Plurals::PluralForms::Special_Romanian,
+ "su" => I18next::Plurals::PluralForms::None,
+ "sk" => I18next::Plurals::PluralForms::Special_Czech_Slovak,
+ "sl" => I18next::Plurals::PluralForms::Special_Slovenian,
+}
+
+SUFFIX_TESTS = {
+ "ach" => [
+ {num: 0, suffix: ""},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "ar" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_1"},
+ {num: 2, suffix: "_2"},
+ {num: 3, suffix: "_3"},
+ {num: 4, suffix: "_3"},
+ {num: 104, suffix: "_3"},
+ {num: 11, suffix: "_4"},
+ {num: 99, suffix: "_4"},
+ {num: 199, suffix: "_4"},
+ {num: 100, suffix: "_5"},
+ ],
+ "be" => [
+ {num: 0, suffix: "_2"},
+ {num: 1, suffix: "_0"},
+ {num: 5, suffix: "_2"},
+ ],
+ "cy" => [
+ {num: 0, suffix: "_2"},
+ {num: 1, suffix: "_0"},
+ {num: 3, suffix: "_2"},
+ {num: 8, suffix: "_3"},
+ ],
+ "en" => [
+ {num: 0, suffix: "_plural"},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "fr" => [
+ {num: 0, suffix: ""},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "ga" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 3, suffix: "_2"},
+ {num: 7, suffix: "_3"},
+ {num: 11, suffix: "_4"},
+ ],
+ "gd" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 3, suffix: "_2"},
+ {num: 20, suffix: "_3"},
+ ],
+ "he" => [
+ {num: 0, suffix: "_3"},
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 3, suffix: "_3"},
+ {num: 20, suffix: "_2"},
+ {num: 21, suffix: "_3"},
+ {num: 30, suffix: "_2"},
+ {num: 100, suffix: "_2"},
+ {num: 101, suffix: "_3"},
+ ],
+ "is" => [
+ {num: 1, suffix: ""},
+ {num: 2, suffix: "_plural"},
+ ],
+ "jv" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_1"},
+ ],
+ "kw" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 3, suffix: "_2"},
+ {num: 4, suffix: "_3"},
+ ],
+ "lt" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 10, suffix: "_2"},
+ ],
+ "lv" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 0, suffix: "_2"},
+ ],
+ "mk" => [
+ {num: 1, suffix: ""},
+ {num: 2, suffix: "_plural"},
+ {num: 0, suffix: "_plural"},
+ {num: 11, suffix: "_plural"},
+ {num: 21, suffix: ""},
+ {num: 31, suffix: ""},
+ {num: 311, suffix: "_plural"},
+ ],
+ "mnk" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_1"},
+ {num: 2, suffix: "_2"},
+ ],
+ "mt" => [
+ {num: 1, suffix: "_0"},
+ {num: 2, suffix: "_1"},
+ {num: 11, suffix: "_2"},
+ {num: 20, suffix: "_3"},
+ ],
+ "or" => [
+ {num: 2, suffix: "_1"},
+ {num: 1, suffix: "_0"},
+ ],
+ "pl" => [
+ {num: 0, suffix: "_2"},
+ {num: 1, suffix: "_0"},
+ {num: 5, suffix: "_2"},
+ ],
+ "pt" => [
+ {num: 0, suffix: ""},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "pt-PT" => [
+ {num: 0, suffix: "_plural"},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "pt-BR" => [
+ {num: 0, suffix: ""},
+ {num: 1, suffix: ""},
+ {num: 10, suffix: "_plural"},
+ ],
+ "ro" => [
+ {num: 0, suffix: "_1"},
+ {num: 1, suffix: "_0"},
+ {num: 20, suffix: "_2"},
+ ],
+ "su" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_0"},
+ {num: 10, suffix: "_0"},
+ ],
+ "sk" => [
+ {num: 0, suffix: "_2"},
+ {num: 1, suffix: "_0"},
+ {num: 5, suffix: "_2"},
+ ],
+ "sl" => [
+ {num: 5, suffix: "_0"},
+ {num: 1, suffix: "_1"},
+ {num: 2, suffix: "_2"},
+ {num: 3, suffix: "_3"},
+ ],
+}
+
+Spectator.describe "i18next_Plural_Resolver" do
+ describe "get_plural_form" do
+ sample FORM_TESTS do |locale, form|
+ it "returns the right plural form for locale '#{locale}'" do
+ expect(resolver.get_plural_form(locale)).to eq(form)
+ end
+ end
+ end
+
+ describe "get_suffix" do
+ sample SUFFIX_TESTS do |locale, tests|
+ it "returns the right suffix for locale '#{locale}'" do
+ tests.each do |d|
+ expect(resolver.get_suffix(locale, d[:num])).to eq(d[:suffix])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr
new file mode 100644
index 00000000..b2436989
--- /dev/null
+++ b/spec/invidious/helpers_spec.cr
@@ -0,0 +1,100 @@
+require "../spec_helper"
+
+CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
+
+Spectator.describe "Helper" do
+ describe "#produce_channel_videos_url" do
+ it "correctly produces url for requesting page `x` of a channel's videos" do
+ expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en")
+
+ expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en")
+
+ expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en")
+
+ expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en")
+ end
+ end
+
+ describe "#produce_channel_search_continuation" do
+ it "correctly produces token for searching a specific channel" do
+ expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
+
+ expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0)).to eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
+ end
+ end
+
+ describe "#produce_channel_playlists_url" do
+ it "correctly produces a /browse_ajax URL with the given UCID and cursor" do
+ expect(produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW")).to eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en")
+ end
+ end
+
+ describe "#produce_search_params" do
+ it "correctly produces token for searching with specified filters" do
+ expect(produce_search_params).to eq("CAASAhABSAA%3D")
+
+ expect(produce_search_params(sort: "upload_date", content_type: "video")).to eq("CAISAhABSAA%3D")
+
+ expect(produce_search_params(content_type: "playlist")).to eq("CAASAhADSAA%3D")
+
+ expect(produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"])).to eq("CAISCxABIAEwAUgByAEBSAA%3D")
+
+ expect(produce_search_params(content_type: "channel")).to eq("CAASAhACSAA%3D")
+ end
+ end
+
+ describe "#produce_comment_continuation" do
+ it "correctly produces a continuation token for comments" do
+ expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
+
+ expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D")
+
+ expect(produce_comment_continuation("29-q7YnyUmY", "")).to eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D")
+
+ expect(produce_comment_continuation("CvFH_6DNRCY", "")).to eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D")
+ end
+ end
+
+ describe "#produce_channel_community_continuation" do
+ it "correctly produces a continuation token for a channel community" do
+ expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")
+ expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE")
+
+ expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE")
+ expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D")).to eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D")
+ end
+ end
+
+ describe "#extract_channel_community_cursor" do
+ it "correctly extracts a community cursor from a given continuation" do
+ expect(extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")).to eq("Egljb21tdW5pdHk=")
+ expect(extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=")
+
+ expect(extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=")
+ expect(extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE")).to eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==")
+ end
+ end
+
+ describe "#sign_token" do
+ it "correctly signs a given hash" do
+ token = {
+ "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "expires" => 1554680038,
+ "scopes" => [
+ ":notifications",
+ ":subscriptions/*",
+ "GET:tokens*",
+ ],
+ "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=",
+ }
+ expect(sign_token("SECRET_KEY", token)).to eq(token["signature"])
+
+ token = {
+ "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "scopes" => [":notifications", "POST:subscriptions/*"],
+ "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=",
+ }
+ expect(sign_token("SECRET_KEY", token)).to eq(token["signature"])
+ end
+ end
+end
diff --git a/spec/invidious/user/imports_spec.cr b/spec/invidious/user/imports_spec.cr
new file mode 100644
index 00000000..5a682ec5
--- /dev/null
+++ b/spec/invidious/user/imports_spec.cr
@@ -0,0 +1,51 @@
+require "spectator"
+require "../../../src/invidious/user/imports"
+
+Spectator.configure do |config|
+ config.fail_blank
+ config.randomize
+end
+
+def csv_sample
+ return <<-CSV
+ Kanal-ID,Kanal-URL,Kanaltitel
+ UC0hHW5Y08ggq-9kbrGgWj0A,http://www.youtube.com/channel/UC0hHW5Y08ggq-9kbrGgWj0A,Matias Marolla
+ UC0vBXGSyV14uvJ4hECDOl0Q,http://www.youtube.com/channel/UC0vBXGSyV14uvJ4hECDOl0Q,Techquickie
+ UC1sELGmy5jp5fQUugmuYlXQ,http://www.youtube.com/channel/UC1sELGmy5jp5fQUugmuYlXQ,Minecraft
+ UC9kFnwdCRrX7oTjqKd6-tiQ,http://www.youtube.com/channel/UC9kFnwdCRrX7oTjqKd6-tiQ,LUMOX - Topic
+ UCBa659QWEk1AI4Tg--mrJ2A,http://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A,Tom Scott
+ UCGu6_XQ64rXPR6nuitMQE_A,http://www.youtube.com/channel/UCGu6_XQ64rXPR6nuitMQE_A,Callcenter Fun
+ UCGwu0nbY2wSkW8N-cghnLpA,http://www.youtube.com/channel/UCGwu0nbY2wSkW8N-cghnLpA,Jaiden Animations
+ UCQ0OvZ54pCFZwsKxbltg_tg,http://www.youtube.com/channel/UCQ0OvZ54pCFZwsKxbltg_tg,Methos
+ UCRE6itj4Jte4manQEu3Y7OA,http://www.youtube.com/channel/UCRE6itj4Jte4manQEu3Y7OA,Chipflake
+ UCRLc6zsv_d0OEBO8OOkz-DA,http://www.youtube.com/channel/UCRLc6zsv_d0OEBO8OOkz-DA,Kegy
+ UCSl5Uxu2LyaoAoMMGp6oTJA,http://www.youtube.com/channel/UCSl5Uxu2LyaoAoMMGp6oTJA,Atomic Shrimp
+ UCXuqSBlHAE6Xw-yeJA0Tunw,http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw,Linus Tech Tips
+ UCZ5XnGb-3t7jCkXdawN2tkA,http://www.youtube.com/channel/UCZ5XnGb-3t7jCkXdawN2tkA,Discord
+ CSV
+end
+
+Spectator.describe "Invidious::User::Imports" do
+ it "imports CSV" do
+ subscriptions = parse_subscription_export_csv(csv_sample)
+
+ expect(subscriptions).to be_an(Array(String))
+ expect(subscriptions.size).to eq(13)
+
+ expect(subscriptions).to contain_exactly(
+ "UC0hHW5Y08ggq-9kbrGgWj0A",
+ "UC0vBXGSyV14uvJ4hECDOl0Q",
+ "UC1sELGmy5jp5fQUugmuYlXQ",
+ "UC9kFnwdCRrX7oTjqKd6-tiQ",
+ "UCBa659QWEk1AI4Tg--mrJ2A",
+ "UCGu6_XQ64rXPR6nuitMQE_A",
+ "UCGwu0nbY2wSkW8N-cghnLpA",
+ "UCQ0OvZ54pCFZwsKxbltg_tg",
+ "UCRE6itj4Jte4manQEu3Y7OA",
+ "UCRLc6zsv_d0OEBO8OOkz-DA",
+ "UCSl5Uxu2LyaoAoMMGp6oTJA",
+ "UCXuqSBlHAE6Xw-yeJA0Tunw",
+ "UCZ5XnGb-3t7jCkXdawN2tkA",
+ ).in_order
+ end
+end
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
new file mode 100644
index 00000000..09320750
--- /dev/null
+++ b/spec/spec_helper.cr
@@ -0,0 +1,18 @@
+require "kemal"
+require "openssl/hmac"
+require "pg"
+require "protodec/utils"
+require "yaml"
+require "../src/invidious/helpers/*"
+require "../src/invidious/channels/*"
+require "../src/invidious/videos"
+require "../src/invidious/comments"
+require "../src/invidious/playlists"
+require "../src/invidious/search"
+require "../src/invidious/trending"
+require "spectator"
+
+Spectator.configure do |config|
+ config.fail_blank
+ config.randomize
+end
diff --git a/src/invidious.cr b/src/invidious.cr
index e29b73a8..8ba62503 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -20,15 +20,18 @@ require "kemal"
require "athena-negotiation"
require "openssl/hmac"
require "option_parser"
-require "pg"
require "sqlite3"
require "xml"
require "yaml"
require "compress/zip"
require "protodec/utils"
+
+require "./invidious/database/*"
require "./invidious/helpers/*"
+require "./invidious/yt_backend/*"
require "./invidious/*"
require "./invidious/channels/*"
+require "./invidious/user/*"
require "./invidious/routes/**"
require "./invidious/jobs/**"
@@ -67,7 +70,7 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
-YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0, use_quic: CONFIG.use_quic)
+YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic)
# CLI
Kemal.config.extra_options do |parser|
@@ -110,19 +113,19 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level)
# Check table integrity
if CONFIG.check_tables
- check_enum(PG_DB, "privacy", PlaylistPrivacy)
+ Invidious::Database.check_enum(PG_DB, "privacy", PlaylistPrivacy)
- check_table(PG_DB, "channels", InvidiousChannel)
- check_table(PG_DB, "channel_videos", ChannelVideo)
- check_table(PG_DB, "playlists", InvidiousPlaylist)
- check_table(PG_DB, "playlist_videos", PlaylistVideo)
- check_table(PG_DB, "nonces", Nonce)
- check_table(PG_DB, "session_ids", SessionId)
- check_table(PG_DB, "users", User)
- check_table(PG_DB, "videos", Video)
+ Invidious::Database.check_table(PG_DB, "channels", InvidiousChannel)
+ Invidious::Database.check_table(PG_DB, "channel_videos", ChannelVideo)
+ Invidious::Database.check_table(PG_DB, "playlists", InvidiousPlaylist)
+ Invidious::Database.check_table(PG_DB, "playlist_videos", PlaylistVideo)
+ Invidious::Database.check_table(PG_DB, "nonces", Nonce)
+ Invidious::Database.check_table(PG_DB, "session_ids", SessionId)
+ Invidious::Database.check_table(PG_DB, "users", User)
+ Invidious::Database.check_table(PG_DB, "videos", Video)
if CONFIG.cache_annotations
- check_table(PG_DB, "annotations", Annotation)
+ Invidious::Database.check_table(PG_DB, "annotations", Annotation)
end
end
@@ -165,10 +168,6 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end
-if CONFIG.captcha_key
- Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new
-end
-
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url)
@@ -260,8 +259,8 @@ before_all do |env|
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
- if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
- user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+ if email = Invidious::Database::SessionIDs.select_email(sid)
+ user = Invidious::Database::Users.select!(email: email)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
@@ -269,7 +268,7 @@ before_all do |env|
":subscription_ajax",
":token_ajax",
":watch_ajax",
- }, HMAC_KEY, PG_DB, 1.week)
+ }, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
@@ -283,7 +282,7 @@ before_all do |env|
headers["Cookie"] = env.request.headers["Cookie"]
begin
- user, sid = get_user(sid, headers, PG_DB, false)
+ user, sid = get_user(sid, headers, false)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
@@ -291,7 +290,7 @@ before_all do |env|
":subscription_ajax",
":token_ajax",
":watch_ajax",
- }, HMAC_KEY, PG_DB, 1.week)
+ }, HMAC_KEY, 1.week)
preferences = user.preferences
env.set "preferences", preferences
@@ -328,80 +327,97 @@ before_all do |env|
env.set "current_page", URI.encode_www_form(current_page)
end
-Invidious::Routing.get "/", Invidious::Routes::Misc, :home
-Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
-Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
-
-Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
-Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
-Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
-Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
-Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
-Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
-
-["", "/videos", "/playlists", "/community", "/about"].each do |path|
- # /c/LinusTechTips
- Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /user/linustechtips | Not always the same as /c/
- Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
- Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
- # /profile?user=linustechtips
- Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
-end
+{% unless flag?(:api_only) %}
+ Invidious::Routing.get "/", Invidious::Routes::Misc, :home
+ Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
+ Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
+
+ Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
+ Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
+ Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
+ Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
+ Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
+ Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
+
+ ["", "/videos", "/playlists", "/community", "/about"].each do |path|
+ # /c/LinusTechTips
+ Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /user/linustechtips | Not always the same as /c/
+ Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
+ Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
+ # /profile?user=linustechtips
+ Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
+ end
+
+ Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
+ Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched
+ Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
+ Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
+
+ Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
+ Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
+
+ Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
+ Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
+ Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
+ Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
+ Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
+ Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
+ Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
+ Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
+ Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
+ Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
+ Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
+
+ Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
+ Invidious::Routing.get "/results", Invidious::Routes::Search, :results
+ Invidious::Routing.get "/search", Invidious::Routes::Search, :search
+
+ Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
+ Invidious::Routing.post "/login", Invidious::Routes::Login, :login
+ Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
+
+ Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
+ Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
+ Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
+ Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
+ Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
+
+ # Feeds
+ Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
+ Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
+ Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
+ Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
+ Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
+ Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
+
+ # RSS Feeds
+ Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
+ Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
+ Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
+ Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
+
+ # Support push notifications via PubSubHubbub
+ Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
+ Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
+
+ Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify
+
+ Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription
+ Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager
+{% end %}
-Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
-Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
-Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
-
-Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
-Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
-
-Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
-Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
-Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
-Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
-Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
-Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
-Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
-Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
-Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
-Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
-Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
-
-Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
-Invidious::Routing.get "/results", Invidious::Routes::Search, :results
-Invidious::Routing.get "/search", Invidious::Routes::Search, :search
-
-Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
-Invidious::Routing.post "/login", Invidious::Routes::Login, :login
-Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
-
-Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
-Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
-Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
-
-# Feeds
-Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
-Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
-Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
-Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
-Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
-Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
-
-# RSS Feeds
-Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
-Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
-Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
-Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
-
-# Support push notifications via PubSubHubbub
-Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
-Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
+Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
+Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard
+Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard
+Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image
+Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image
+Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails
# API routes (macro)
define_v1_api_routes()
@@ -410,504 +426,8 @@ define_v1_api_routes()
define_api_manifest_routes()
define_video_playback_routes()
-# Users
-
-post "/watch_ajax" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env, "/feed/subscriptions")
-
- redirect = env.params.query["redirect"]?
- redirect ||= "true"
- redirect = redirect == "true"
-
- if !user
- if redirect
- next env.redirect referer
- else
- next error_json(403, "No such user")
- end
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- id = env.params.query["id"]?
- if !id
- env.response.status_code = 400
- next
- end
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- if redirect
- next error_template(400, ex)
- else
- next error_json(400, ex)
- end
- end
-
- if env.params.query["action_mark_watched"]?
- action = "action_mark_watched"
- elsif env.params.query["action_mark_unwatched"]?
- action = "action_mark_unwatched"
- else
- next env.redirect referer
- end
-
- case action
- when "action_mark_watched"
- if !user.watched.includes? id
- PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.email)
- end
- when "action_mark_unwatched"
- PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email)
- else
- next error_json(400, "Unsupported action #{action}")
- end
-
- if redirect
- env.redirect referer
- else
- env.response.content_type = "application/json"
- "{}"
- end
-end
-
-# /modify_notifications
-# will "ding" all subscriptions.
-# /modify_notifications?receive_all_updates=false&receive_no_updates=false
-# will "unding" all subscriptions.
-get "/modify_notifications" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env, "/")
-
- redirect = env.params.query["redirect"]?
- redirect ||= "false"
- redirect = redirect == "true"
-
- if !user
- if redirect
- next env.redirect referer
- else
- next error_json(403, "No such user")
- end
- end
-
- user = user.as(User)
-
- if !user.password
- channel_req = {} of String => String
-
- channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true"
- channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || ""
- channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true"
-
- channel_req.reject! { |k, v| v != "true" && v != "false" }
-
- headers = HTTP::Headers.new
- headers["Cookie"] = env.request.headers["Cookie"]
-
- html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
-
- cookies = HTTP::Cookies.from_client_headers(headers)
- html.cookies.each do |cookie|
- if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
- if cookies[cookie.name]?
- cookies[cookie.name] = cookie
- else
- cookies << cookie
- end
- end
- end
- headers = cookies.add_request_headers(headers)
-
- if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
- session_token = match["session_token"]
- else
- next env.redirect referer
- end
-
- headers["content-type"] = "application/x-www-form-urlencoded"
- channel_req["session_token"] = session_token
-
- subs = XML.parse_html(html.body)
- subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel|
- channel_id = channel.content.lstrip("/channel/").not_nil!
- channel_req["channel_id"] = channel_id
-
- YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req)
- end
- end
-
- if redirect
- env.redirect referer
- else
- env.response.content_type = "application/json"
- "{}"
- end
-end
-
-post "/subscription_ajax" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env, "/")
-
- redirect = env.params.query["redirect"]?
- redirect ||= "true"
- redirect = redirect == "true"
-
- if !user
- if redirect
- next env.redirect referer
- else
- next error_json(403, "No such user")
- end
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- if redirect
- next error_template(400, ex)
- else
- next error_json(400, ex)
- end
- end
-
- if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
- action = "action_create_subscription_to_channel"
- elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
- action = "action_remove_subscriptions"
- else
- next env.redirect referer
- end
-
- channel_id = env.params.query["c"]?
- channel_id ||= ""
-
- if !user.password
- # Sync subscriptions with YouTube
- subscribe_ajax(channel_id, action, env.request.headers)
- end
- email = user.email
-
- case action
- when "action_create_subscription_to_channel"
- if !user.subscriptions.includes? channel_id
- get_channel(channel_id, PG_DB, false, false)
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email)
- end
- when "action_remove_subscriptions"
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email)
- else
- next error_json(400, "Unsupported action #{action}")
- end
-
- if redirect
- env.redirect referer
- else
- env.response.content_type = "application/json"
- "{}"
- end
-end
-
-get "/subscription_manager" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect referer
- end
-
- user = user.as(User)
-
- if !user.password
- # Refresh account
- headers = HTTP::Headers.new
- headers["Cookie"] = env.request.headers["Cookie"]
-
- user, sid = get_user(sid, headers, PG_DB)
- end
-
- action_takeout = env.params.query["action_takeout"]?.try &.to_i?
- action_takeout ||= 0
- action_takeout = action_takeout == 1
-
- format = env.params.query["format"]?
- format ||= "rss"
-
- if user.subscriptions.empty?
- values = "'{}'"
- else
- values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}"
- end
-
- subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel)
- subscriptions.sort_by! { |channel| channel.author.downcase }
-
- if action_takeout
- if format == "json"
- env.response.content_type = "application/json"
- env.response.headers["content-disposition"] = "attachment"
- playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
-
- next JSON.build do |json|
- json.object do
- json.field "subscriptions", user.subscriptions
- json.field "watch_history", user.watched
- json.field "preferences", user.preferences
- json.field "playlists" do
- json.array do
- playlists.each do |playlist|
- json.object do
- json.field "title", playlist.title
- json.field "description", html_to_content(playlist.description_html)
- json.field "privacy", playlist.privacy.to_s
- json.field "videos" do
- json.array do
- PG_DB.query_all("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 500", playlist.id, playlist.index, as: String).each do |video_id|
- json.string video_id
- end
- end
- end
- end
- end
- end
- end
- end
- end
- else
- env.response.content_type = "application/xml"
- env.response.headers["content-disposition"] = "attachment"
- export = XML.build do |xml|
- xml.element("opml", version: "1.1") do
- xml.element("body") do
- if format == "newpipe"
- title = "YouTube Subscriptions"
- else
- title = "Invidious Subscriptions"
- end
-
- xml.element("outline", text: title, title: title) do
- subscriptions.each do |channel|
- if format == "newpipe"
- xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}"
- else
- xmlUrl = "#{HOST_URL}/feed/channel/#{channel.id}"
- end
-
- xml.element("outline", text: channel.author, title: channel.author,
- "type": "rss", xmlUrl: xmlUrl)
- end
- end
- end
- end
- end
-
- next export.gsub(%(<?xml version="1.0"?>\n), "")
- end
- end
-
- templated "subscription_manager"
-end
-
-get "/data_control" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- referer = get_referer(env)
-
- if !user
- next env.redirect referer
- end
-
- user = user.as(User)
-
- templated "data_control"
-end
-
-post "/data_control" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- referer = get_referer(env)
-
- if user
- user = user.as(User)
-
- # TODO: Find a way to prevent browser timeout
-
- HTTP::FormData.parse(env.request) do |part|
- body = part.body.gets_to_end
- next if body.empty?
-
- # TODO: Unify into single import based on content-type
- case part.name
- when "import_invidious"
- body = JSON.parse(body)
-
- if body["subscriptions"]?
- user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s }
- user.subscriptions.uniq!
-
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
-
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
- end
-
- if body["watch_history"]?
- user.watched += body["watch_history"].as_a.map { |a| a.as_s }
- user.watched.uniq!
- PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email)
- end
-
- if body["preferences"]?
- user.preferences = Preferences.from_json(body["preferences"].to_json)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", user.preferences.to_json, user.email)
- end
-
- if playlists = body["playlists"]?.try &.as_a?
- playlists.each do |item|
- title = item["title"]?.try &.as_s?.try &.delete("<>")
- description = item["description"]?.try &.as_s?.try &.delete("\r")
- privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
-
- next if !title
- next if !description
- next if !privacy
-
- playlist = create_playlist(PG_DB, title, privacy, user)
- PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id)
-
- videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
- raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
-
- video_id = video_id.try &.as_s?
- next if !video_id
-
- begin
- video = get_video(video_id, PG_DB)
- rescue ex
- next
- end
-
- playlist_video = PlaylistVideo.new({
- title: video.title,
- id: video.id,
- author: video.author,
- ucid: video.ucid,
- length_seconds: video.length_seconds,
- published: video.published,
- plid: playlist.id,
- live_now: video.live_now,
- index: Random::Secure.rand(0_i64..Int64::MAX),
- })
-
- video_array = playlist_video.to_a
- args = arg_array(video_array)
-
- PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
- PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist.id)
- end
- end
- end
- when "import_youtube"
- if body[0..4] == "<opml"
- subscriptions = XML.parse(body)
- user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
- channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
- end
- else
- subscriptions = JSON.parse(body)
- user.subscriptions += subscriptions.as_a.compact_map do |entry|
- entry["snippet"]["resourceId"]["channelId"].as_s
- end
- end
- user.subscriptions.uniq!
-
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
-
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
- when "import_freetube"
- user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
- md["channel_id"]
- end
- user.subscriptions.uniq!
-
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
-
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
- when "import_newpipe_subscriptions"
- body = JSON.parse(body)
- user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
- if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
- next match["channel"]
- elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
- response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
- html = XML.parse_html(response.body)
- ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
- next ucid if ucid
- end
-
- nil
- end
- user.subscriptions.uniq!
-
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
-
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
- when "import_newpipe"
- Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
- file.each_entry do |entry|
- if entry.filename == "newpipe.db"
- tempfile = File.tempfile(".db")
- File.write(tempfile.path, entry.io.gets_to_end)
- db = DB.open("sqlite3://" + tempfile.path)
-
- user.watched += db.query_all("SELECT url FROM streams", as: String).map { |url| url.lchop("https://www.youtube.com/watch?v=") }
- user.watched.uniq!
-
- PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email)
-
- user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") }
- user.subscriptions.uniq!
-
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
-
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
-
- db.close
- tempfile.delete
- end
- end
- end
- else nil # Ignore
- end
- end
- end
-
- env.redirect referer
-end
-
get "/change_password" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -919,13 +439,13 @@ get "/change_password" do |env|
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
templated "change_password"
end
post "/change_password" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -945,7 +465,7 @@ post "/change_password" do |env|
end
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
@@ -975,13 +495,13 @@ post "/change_password" do |env|
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
- PG_DB.exec("UPDATE users SET password = $1 WHERE email = $2", new_password.to_s, user.email)
+ Invidious::Database::Users.update_password(user, new_password.to_s)
env.redirect referer
end
get "/delete_account" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -993,13 +513,13 @@ get "/delete_account" do |env|
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
templated "delete_account"
end
post "/delete_account" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1014,14 +534,14 @@ post "/delete_account" do |env|
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
- PG_DB.exec("DELETE FROM users * WHERE email = $1", user.email)
- PG_DB.exec("DELETE FROM session_ids * WHERE email = $1", user.email)
+ Invidious::Database::Users.delete(user)
+ Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
@@ -1033,7 +553,7 @@ post "/delete_account" do |env|
end
get "/clear_watch_history" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1045,13 +565,13 @@ get "/clear_watch_history" do |env|
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
templated "clear_watch_history"
end
post "/clear_watch_history" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1066,17 +586,17 @@ post "/clear_watch_history" do |env|
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
- PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email)
+ Invidious::Database::Users.clear_watch_history(user)
env.redirect referer
end
get "/authorize_token" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1088,7 +608,7 @@ get "/authorize_token" do |env|
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
scopes = env.params.query["scopes"]?.try &.split(",")
scopes ||= [] of String
@@ -1104,7 +624,7 @@ get "/authorize_token" do |env|
end
post "/authorize_token" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1119,7 +639,7 @@ post "/authorize_token" do |env|
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
next error_template(400, ex)
end
@@ -1128,7 +648,7 @@ post "/authorize_token" do |env|
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
- access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB)
+ access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
@@ -1152,7 +672,7 @@ post "/authorize_token" do |env|
end
get "/token_manager" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1163,14 +683,13 @@ get "/token_manager" do |env|
end
user = user.as(User)
-
- tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time})
+ tokens = Invidious::Database::SessionIDs.select_all(user.email)
templated "token_manager"
end
post "/token_ajax" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -1193,7 +712,7 @@ post "/token_ajax" do |env|
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
next error_template(400, ex)
@@ -1213,7 +732,7 @@ post "/token_ajax" do |env|
case action
when .starts_with? "action_revoke_token"
- PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email)
+ Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
next error_json(400, "Unsupported action #{action}")
end
@@ -1230,7 +749,7 @@ end
{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route|
get route do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
# Appears to be a bug in routing, having several routes configured
# as `/a/:a`, `/b/:a`, `/c/:a` results in 404
@@ -1287,194 +806,6 @@ post "/api/v1/auth/notifications" do |env|
create_notification_stream(env, topics, connection_channel)
end
-get "/ggpht/*" do |env|
- url = env.request.path.lchop("/ggpht")
-
- headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-options "/sb/:authority/:id/:storyboard/:index" do |env|
- env.response.headers["Access-Control-Allow-Origin"] = "*"
- env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
- env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
-end
-
-get "/sb/:authority/:id/:storyboard/:index" do |env|
- authority = env.params.url["authority"]
- id = env.params.url["id"]
- storyboard = env.params.url["storyboard"]
- index = env.params.url["index"]
-
- url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
-
- headers = HTTP::Headers.new
-
- headers[":authority"] = "#{authority}.ytimg.com"
-
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Connection"] = "close"
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/s_p/:id/:name" do |env|
- id = env.params.url["id"]
- name = env.params.url["name"]
-
- url = env.request.resource
-
- headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/yts/img/:name" do |env|
- headers = HTTP::Headers.new
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(env.request.resource, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
-get "/vi/:id/:name" do |env|
- id = env.params.url["id"]
- name = env.params.url["name"]
-
- headers = HTTP::Headers{":authority" => "i.ytimg.com"}
-
- if name == "maxres.jpg"
- build_thumbnails(id).each do |thumb|
- if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
- name = thumb[:url] + ".jpg"
- break
- end
- end
- end
- url = "/vi/#{id}/#{name}"
-
- REQUEST_HEADERS_WHITELIST.each do |header|
- if env.request.headers[header]?
- headers[header] = env.request.headers[header]
- end
- end
-
- begin
- YT_POOL.client &.get(url, headers) do |response|
- env.response.status_code = response.status_code
- response.headers.each do |key, value|
- if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
- env.response.headers[key] = value
- end
- end
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- if response.status_code >= 300 && response.status_code != 404
- env.response.headers.delete("Transfer-Encoding")
- break
- end
-
- proxy_file(response, env)
- end
- rescue ex
- end
-end
-
get "/Captcha" do |env|
headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = YT_POOL.client &.get(env.request.resource, headers)
@@ -1540,11 +871,11 @@ error 404 do |env|
end
error 500 do |env, ex|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
error_template(500, ex)
end
-static_headers do |response, filepath, filestat|
+static_headers do |response|
response.headers.add("Cache-Control", "max-age=2629800")
end
@@ -1563,4 +894,11 @@ Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
Kemal.config.app_name = "Invidious"
+
+# Use in kemal's production mode.
+# Users can also set the KEMAL_ENV environmental variable for this to be set automatically.
+{% if flag?(:release) || flag?(:production) %}
+ Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
+{% end %}
+
Kemal.run
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
index 628d5b6f..8cae7ae2 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -1,33 +1,26 @@
# TODO: Refactor into either SearchChannel or InvidiousChannel
-struct AboutChannel
- include DB::Serializable
-
- property ucid : String
- property author : String
- property auto_generated : Bool
- property author_url : String
- property author_thumbnail : String
- property banner : String?
- property description_html : String
- property total_views : Int64
- property sub_count : Int32
- property joined : Time
- property is_family_friendly : Bool
- property allowed_regions : Array(String)
- property related_channels : Array(AboutRelatedChannel)
- property tabs : Array(String)
-end
-
-struct AboutRelatedChannel
- include DB::Serializable
-
- property ucid : String
- property author : String
- property author_url : String
- property author_thumbnail : String
-end
-
-def get_about_info(ucid, locale)
+record AboutChannel,
+ ucid : String,
+ author : String,
+ auto_generated : Bool,
+ author_url : String,
+ author_thumbnail : String,
+ banner : String?,
+ description_html : String,
+ total_views : Int64,
+ sub_count : Int32,
+ joined : Time,
+ is_family_friendly : Bool,
+ allowed_regions : Array(String),
+ tabs : Array(String)
+
+record AboutRelatedChannel,
+ ucid : String,
+ author : String,
+ author_url : String,
+ author_thumbnail : String
+
+def get_about_info(ucid, locale) : AboutChannel
begin
# "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"}
initdata = YoutubeAPI.browse(browse_id: ucid, params: "EgVhYm91dA==")
@@ -59,12 +52,10 @@ def get_about_info(ucid, locale)
banner = banners.try &.[-1]?.try &.["url"].as_s?
description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s
- description_html = HTML.escape(description).gsub("\n", "<br>")
+ description_html = HTML.escape(description)
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s }
-
- related_channels = [] of AboutRelatedChannel
+ allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
@@ -81,42 +72,10 @@ def get_about_info(ucid, locale)
# end
description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || ""
- description_html = HTML.escape(description).gsub("\n", "<br>")
+ description_html = HTML.escape(description)
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s }
-
- related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"]
- .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]?
- .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node|
- renderer = node["miniChannelRenderer"]?
- related_id = renderer.try &.["channelId"]?.try &.as_s?
- related_id ||= ""
-
- related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s?
- related_title ||= ""
-
- related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]?
- .try &.["url"]?.try &.as_s?
- related_author_url ||= ""
-
- related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a?
- related_author_thumbnails ||= [] of JSON::Any
-
- related_author_thumbnail = ""
- if related_author_thumbnails.size > 0
- related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s?
- related_author_thumbnail ||= ""
- end
-
- AboutRelatedChannel.new({
- ucid: related_id,
- author: related_title,
- author_url: related_author_url,
- author_thumbnail: related_author_thumbnail,
- })
- end
- related_channels ||= [] of AboutRelatedChannel
+ allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
end
total_views = 0_i64
@@ -149,26 +108,50 @@ def get_about_info(ucid, locale)
end
end
end
- tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase }
+ tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase)
end
sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s?
.try { |text| short_text_to_number(text.split(" ")[0]) } || 0
- AboutChannel.new({
- ucid: ucid,
- author: author,
- auto_generated: auto_generated,
- author_url: author_url,
- author_thumbnail: author_thumbnail,
- banner: banner,
- description_html: description_html,
- total_views: total_views,
- sub_count: sub_count,
- joined: joined,
+ AboutChannel.new(
+ ucid: ucid,
+ author: author,
+ auto_generated: auto_generated,
+ author_url: author_url,
+ author_thumbnail: author_thumbnail,
+ banner: banner,
+ description_html: description_html,
+ total_views: total_views,
+ sub_count: sub_count,
+ joined: joined,
is_family_friendly: is_family_friendly,
- allowed_regions: allowed_regions,
- related_channels: related_channels,
- tabs: tabs,
- })
+ allowed_regions: allowed_regions,
+ tabs: tabs,
+ )
+end
+
+def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedChannel)
+ # params is {"2:string":"channels"} encoded
+ channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D")
+
+ tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any
+ tab = tabs.find { |tab| tab.dig?("tabRenderer", "title").try(&.as_s?) == "Channels" }
+ return [] of AboutRelatedChannel if tab.nil?
+
+ items = tab.dig?("tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "gridRenderer", "items").try(&.as_a?) || [] of JSON::Any
+
+ items.map do |item|
+ related_id = item.dig("gridChannelRenderer", "channelId").as_s
+ related_title = item.dig("gridChannelRenderer", "title", "simpleText").as_s
+ related_author_url = item.dig("gridChannelRenderer", "navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s
+ related_author_thumbnail = item.dig("gridChannelRenderer", "thumbnail", "thumbnails", -1, "url").as_s
+
+ AboutRelatedChannel.new(
+ ucid: related_id,
+ author: related_title,
+ author_url: related_author_url,
+ author_thumbnail: related_author_thumbnail,
+ )
+ end
end
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
index 70623cc0..155ec559 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -101,7 +101,7 @@ struct ChannelVideo
def to_tuple
{% begin %}
{
- {{*@type.instance_vars.map { |var| var.name }}}
+ {{*@type.instance_vars.map(&.name)}}
}
{% end %}
end
@@ -114,7 +114,7 @@ class ChannelRedirect < Exception
end
end
-def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
+def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_threads = 10)
finished_channel = Channel(String | Nil).new
spawn do
@@ -130,7 +130,7 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
active_threads += 1
spawn do
begin
- get_channel(ucid, db, refresh, pull_all_videos)
+ get_channel(ucid, refresh, pull_all_videos)
finished_channel.send(ucid)
rescue ex
finished_channel.send(nil)
@@ -151,28 +151,21 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
return final
end
-def get_channel(id, db, refresh = true, pull_all_videos = true)
- if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
+def get_channel(id, refresh = true, pull_all_videos = true)
+ if channel = Invidious::Database::Channels.select(id)
if refresh && Time.utc - channel.updated > 10.minutes
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
- channel_array = channel.to_a
- args = arg_array(channel_array)
-
- db.exec("INSERT INTO channels VALUES (#{args}) \
- ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array)
+ channel = fetch_channel(id, pull_all_videos: pull_all_videos)
+ Invidious::Database::Channels.insert(channel, update_on_conflict: true)
end
else
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
- channel_array = channel.to_a
- args = arg_array(channel_array)
-
- db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array)
+ channel = fetch_channel(id, pull_all_videos: pull_all_videos)
+ Invidious::Database::Channels.insert(channel)
end
return channel
end
-def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
+def fetch_channel(ucid, pull_all_videos = true, locale = nil)
LOGGER.debug("fetch_channel: #{ucid}")
LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}")
@@ -241,15 +234,11 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
# We don't include the 'premiere_timestamp' here because channel pages don't include them,
# meaning the above timestamp is always null
- was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
- ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
- updated = $4, ucid = $5, author = $6, length_seconds = $7, \
- live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
+ was_insert = Invidious::Database::ChannelVideos.insert(video)
if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
- db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid)
+ Invidious::Database::Users.add_notification(video)
else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end
@@ -284,13 +273,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
# so since they don't provide a published date here we can safely ignore them.
if Time.utc - video.published > 1.minute
- was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
- ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
- updated = $4, ucid = $5, author = $6, length_seconds = $7, \
- live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
-
- db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert
+ was_insert = Invidious::Database::ChannelVideos.insert(video)
+ Invidious::Database::Users.add_notification(video) if was_insert
end
end
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 97ab30ec..4701ecbd 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -158,7 +158,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
json.field "viewCount", view_count
- json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
+ json.field "viewCountText", translate_count(locale, "generic_views_count", view_count, NumberFormatting::Short)
end
when .has_key?("backstageImageRenderer")
attachment = attachment["backstageImageRenderer"]
@@ -242,7 +242,7 @@ def produce_channel_community_continuation(ucid, cursor)
},
}
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
@@ -255,11 +255,11 @@ def extract_channel_community_cursor(continuation)
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) }
- .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h }
+ .try(&.["80226972:0:embedded"]["3:1:base64"].as_h)
if object["53:2:embedded"]?.try &.["3:0:embedded"]?
object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"]
- .try { |i| i["2:0:base64"].as_h }
+ .try(&.["2:0:base64"].as_h)
.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i, padding: false) }
diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr
index 393b055e..d5628f6a 100644
--- a/src/invidious/channels/playlists.cr
+++ b/src/invidious/channels/playlists.cr
@@ -1,17 +1,17 @@
def fetch_channel_playlists(ucid, author, continuation, sort_by)
if continuation
response_json = YoutubeAPI.browse(continuation)
- continuationItems = response_json["onResponseReceivedActions"]?
+ continuation_items = response_json["onResponseReceivedActions"]?
.try &.[0]["appendContinuationItemsAction"]["continuationItems"]
- return [] of SearchItem, nil if !continuationItems
+ return [] of SearchItem, nil if !continuation_items
items = [] of SearchItem
- continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item|
+ continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item|
extract_item(item, author, ucid).try { |t| items << t }
}
- continuation = continuationItems.as_a.last["continuationItemRenderer"]?
+ continuation = continuation_items.as_a.last["continuationItemRenderer"]?
.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
else
url = "/channel/#{ucid}/playlists?flow=list&view=1"
@@ -84,7 +84,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
object["80226972:embedded"].delete("3:base64")
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index 2c43bf0b..48453bb7 100644
--- a/src/invidious/channels/videos.cr
+++ b/src/invidious/channels/videos.cr
@@ -49,7 +49,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
object["80226972:embedded"].delete("3:base64")
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index a5506b03..dda92440 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -60,8 +60,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
case cursor
when nil, ""
ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
- # when .starts_with? "Ug"
- # ctoken = produce_comment_reply_continuation(id, video.ucid, cursor)
when .starts_with? "ADSJ"
ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by)
else
@@ -72,10 +70,9 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
response = YoutubeAPI.next(continuation: ctoken, client_config: client_config)
contents = nil
- if response["onResponseReceivedEndpoints"]?
- onResponseReceivedEndpoints = response["onResponseReceivedEndpoints"]
+ if on_response_received_endpoints = response["onResponseReceivedEndpoints"]?
header = nil
- onResponseReceivedEndpoints.as_a.each do |item|
+ on_response_received_endpoints.as_a.each do |item|
if item["reloadContinuationItemsCommand"]?
case item["reloadContinuationItemsCommand"]["slot"]
when "RELOAD_CONTINUATION_SLOT_HEADER"
@@ -97,7 +94,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
contents = body["contents"]?
header = body["header"]?
if body["continuations"]?
- moreRepliesContinuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
+ # Removable? Doesn't seem like this is used.
+ more_replies_continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
end
else
raise InfoException.new("Could not fetch comments")
@@ -111,10 +109,10 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
end
end
- continuationItemRenderer = nil
+ continuation_item_renderer = nil
contents.as_a.reject! do |item|
if item["continuationItemRenderer"]?
- continuationItemRenderer = item["continuationItemRenderer"]
+ continuation_item_renderer = item["continuationItemRenderer"]
true
end
end
@@ -232,14 +230,14 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
end
end
- if continuationItemRenderer
- if continuationItemRenderer["continuationEndpoint"]?
- continuationEndpoint = continuationItemRenderer["continuationEndpoint"]
- elsif continuationItemRenderer["button"]?
- continuationEndpoint = continuationItemRenderer["button"]["buttonRenderer"]["command"]
+ if continuation_item_renderer
+ if continuation_item_renderer["continuationEndpoint"]?
+ continuation_endpoint = continuation_item_renderer["continuationEndpoint"]
+ elsif continuation_item_renderer["button"]?
+ continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"]
end
- if continuationEndpoint
- json.field "continuation", continuationEndpoint["continuationCommand"]["token"].as_s
+ if continuation_endpoint
+ json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s
end
end
end
@@ -270,18 +268,20 @@ def fetch_reddit_comments(id, sort_by = "confidence")
headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"}
# TODO: Use something like #479 for a static list of instances to use here
- query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)"
- search_results = client.get("/search.json?q=#{query}", headers)
+ query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"})
+ search_results = client.get("/search.json?#{query}", headers)
if search_results.status_code == 200
search_results = RedditThing.from_json(search_results.body)
# For videos that have more than one thread, choose the one with the highest score
- thread = search_results.data.as(RedditListing).children.sort_by { |child| child.data.as(RedditLink).score }[-1]
- thread = thread.data.as(RedditLink)
-
- result = client.get("/r/#{thread.subreddit}/comments/#{thread.id}.json?limit=100&sort=#{sort_by}", headers).body
- result = Array(RedditThing).from_json(result)
+ threads = search_results.data.as(RedditListing).children
+ thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink))
+ result = thread.try do |t|
+ body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body
+ Array(RedditThing).from_json(body)
+ end
+ result ||= [] of RedditThing
elsif search_results.status_code == 302
# Previously, if there was only one result then the API would redirect to that result.
# Now, it appears it will still return a listing so this section is likely unnecessary.
@@ -296,7 +296,8 @@ def fetch_reddit_comments(id, sort_by = "confidence")
client.close
- comments = result[1].data.as(RedditListing).children
+ comments = result[1]?.try(&.data.as(RedditListing).children)
+ comments ||= [] of RedditThing
return comments, thread
end
@@ -305,13 +306,19 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
root = comments["comments"].as_a
root.each do |child|
if child["replies"]?
+ replies_count_text = translate_count(locale,
+ "comments_view_x_replies",
+ child["replies"]["replyCount"].as_i64 || 0,
+ NumberFormatting::Separator
+ )
+
replies_html = <<-END_HTML
<div id="replies" class="pure-g">
<div class="pure-u-1-24"></div>
<div class="pure-u-23-24">
<p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
- data-onclick="get_youtube_replies" data-load-replies>#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a>
+ data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</a>
</p>
</div>
</div>
@@ -329,7 +336,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<div class="pure-g" style="width:100%">
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
- <img style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}">
+ <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}">
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
@@ -349,7 +356,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
- <img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}">
+ <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}">
</div>
</div>
END_HTML
@@ -410,7 +417,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
html << <<-END_HTML
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<div class="creator-heart">
- <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
+ <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
<div class="creator-heart-small-hearted">
<div class="icon ion-ios-heart creator-heart-small-container"></div>
</div>
@@ -473,7 +480,7 @@ def template_reddit_comments(root, locale)
<p>
<a href="javascript:void(0)" data-onclick="toggle_parent">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
- #{translate(locale, "`x` points", number_with_separator(child.score))}
+ #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)}
<span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
<a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
</p>
@@ -552,12 +559,12 @@ end
def parse_content(content : JSON::Any) : String
content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s ||
- content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || ""
+ content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s.gsub("\n", "<br>") } || ""
end
def content_to_comment_html(content)
comment_html = content.map do |run|
- text = HTML.escape(run["text"].as_s).gsub("\n", "<br>")
+ text = HTML.escape(run["text"].as_s)
if run["bold"]?
text = "<b>#{text}</b>"
@@ -575,7 +582,9 @@ def content_to_comment_html(content)
url = "/watch?v=#{url.request_target.lstrip('/')}"
elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com")
if url.path == "/redirect"
- url = HTTP::Params.parse(url.query.not_nil!)["q"]
+ # Sometimes, links can be corrupted (why?) so make sure to fallback
+ # nicely. See https://github.com/iv-org/invidious/issues/2682
+ url = HTTP::Params.parse(url.query.not_nil!)["q"]? || ""
else
url = url.request_target
end
@@ -638,42 +647,7 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
end
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
- return continuation
-end
-
-def produce_comment_reply_continuation(video_id, ucid, comment_id)
- object = {
- "2:embedded" => {
- "2:string" => video_id,
- "24:varint" => 1_i64,
- "25:varint" => 1_i64,
- "28:varint" => 1_i64,
- "36:embedded" => {
- "5:varint" => -1_i64,
- "8:varint" => 0_i64,
- },
- },
- "3:varint" => 6_i64,
- "6:embedded" => {
- "3:embedded" => {
- "2:string" => comment_id,
- "4:embedded" => {
- "1:varint" => 0_i64,
- },
- "5:string" => ucid,
- "6:string" => video_id,
- "8:varint" => 1_i64,
- "9:varint" => 10_i64,
- },
- },
- }
-
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
new file mode 100644
index 00000000..c4a8bf83
--- /dev/null
+++ b/src/invidious/config.cr
@@ -0,0 +1,192 @@
+struct DBConfig
+ include YAML::Serializable
+
+ property user : String
+ property password : String
+ property host : String
+ property port : Int32
+ property dbname : String
+end
+
+struct ConfigPreferences
+ include YAML::Serializable
+
+ property annotations : Bool = false
+ property annotations_subscribed : Bool = false
+ property autoplay : Bool = false
+ property captions : Array(String) = ["", "", ""]
+ property comments : Array(String) = ["youtube", ""]
+ property continue : Bool = false
+ property continue_autoplay : Bool = true
+ property dark_mode : String = ""
+ property latest_only : Bool = false
+ property listen : Bool = false
+ property local : Bool = false
+ property locale : String = "en-US"
+ property max_results : Int32 = 40
+ property notifications_only : Bool = false
+ property player_style : String = "invidious"
+ property quality : String = "hd720"
+ property quality_dash : String = "auto"
+ property default_home : String? = "Popular"
+ property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
+ property automatic_instance_redirect : Bool = false
+ property region : String = "US"
+ property related_videos : Bool = true
+ property sort : String = "published"
+ property speed : Float32 = 1.0_f32
+ property thin_mode : Bool = false
+ property unseen_only : Bool = false
+ property video_loop : Bool = false
+ property extend_desc : Bool = false
+ property volume : Int32 = 100
+ property vr_mode : Bool = true
+ property show_nick : Bool = true
+ property save_player_pos : Bool = false
+
+ def to_tuple
+ {% begin %}
+ {
+ {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}}
+ }
+ {% end %}
+ end
+end
+
+class Config
+ include YAML::Serializable
+
+ property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions)
+ property feed_threads : Int32 = 1 # Number of threads to use for updating feeds
+ property output : String = "STDOUT" # Log file path or STDOUT
+ property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
+ property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc)
+
+ @[YAML::Field(converter: Preferences::URIConverter)]
+ property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax
+ property decrypt_polling : Bool = true # Use polling to keep decryption function up to date
+ property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel
+ property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https://
+ property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
+ property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
+ property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
+ property popular_enabled : Bool = true
+ property captcha_enabled : Bool = true
+ property login_enabled : Bool = true
+ property registration_enabled : Bool = true
+ property statistics_enabled : Bool = false
+ property admins : Array(String) = [] of String
+ property external_port : Int32? = nil
+ property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
+ property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs
+ property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc.
+ property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
+ property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc.
+ property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
+ property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
+
+ # URL to the modified source code to be easily AGPL compliant
+ # Will display in the footer, next to the main source code link
+ property modified_source_code_url : String? = nil
+
+ @[YAML::Field(converter: Preferences::FamilyConverter)]
+ property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
+ property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument)
+ property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument)
+ property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
+ property use_quic : Bool = false # Use quic transport for youtube api
+
+ @[YAML::Field(converter: Preferences::StringToCookies)]
+ property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
+ property captcha_key : String? = nil # Key for Anti-Captcha
+ property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
+
+ def disabled?(option)
+ case disabled = CONFIG.disable_proxy
+ when Bool
+ return disabled
+ when Array
+ if disabled.includes? option
+ return true
+ else
+ return false
+ end
+ else
+ return false
+ end
+ end
+
+ def self.load
+ # Load config from file or YAML string env var
+ env_config_file = "INVIDIOUS_CONFIG_FILE"
+ env_config_yaml = "INVIDIOUS_CONFIG"
+
+ config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
+ config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
+
+ config = Config.from_yaml(config_yaml)
+
+ # Update config from env vars (upcased and prefixed with "INVIDIOUS_")
+ {% for ivar in Config.instance_vars %}
+ {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
+
+ if ENV.has_key?({{env_id}})
+ # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}})
+ env_value = ENV.fetch({{env_id}})
+ success = false
+
+ # Use YAML converter if specified
+ {% ann = ivar.annotation(::YAML::Field) %}
+ {% if ann && ann[:converter] %}
+ puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter)
+ config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
+ puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}})
+ success = true
+
+ # Use regular YAML parser otherwise
+ {% else %}
+ {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
+ # Sort types to avoid parsing nulls and numbers as strings
+ {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
+ {{ivar_types}}.each do |ivar_type|
+ if !success
+ begin
+ # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type})
+ config.{{ivar.id}} = ivar_type.from_yaml(env_value)
+ puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type}))
+ success = true
+ rescue
+ # nop
+ end
+ end
+ end
+ {% end %}
+
+ # Exit on fail
+ if !success
+ puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}})
+ exit(1)
+ end
+ end
+ {% end %}
+
+ # Build database_url from db.* if it's not set directly
+ if config.database_url.to_s.empty?
+ if db = config.db
+ config.database_url = URI.new(
+ scheme: "postgres",
+ user: db.user,
+ password: db.password,
+ host: db.host,
+ port: db.port,
+ path: db.dbname,
+ )
+ else
+ puts "Config : Either database_url or db.* is required"
+ exit(1)
+ end
+ end
+
+ return config
+ end
+end
diff --git a/src/invidious/database/annotations.cr b/src/invidious/database/annotations.cr
new file mode 100644
index 00000000..03749473
--- /dev/null
+++ b/src/invidious/database/annotations.cr
@@ -0,0 +1,24 @@
+require "./base.cr"
+
+module Invidious::Database::Annotations
+ extend self
+
+ def insert(id : String, annotations : String)
+ request = <<-SQL
+ INSERT INTO annotations
+ VALUES ($1, $2)
+ ON CONFLICT DO NOTHING
+ SQL
+
+ PG_DB.exec(request, id, annotations)
+ end
+
+ def select(id : String) : Annotation?
+ request = <<-SQL
+ SELECT * FROM annotations
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: Annotation)
+ end
+end
diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr
new file mode 100644
index 00000000..6e49ea1a
--- /dev/null
+++ b/src/invidious/database/base.cr
@@ -0,0 +1,110 @@
+require "pg"
+
+module Invidious::Database
+ extend self
+
+ def check_enum(db, enum_name, struct_type = nil)
+ return # TODO
+
+ if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool)
+ LOGGER.info("check_enum: CREATE TYPE #{enum_name}")
+
+ db.using_connection do |conn|
+ conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql"))
+ end
+ end
+ end
+
+ def check_table(db, table_name, struct_type = nil)
+ # Create table if it doesn't exist
+ begin
+ db.exec("SELECT * FROM #{table_name} LIMIT 0")
+ rescue ex
+ LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}")
+
+ db.using_connection do |conn|
+ conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
+ end
+ end
+
+ return if !struct_type
+
+ struct_array = struct_type.type_array
+ column_array = get_column_array(db, table_name)
+ column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/)
+ .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT")
+
+ return if !column_types
+
+ struct_array.each_with_index do |name, i|
+ if name != column_array[i]?
+ if !column_array[i]?
+ new_column = column_types.select(&.starts_with?(name))[0]
+ LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ next
+ end
+
+ # Column doesn't exist
+ if !column_array.includes? name
+ new_column = column_types.select(&.starts_with?(name))[0]
+ db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ end
+
+ # Column exists but in the wrong position, rotate
+ if struct_array.includes? column_array[i]
+ until name == column_array[i]
+ new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new")
+
+ # There's a column we didn't expect
+ if !new_column
+ LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
+ db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+
+ column_array = get_column_array(db, table_name)
+ next
+ end
+
+ LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+
+ LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
+ db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
+
+ LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+ db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+
+ LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
+ db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
+
+ column_array = get_column_array(db, table_name)
+ end
+ else
+ LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+ db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+ end
+ end
+ end
+
+ return if column_array.size <= struct_array.size
+
+ column_array.each do |column|
+ if !struct_array.includes? column
+ LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
+ db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
+ end
+ end
+ end
+
+ def get_column_array(db, table_name)
+ column_array = [] of String
+ db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs|
+ rs.column_count.times do |i|
+ column = rs.as(PG::ResultSet).field(i)
+ column_array << column.name
+ end
+ end
+
+ return column_array
+ end
+end
diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr
new file mode 100644
index 00000000..134cf59d
--- /dev/null
+++ b/src/invidious/database/channels.cr
@@ -0,0 +1,149 @@
+require "./base.cr"
+
+#
+# This module contains functions related to the "channels" table.
+#
+module Invidious::Database::Channels
+ extend self
+
+ # -------------------
+ # Insert / delete
+ # -------------------
+
+ def insert(channel : InvidiousChannel, update_on_conflict : Bool = false)
+ channel_array = channel.to_a
+
+ request = <<-SQL
+ INSERT INTO channels
+ VALUES (#{arg_array(channel_array)})
+ SQL
+
+ if update_on_conflict
+ request += <<-SQL
+ ON CONFLICT (id) DO UPDATE
+ SET author = $2, updated = $3
+ SQL
+ end
+
+ PG_DB.exec(request, args: channel_array)
+ end
+
+ # -------------------
+ # Update
+ # -------------------
+
+ def update_author(id : String, author : String)
+ request = <<-SQL
+ UPDATE channels
+ SET updated = $1, author = $2, deleted = false
+ WHERE id = $3
+ SQL
+
+ PG_DB.exec(request, Time.utc, author, id)
+ end
+
+ def update_mark_deleted(id : String)
+ request = <<-SQL
+ UPDATE channels
+ SET updated = $1, deleted = true
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, Time.utc, id)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(id : String) : InvidiousChannel?
+ request = <<-SQL
+ SELECT * FROM channels
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: InvidiousChannel)
+ end
+
+ def select(ids : Array(String)) : Array(InvidiousChannel)?
+ return [] of InvidiousChannel if ids.empty?
+ values = ids.map { |id| %(('#{id}')) }.join(",")
+
+ request = <<-SQL
+ SELECT * FROM channels
+ WHERE id = ANY(VALUES #{values})
+ SQL
+
+ return PG_DB.query_all(request, as: InvidiousChannel)
+ end
+end
+
+#
+# This module contains functions related to the "channel_videos" table.
+#
+module Invidious::Database::ChannelVideos
+ extend self
+
+ # -------------------
+ # Insert
+ # -------------------
+
+ # This function returns the status of the query (i.e: success?)
+ def insert(video : ChannelVideo, with_premiere_timestamp : Bool = false) : Bool
+ if with_premiere_timestamp
+ last_items = "premiere_timestamp = $9, views = $10"
+ else
+ last_items = "views = $10"
+ end
+
+ request = <<-SQL
+ INSERT INTO channel_videos
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (id) DO UPDATE
+ SET title = $2, published = $3, updated = $4, ucid = $5,
+ author = $6, length_seconds = $7, live_now = $8, #{last_items}
+ RETURNING (xmax=0) AS was_insert
+ SQL
+
+ return PG_DB.query_one(request, *video.to_tuple, as: Bool)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(ids : Array(String)) : Array(ChannelVideo)
+ return [] of ChannelVideo if ids.empty?
+
+ request = <<-SQL
+ SELECT * FROM channel_videos
+ WHERE id IN (#{arg_array(ids)})
+ ORDER BY published DESC
+ SQL
+
+ return PG_DB.query_all(request, args: ids, as: ChannelVideo)
+ end
+
+ def select_notfications(ucid : String, since : Time) : Array(ChannelVideo)
+ request = <<-SQL
+ SELECT * FROM channel_videos
+ WHERE ucid = $1 AND published > $2
+ ORDER BY published DESC
+ LIMIT 15
+ SQL
+
+ return PG_DB.query_all(request, ucid, since, as: ChannelVideo)
+ end
+
+ def select_popular_videos : Array(ChannelVideo)
+ request = <<-SQL
+ SELECT DISTINCT ON (ucid) *
+ FROM channel_videos
+ WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
+ GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
+ ORDER BY ucid, published DESC
+ SQL
+
+ PG_DB.query_all(request, as: ChannelVideo)
+ end
+end
diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr
new file mode 100644
index 00000000..469fcbd8
--- /dev/null
+++ b/src/invidious/database/nonces.cr
@@ -0,0 +1,46 @@
+require "./base.cr"
+
+module Invidious::Database::Nonces
+ extend self
+
+ # -------------------
+ # Insert
+ # -------------------
+
+ def insert(nonce : String, expire : Time)
+ request = <<-SQL
+ INSERT INTO nonces
+ VALUES ($1, $2)
+ ON CONFLICT DO NOTHING
+ SQL
+
+ PG_DB.exec(request, nonce, expire)
+ end
+
+ # -------------------
+ # Update
+ # -------------------
+
+ def update_set_expired(nonce : String)
+ request = <<-SQL
+ UPDATE nonces
+ SET expire = $1
+ WHERE nonce = $2
+ SQL
+
+ PG_DB.exec(request, Time.utc(1990, 1, 1), nonce)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(nonce : String) : Tuple(String, Time)?
+ request = <<-SQL
+ SELECT * FROM nonces
+ WHERE nonce = $1
+ SQL
+
+ return PG_DB.query_one?(request, nonce, as: {String, Time})
+ end
+end
diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr
new file mode 100644
index 00000000..7a5f61dc
--- /dev/null
+++ b/src/invidious/database/playlists.cr
@@ -0,0 +1,265 @@
+require "./base.cr"
+
+#
+# This module contains functions related to the "playlists" table.
+#
+module Invidious::Database::Playlists
+ extend self
+
+ # -------------------
+ # Insert / delete
+ # -------------------
+
+ def insert(playlist : InvidiousPlaylist)
+ playlist_array = playlist.to_a
+
+ request = <<-SQL
+ INSERT INTO playlists
+ VALUES (#{arg_array(playlist_array)})
+ SQL
+
+ PG_DB.exec(request, args: playlist_array)
+ end
+
+ # deletes the given playlist and connected playlist videos
+ def delete(id : String)
+ PlaylistVideos.delete_by_playlist(id)
+ request = <<-SQL
+ DELETE FROM playlists *
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id)
+ end
+
+ # -------------------
+ # Update
+ # -------------------
+
+ def update(id : String, title : String, privacy, description, updated)
+ request = <<-SQL
+ UPDATE playlists
+ SET title = $1, privacy = $2, description = $3, updated = $4
+ WHERE id = $5
+ SQL
+
+ PG_DB.exec(request, title, privacy, description, updated, id)
+ end
+
+ def update_description(id : String, description)
+ request = <<-SQL
+ UPDATE playlists
+ SET description = $1
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, description, id)
+ end
+
+ def update_subscription_time(id : String)
+ request = <<-SQL
+ UPDATE playlists
+ SET subscribed = $1
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, Time.utc, id)
+ end
+
+ def update_video_added(id : String, index : String | Int64)
+ request = <<-SQL
+ UPDATE playlists
+ SET index = array_append(index, $1),
+ video_count = cardinality(index) + 1,
+ updated = $2
+ WHERE id = $3
+ SQL
+
+ PG_DB.exec(request, index, Time.utc, id)
+ end
+
+ def update_video_removed(id : String, index : String | Int64)
+ request = <<-SQL
+ UPDATE playlists
+ SET index = array_remove(index, $1),
+ video_count = cardinality(index) - 1,
+ updated = $2
+ WHERE id = $3
+ SQL
+
+ PG_DB.exec(request, index, Time.utc, id)
+ end
+
+ # -------------------
+ # Salect
+ # -------------------
+
+ def select(*, id : String, raise_on_fail : Bool = false) : InvidiousPlaylist?
+ request = <<-SQL
+ SELECT * FROM playlists
+ WHERE id = $1
+ SQL
+
+ if raise_on_fail
+ return PG_DB.query_one(request, id, as: InvidiousPlaylist)
+ else
+ return PG_DB.query_one?(request, id, as: InvidiousPlaylist)
+ end
+ end
+
+ def select_all(*, author : String) : Array(InvidiousPlaylist)
+ request = <<-SQL
+ SELECT * FROM playlists
+ WHERE author = $1
+ SQL
+
+ return PG_DB.query_all(request, author, as: InvidiousPlaylist)
+ end
+
+ # -------------------
+ # Salect (filtered)
+ # -------------------
+
+ def select_like_iv(email : String) : Array(InvidiousPlaylist)
+ request = <<-SQL
+ SELECT * FROM playlists
+ WHERE author = $1 AND id LIKE 'IV%'
+ ORDER BY created
+ SQL
+
+ PG_DB.query_all(request, email, as: InvidiousPlaylist)
+ end
+
+ def select_not_like_iv(email : String) : Array(InvidiousPlaylist)
+ request = <<-SQL
+ SELECT * FROM playlists
+ WHERE author = $1 AND id NOT LIKE 'IV%'
+ ORDER BY created
+ SQL
+
+ PG_DB.query_all(request, email, as: InvidiousPlaylist)
+ end
+
+ def select_user_created_playlists(email : String) : Array({String, String})
+ request = <<-SQL
+ SELECT id,title FROM playlists
+ WHERE author = $1 AND id LIKE 'IV%'
+ SQL
+
+ PG_DB.query_all(request, email, as: {String, String})
+ end
+
+ # -------------------
+ # Misc checks
+ # -------------------
+
+ # Check if given playlist ID exists
+ def exists?(id : String) : Bool
+ request = <<-SQL
+ SELECT id FROM playlists
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: String).nil?
+ end
+
+ # Count how many playlist a user has created.
+ def count_owned_by(author : String) : Int64
+ request = <<-SQL
+ SELECT count(*) FROM playlists
+ WHERE author = $1
+ SQL
+
+ return PG_DB.query_one?(request, author, as: Int64) || 0_i64
+ end
+end
+
+#
+# This module contains functions related to the "playlist_videos" table.
+#
+module Invidious::Database::PlaylistVideos
+ extend self
+
+ private alias VideoIndex = Int64 | Array(Int64)
+
+ # -------------------
+ # Insert / Delete
+ # -------------------
+
+ def insert(video : PlaylistVideo)
+ video_array = video.to_a
+
+ request = <<-SQL
+ INSERT INTO playlist_videos
+ VALUES (#{arg_array(video_array)})
+ SQL
+
+ PG_DB.exec(request, args: video_array)
+ end
+
+ def delete(index)
+ request = <<-SQL
+ DELETE FROM playlist_videos *
+ WHERE index = $1
+ SQL
+
+ PG_DB.exec(request, index)
+ end
+
+ def delete_by_playlist(plid : String)
+ request = <<-SQL
+ DELETE FROM playlist_videos *
+ WHERE plid = $1
+ SQL
+
+ PG_DB.exec(request, plid)
+ end
+
+ # -------------------
+ # Salect
+ # -------------------
+
+ def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo)
+ request = <<-SQL
+ SELECT * FROM playlist_videos
+ WHERE plid = $1
+ ORDER BY array_position($2, index)
+ LIMIT $3
+ OFFSET $4
+ SQL
+
+ return PG_DB.query_all(request, plid, index, limit, offset, as: PlaylistVideo)
+ end
+
+ def select_index(plid : String, vid : String) : Int64?
+ request = <<-SQL
+ SELECT index FROM playlist_videos
+ WHERE plid = $1 AND id = $2
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, plid, vid, as: Int64)
+ end
+
+ def select_one_id(plid : String, index : VideoIndex) : String?
+ request = <<-SQL
+ SELECT id FROM playlist_videos
+ WHERE plid = $1
+ ORDER BY array_position($2, index)
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, plid, index, as: String)
+ end
+
+ def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String)
+ request = <<-SQL
+ SELECT id FROM playlist_videos
+ WHERE plid = $1
+ ORDER BY array_position($2, index)
+ LIMIT $3
+ SQL
+
+ return PG_DB.query_all(request, plid, index, limit, as: String)
+ end
+end
diff --git a/src/invidious/database/sessions.cr b/src/invidious/database/sessions.cr
new file mode 100644
index 00000000..d5f85dd6
--- /dev/null
+++ b/src/invidious/database/sessions.cr
@@ -0,0 +1,74 @@
+require "./base.cr"
+
+module Invidious::Database::SessionIDs
+ extend self
+
+ # -------------------
+ # Insert
+ # -------------------
+
+ def insert(sid : String, email : String, handle_conflicts : Bool = false)
+ request = <<-SQL
+ INSERT INTO session_ids
+ VALUES ($1, $2, $3)
+ SQL
+
+ request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts
+
+ PG_DB.exec(request, sid, email, Time.utc)
+ end
+
+ # -------------------
+ # Delete
+ # -------------------
+
+ def delete(*, sid : String)
+ request = <<-SQL
+ DELETE FROM session_ids *
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, sid)
+ end
+
+ def delete(*, email : String)
+ request = <<-SQL
+ DELETE FROM session_ids *
+ WHERE email = $1
+ SQL
+
+ PG_DB.exec(request, email)
+ end
+
+ def delete(*, sid : String, email : String)
+ request = <<-SQL
+ DELETE FROM session_ids *
+ WHERE id = $1 AND email = $2
+ SQL
+
+ PG_DB.exec(request, sid, email)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select_email(sid : String) : String?
+ request = <<-SQL
+ SELECT email FROM session_ids
+ WHERE id = $1
+ SQL
+
+ PG_DB.query_one?(request, sid, as: String)
+ end
+
+ def select_all(email : String) : Array({session: String, issued: Time})
+ request = <<-SQL
+ SELECT id, issued FROM session_ids
+ WHERE email = $1
+ ORDER BY issued DESC
+ SQL
+
+ PG_DB.query_all(request, email, as: {session: String, issued: Time})
+ end
+end
diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr
new file mode 100644
index 00000000..1df549e2
--- /dev/null
+++ b/src/invidious/database/statistics.cr
@@ -0,0 +1,49 @@
+require "./base.cr"
+
+module Invidious::Database::Statistics
+ extend self
+
+ # -------------------
+ # User stats
+ # -------------------
+
+ def count_users_total : Int64
+ request = <<-SQL
+ SELECT count(*) FROM users
+ SQL
+
+ PG_DB.query_one(request, as: Int64)
+ end
+
+ def count_users_active_1m : Int64
+ request = <<-SQL
+ SELECT count(*) FROM users
+ WHERE CURRENT_TIMESTAMP - updated < '6 months'
+ SQL
+
+ PG_DB.query_one(request, as: Int64)
+ end
+
+ def count_users_active_6m : Int64
+ request = <<-SQL
+ SELECT count(*) FROM users
+ WHERE CURRENT_TIMESTAMP - updated < '1 month'
+ SQL
+
+ PG_DB.query_one(request, as: Int64)
+ end
+
+ # -------------------
+ # Channel stats
+ # -------------------
+
+ def channel_last_update : Time?
+ request = <<-SQL
+ SELECT updated FROM channels
+ ORDER BY updated DESC
+ LIMIT 1
+ SQL
+
+ PG_DB.query_one?(request, as: Time)
+ end
+end
diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr
new file mode 100644
index 00000000..53724dbf
--- /dev/null
+++ b/src/invidious/database/users.cr
@@ -0,0 +1,218 @@
+require "./base.cr"
+
+module Invidious::Database::Users
+ extend self
+
+ # -------------------
+ # Insert / delete
+ # -------------------
+
+ def insert(user : User, update_on_conflict : Bool = false)
+ user_array = user.to_a
+ user_array[4] = user_array[4].to_json # User preferences
+
+ request = <<-SQL
+ INSERT INTO users
+ VALUES (#{arg_array(user_array)})
+ SQL
+
+ if update_on_conflict
+ request += <<-SQL
+ ON CONFLICT (email) DO UPDATE
+ SET updated = $1, subscriptions = $3
+ SQL
+ end
+
+ PG_DB.exec(request, args: user_array)
+ end
+
+ def delete(user : User)
+ request = <<-SQL
+ DELETE FROM users *
+ WHERE email = $1
+ SQL
+
+ PG_DB.exec(request, user.email)
+ end
+
+ # -------------------
+ # Update (history)
+ # -------------------
+
+ def update_watch_history(user : User)
+ request = <<-SQL
+ UPDATE users
+ SET watched = $1
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, user.watched, user.email)
+ end
+
+ def mark_watched(user : User, vid : String)
+ request = <<-SQL
+ UPDATE users
+ SET watched = array_append(watched, $1)
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, vid, user.email)
+ end
+
+ def mark_unwatched(user : User, vid : String)
+ request = <<-SQL
+ UPDATE users
+ SET watched = array_remove(watched, $1)
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, vid, user.email)
+ end
+
+ def clear_watch_history(user : User)
+ request = <<-SQL
+ UPDATE users
+ SET watched = '{}'
+ WHERE email = $1
+ SQL
+
+ PG_DB.exec(request, user.email)
+ end
+
+ # -------------------
+ # Update (channels)
+ # -------------------
+
+ def update_subscriptions(user : User)
+ request = <<-SQL
+ UPDATE users
+ SET feed_needs_update = true, subscriptions = $1
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, user.subscriptions, user.email)
+ end
+
+ def subscribe_channel(user : User, ucid : String)
+ request = <<-SQL
+ UPDATE users
+ SET feed_needs_update = true,
+ subscriptions = array_append(subscriptions,$1)
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, ucid, user.email)
+ end
+
+ def unsubscribe_channel(user : User, ucid : String)
+ request = <<-SQL
+ UPDATE users
+ SET feed_needs_update = true,
+ subscriptions = array_remove(subscriptions, $1)
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, ucid, user.email)
+ end
+
+ # -------------------
+ # Update (notifs)
+ # -------------------
+
+ def add_notification(video : ChannelVideo)
+ request = <<-SQL
+ UPDATE users
+ SET notifications = array_append(notifications, $1),
+ feed_needs_update = true
+ WHERE $2 = ANY(subscriptions)
+ SQL
+
+ PG_DB.exec(request, video.id, video.ucid)
+ end
+
+ def remove_notification(user : User, vid : String)
+ request = <<-SQL
+ UPDATE users
+ SET notifications = array_remove(notifications, $1)
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, vid, user.email)
+ end
+
+ def clear_notifications(user : User)
+ request = <<-SQL
+ UPDATE users
+ SET notifications = '{}', updated = $1
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, Time.utc, user.email)
+ end
+
+ # -------------------
+ # Update (misc)
+ # -------------------
+
+ def update_preferences(user : User)
+ request = <<-SQL
+ UPDATE users
+ SET preferences = $1
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, user.preferences.to_json, user.email)
+ end
+
+ def update_password(user : User, pass : String)
+ request = <<-SQL
+ UPDATE users
+ SET password = $1
+ WHERE email = $2
+ SQL
+
+ PG_DB.exec(request, user.email, pass)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(*, email : String) : User?
+ request = <<-SQL
+ SELECT * FROM users
+ WHERE email = $1
+ SQL
+
+ return PG_DB.query_one?(request, email, as: User)
+ end
+
+ # Same as select, but can raise an exception
+ def select!(*, email : String) : User
+ request = <<-SQL
+ SELECT * FROM users
+ WHERE email = $1
+ SQL
+
+ return PG_DB.query_one(request, email, as: User)
+ end
+
+ def select(*, token : String) : User?
+ request = <<-SQL
+ SELECT * FROM users
+ WHERE token = $1
+ SQL
+
+ return PG_DB.query_one?(request, token, as: User)
+ end
+
+ def select_notifications(user : User) : Array(String)
+ request = <<-SQL
+ SELECT notifications
+ FROM users
+ WHERE email = $1
+ SQL
+
+ return PG_DB.query_one(request, user.email, as: Array(String))
+ end
+end
diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr
new file mode 100644
index 00000000..e1fa01c3
--- /dev/null
+++ b/src/invidious/database/videos.cr
@@ -0,0 +1,43 @@
+require "./base.cr"
+
+module Invidious::Database::Videos
+ extend self
+
+ def insert(video : Video)
+ request = <<-SQL
+ INSERT INTO videos
+ VALUES ($1, $2, $3)
+ ON CONFLICT (id) DO NOTHING
+ SQL
+
+ PG_DB.exec(request, video.id, video.info.to_json, video.updated)
+ end
+
+ def delete(id)
+ request = <<-SQL
+ DELETE FROM videos *
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id)
+ end
+
+ def update(video : Video)
+ request = <<-SQL
+ UPDATE videos
+ SET (id, info, updated) = ($1, $2, $3)
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, video.id, video.info.to_json, video.updated)
+ end
+
+ def select(id : String) : Video?
+ request = <<-SQL
+ SELECT * FROM videos
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: Video)
+ end
+end
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
index e1d02563..26c38669 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -22,31 +22,62 @@ def github_details(summary : String, content : String)
return HTML.escape(details)
end
-def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception)
if exception.is_a?(InfoException)
return error_template_helper(env, locale, status_code, exception.message || "")
end
+
env.response.content_type = "text/html"
env.response.status_code = status_code
- issue_template = %(Title: `#{exception.message} (#{exception.class})`)
+
+ issue_title = "#{exception.message} (#{exception.class})"
+
+ issue_template = %(Title: `#{issue_title}`)
issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`)
issue_template += %(\nRoute: `#{env.request.resource}`)
issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`)
# issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json)
issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
+
+ # URLs for the error message below
+ url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md"
+ url_search_issues = "https://github.com/iv-org/invidious/issues"
+
+ url_switch = "https://redirect.invidious.io" + env.request.resource
+
+ url_new_issue = "https://github.com/iv-org/invidious/issues/new"
+ url_new_issue += "?labels=bug&template=bug_report.md&title="
+ url_new_issue += URI.encode_www_form("[Bug] " + issue_title)
+
error_message = <<-END_HTML
- Looks like you've found a bug in Invidious. Please open a new issue
- <a href="https://github.com/iv-org/invidious/issues">on GitHub</a>
- and include the following text in your message:
- <pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre>
+ <div class="error_message">
+ <h2>#{translate(locale, "crash_page_you_found_a_bug")}</h2>
+ <br/><br/>
+
+ <p><b>#{translate(locale, "crash_page_before_reporting")}</b></p>
+ <ul>
+ <li>#{translate(locale, "crash_page_refresh", env.request.resource)}</li>
+ <li>#{translate(locale, "crash_page_switch_instance", url_switch)}</li>
+ <li>#{translate(locale, "crash_page_read_the_faq", url_faq)}</li>
+ <li>#{translate(locale, "crash_page_search_issue", url_search_issues)}</li>
+ </ul>
+
+ <br/>
+ <p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p>
+
+ <!-- TODO: Add a "copy to clipboard" button -->
+ <pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre>
+ </div>
END_HTML
- next_steps = error_redirect_helper(env, locale)
+ # Don't show the usual "next steps" widget. The same options are
+ # proposed above the error message, just worded differently.
+ next_steps = ""
return templated "error"
end
-def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String)
env.response.content_type = "text/html"
env.response.status_code = status_code
error_message = translate(locale, message)
@@ -58,7 +89,7 @@ macro error_atom(*args)
error_atom_helper(env, locale, {{*args}})
end
-def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception)
if exception.is_a?(InfoException)
return error_atom_helper(env, locale, status_code, exception.message || "")
end
@@ -67,7 +98,7 @@ def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A
return "<error>#{exception.inspect_with_backtrace}</error>"
end
-def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String)
env.response.content_type = "application/atom+xml"
env.response.status_code = status_code
return "<error>#{message}</error>"
@@ -77,7 +108,7 @@ macro error_json(*args)
error_json_helper(env, locale, {{*args}})
end
-def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil)
+def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil)
if exception.is_a?(InfoException)
return error_json_helper(env, locale, status_code, exception.message || "", additional_fields)
end
@@ -90,11 +121,11 @@ def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A
return error_message.to_json
end
-def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception)
return error_json_helper(env, locale, status_code, exception, nil)
end
-def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil)
+def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil)
env.response.content_type = "application/json"
env.response.status_code = status_code
error_message = {"error" => message}
@@ -104,11 +135,11 @@ def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A
return error_message.to_json
end
-def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String)
error_json_helper(env, locale, status_code, message, nil)
end
-def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil)
+def error_redirect_helper(env : HTTP::Server::Context, locale : String?)
request_path = env.request.path
if request_path.starts_with?("/search") || request_path.starts_with?("/watch") ||
@@ -132,8 +163,6 @@ def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSO
</li>
</ul>
END_HTML
-
- return next_step_html
else
return ""
end
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index 045b6701..d140a858 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -97,18 +97,18 @@ class AuthHandler < Kemal::Handler
if token = env.request.headers["Authorization"]?
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
session = URI.decode_www_form(token["session"].as_s)
- scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil)
+ scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil)
- if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String)
- user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+ if email = Invidious::Database::SessionIDs.select_email(session)
+ user = Invidious::Database::Users.select!(email: email)
end
elsif sid = env.request.cookies["SID"]?.try &.value
if sid.starts_with? "v1:"
raise "Cannot use token as SID"
end
- if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
- user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+ if email = Invidious::Database::SessionIDs.select_email(sid)
+ user = Invidious::Database::Users.select!(email: email)
end
scopes = [":*"]
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index fb33df1c..c3b53339 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -22,193 +22,6 @@ struct Annotation
property annotations : String
end
-struct ConfigPreferences
- include YAML::Serializable
-
- property annotations : Bool = false
- property annotations_subscribed : Bool = false
- property autoplay : Bool = false
- property captions : Array(String) = ["", "", ""]
- property comments : Array(String) = ["youtube", ""]
- property continue : Bool = false
- property continue_autoplay : Bool = true
- property dark_mode : String = ""
- property latest_only : Bool = false
- property listen : Bool = false
- property local : Bool = false
- property locale : String = "en-US"
- property max_results : Int32 = 40
- property notifications_only : Bool = false
- property player_style : String = "invidious"
- property quality : String = "hd720"
- property quality_dash : String = "auto"
- property default_home : String? = "Popular"
- property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
- property automatic_instance_redirect : Bool = false
- property related_videos : Bool = true
- property sort : String = "published"
- property speed : Float32 = 1.0_f32
- property thin_mode : Bool = false
- property unseen_only : Bool = false
- property video_loop : Bool = false
- property extend_desc : Bool = false
- property volume : Int32 = 100
- property vr_mode : Bool = true
- property show_nick : Bool = true
-
- def to_tuple
- {% begin %}
- {
- {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}}
- }
- {% end %}
- end
-end
-
-class Config
- include YAML::Serializable
-
- property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions)
- property feed_threads : Int32 = 1 # Number of threads to use for updating feeds
- property output : String = "STDOUT" # Log file path or STDOUT
- property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
- property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc)
-
- @[YAML::Field(converter: Preferences::URIConverter)]
- property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax
- property decrypt_polling : Bool = true # Use polling to keep decryption function up to date
- property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel
- property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https://
- property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
- property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
- property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
- property popular_enabled : Bool = true
- property captcha_enabled : Bool = true
- property login_enabled : Bool = true
- property registration_enabled : Bool = true
- property statistics_enabled : Bool = false
- property admins : Array(String) = [] of String
- property external_port : Int32? = nil
- property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
- property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs
- property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc.
- property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
- property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc.
- property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
- property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
-
- @[YAML::Field(converter: Preferences::FamilyConverter)]
- property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
- property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument)
- property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument)
- property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
- property use_quic : Bool = true # Use quic transport for youtube api
-
- @[YAML::Field(converter: Preferences::StringToCookies)]
- property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
- property captcha_key : String? = nil # Key for Anti-Captcha
- property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
-
- def disabled?(option)
- case disabled = CONFIG.disable_proxy
- when Bool
- return disabled
- when Array
- if disabled.includes? option
- return true
- else
- return false
- end
- else
- return false
- end
- end
-
- def self.load
- # Load config from file or YAML string env var
- env_config_file = "INVIDIOUS_CONFIG_FILE"
- env_config_yaml = "INVIDIOUS_CONFIG"
-
- config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
- config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
-
- config = Config.from_yaml(config_yaml)
-
- # Update config from env vars (upcased and prefixed with "INVIDIOUS_")
- {% for ivar in Config.instance_vars %}
- {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
-
- if ENV.has_key?({{env_id}})
- # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}})
- env_value = ENV.fetch({{env_id}})
- success = false
-
- # Use YAML converter if specified
- {% ann = ivar.annotation(::YAML::Field) %}
- {% if ann && ann[:converter] %}
- puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter)
- config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
- puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}})
- success = true
-
- # Use regular YAML parser otherwise
- {% else %}
- {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
- # Sort types to avoid parsing nulls and numbers as strings
- {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
- {{ivar_types}}.each do |ivar_type|
- if !success
- begin
- # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type})
- config.{{ivar.id}} = ivar_type.from_yaml(env_value)
- puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type}))
- success = true
- rescue
- # nop
- end
- end
- end
- {% end %}
-
- # Exit on fail
- if !success
- puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}})
- exit(1)
- end
- end
- {% end %}
-
- # Build database_url from db.* if it's not set directly
- if config.database_url.to_s.empty?
- if db = config.db
- config.database_url = URI.new(
- scheme: "postgres",
- user: db.user,
- password: db.password,
- host: db.host,
- port: db.port,
- path: db.dbname,
- )
- else
- puts "Config : Either database_url or db.* is required"
- exit(1)
- end
- end
-
- return config
- end
-end
-
-struct DBConfig
- include YAML::Serializable
-
- property user : String
- property password : String
- property host : String
- property port : Int32
- property dbname : String
-end
-
def login_req(f_req)
data = {
# Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard
@@ -247,277 +60,7 @@ def html_to_content(description_html : String)
return description
end
-def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
- extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
-end
-
-def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil)
- if i = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
- video_id = i["videoId"].as_s
- title = i["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || ""
-
- author_info = i["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]?
- author = author_info.try &.["text"].as_s || author_fallback || ""
- author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || ""
-
- published = i["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local
- view_count = i["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
- description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
- length_seconds = i["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } ||
- i["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]?
- .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0
-
- live_now = false
- premium = false
-
- premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) }
-
- i["badges"]?.try &.as_a.each do |badge|
- b = badge["metadataBadgeRenderer"]
- case b["label"].as_s
- when "LIVE NOW"
- live_now = true
- when "New", "4K", "CC"
- # TODO
- when "Premium"
- # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"]
- premium = true
- else nil # Ignore
- end
- end
-
- SearchVideo.new({
- title: title,
- id: video_id,
- author: author,
- ucid: author_id,
- published: published,
- views: view_count,
- description_html: description_html,
- length_seconds: length_seconds,
- live_now: live_now,
- premium: premium,
- premiere_timestamp: premiere_timestamp,
- })
- elsif i = item["channelRenderer"]?
- author = i["title"]["simpleText"]?.try &.as_s || author_fallback || ""
- author_id = i["channelId"]?.try &.as_s || author_id_fallback || ""
-
- author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || ""
- subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0
-
- auto_generated = false
- auto_generated = true if !i["videoCountText"]?
- video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
- description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
-
- SearchChannel.new({
- author: author,
- ucid: author_id,
- author_thumbnail: author_thumbnail,
- subscriber_count: subscriber_count,
- video_count: video_count,
- description_html: description_html,
- auto_generated: auto_generated,
- })
- elsif i = item["gridPlaylistRenderer"]?
- title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || ""
- plid = i["playlistId"]?.try &.as_s || ""
-
- video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0
- playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || ""
-
- SearchPlaylist.new({
- title: title,
- id: plid,
- author: author_fallback || "",
- ucid: author_id_fallback || "",
- video_count: video_count,
- videos: [] of SearchPlaylistVideo,
- thumbnail: playlist_thumbnail,
- })
- elsif i = item["playlistRenderer"]?
- title = i["title"]["simpleText"]?.try &.as_s || ""
- plid = i["playlistId"]?.try &.as_s || ""
-
- video_count = i["videoCount"]?.try &.as_s.to_i || 0
- playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || ""
-
- author_info = i["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]?
- author = author_info.try &.["text"].as_s || author_fallback || ""
- author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || ""
-
- videos = i["videos"]?.try &.as_a.map do |v|
- v = v["childVideoRenderer"]
- v_title = v["title"]["simpleText"]?.try &.as_s || ""
- v_id = v["videoId"]?.try &.as_s || ""
- v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0
- SearchPlaylistVideo.new({
- title: v_title,
- id: v_id,
- length_seconds: v_length_seconds,
- })
- end || [] of SearchPlaylistVideo
-
- # TODO: i["publishedTimeText"]?
-
- SearchPlaylist.new({
- title: title,
- id: plid,
- author: author,
- ucid: author_id,
- video_count: video_count,
- videos: videos,
- thumbnail: playlist_thumbnail,
- })
- elsif i = item["radioRenderer"]? # Mix
- # TODO
- elsif i = item["showRenderer"]? # Show
- # TODO
- elsif i = item["shelfRenderer"]?
- elsif i = item["horizontalCardListRenderer"]?
- elsif i = item["searchPyvRenderer"]? # Ad
- end
-end
-
-def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
- items = [] of SearchItem
-
- channel_v2_response = initial_data
- .try &.["continuationContents"]?
- .try &.["gridContinuation"]?
- .try &.["items"]?
-
- if channel_v2_response
- channel_v2_response.try &.as_a.each { |item|
- extract_item(item, author_fallback, author_id_fallback)
- .try { |t| items << t }
- }
- else
- initial_data.try { |t| t["contents"]? || t["response"]? }
- .try { |t| t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]?.try &.["tabRenderer"]["content"] ||
- t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] ||
- t["continuationContents"]? }
- .try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? }
- .try &.["contents"].as_a
- .each { |c| c.try &.["itemSectionRenderer"]?.try &.["contents"].as_a
- .try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a ||
- t[0]?.try &.["gridRenderer"]?.try &.["items"].as_a || t }
- .each { |item|
- extract_item(item, author_fallback, author_id_fallback)
- .try { |t| items << t }
- } }
- end
-
- items
-end
-
-def check_enum(db, enum_name, struct_type = nil)
- return # TODO
-
- if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool)
- LOGGER.info("check_enum: CREATE TYPE #{enum_name}")
-
- db.using_connection do |conn|
- conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql"))
- end
- end
-end
-
-def check_table(db, table_name, struct_type = nil)
- # Create table if it doesn't exist
- begin
- db.exec("SELECT * FROM #{table_name} LIMIT 0")
- rescue ex
- LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}")
-
- db.using_connection do |conn|
- conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
- end
- end
-
- return if !struct_type
-
- struct_array = struct_type.type_array
- column_array = get_column_array(db, table_name)
- column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/)
- .try &.["types"].split(",").map { |line| line.strip }.reject &.starts_with?("CONSTRAINT")
-
- return if !column_types
-
- struct_array.each_with_index do |name, i|
- if name != column_array[i]?
- if !column_array[i]?
- new_column = column_types.select { |line| line.starts_with? name }[0]
- LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
- db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
- next
- end
-
- # Column doesn't exist
- if !column_array.includes? name
- new_column = column_types.select { |line| line.starts_with? name }[0]
- db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
- end
-
- # Column exists but in the wrong position, rotate
- if struct_array.includes? column_array[i]
- until name == column_array[i]
- new_column = column_types.select { |line| line.starts_with? column_array[i] }[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new")
-
- # There's a column we didn't expect
- if !new_column
- LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
- db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
-
- column_array = get_column_array(db, table_name)
- next
- end
-
- LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
- db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
-
- LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
- db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
-
- LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
- db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
-
- LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
- db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
-
- column_array = get_column_array(db, table_name)
- end
- else
- LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
- db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
- end
- end
- end
-
- return if column_array.size <= struct_array.size
-
- column_array.each do |column|
- if !struct_array.includes? column
- LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
- db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
- end
- end
-end
-
-def get_column_array(db, table_name)
- column_array = [] of String
- db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs|
- rs.column_count.times do |i|
- column = rs.as(PG::ResultSet).field(i)
- column_array << column.name
- end
- end
-
- return column_array
-end
-
-def cache_annotation(db, id, annotations)
+def cache_annotation(id, annotations)
if !CONFIG.cache_annotations
return
end
@@ -535,14 +78,14 @@ def cache_annotation(db, id, annotations)
end
end
- db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) if has_legacy_annotations
+ Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations
end
def create_notification_stream(env, topics, connection_channel)
connection = Channel(PQ::Notification).new(8)
connection_channel.send({true, connection})
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
since = env.params.query["since"]?.try &.to_i?
id = 0
@@ -556,9 +99,9 @@ def create_notification_stream(env, topics, connection_channel)
published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)]
- video = get_video(video_id, PG_DB)
+ video = get_video(video_id)
video.published = published
- response = JSON.parse(video.to_json(locale))
+ response = JSON.parse(video.to_json(locale, nil))
if fields_text = env.params.query["fields"]?
begin
@@ -587,11 +130,12 @@ def create_notification_stream(env, topics, connection_channel)
spawn do
begin
if since
+ since_unix = Time.unix(since.not_nil!)
+
topics.try &.each do |topic|
case topic
when .match(/UC[A-Za-z0-9_-]{22}/)
- PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 AND published > $2 ORDER BY published DESC LIMIT 15",
- topic, Time.unix(since.not_nil!), as: ChannelVideo).each do |video|
+ Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video|
response = JSON.parse(video.to_json(locale))
if fields_text = env.params.query["fields"]?
@@ -632,9 +176,9 @@ def create_notification_stream(env, topics, connection_channel)
next
end
- video = get_video(video_id, PG_DB)
+ video = get_video(video_id)
video.published = Time.unix(published)
- response = JSON.parse(video.to_json(locale))
+ response = JSON.parse(video.to_json(locale, nil))
if fields_text = env.params.query["fields"]?
begin
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index 7ffdfdcc..e88e4491 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -1,74 +1,111 @@
-LOCALES = {
- "ar" => load_locale("ar"), # Arabic
- "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh)
- "cs" => load_locale("cs"), # Czech
- "da" => load_locale("da"), # Danish
- "de" => load_locale("de"), # German
- "el" => load_locale("el"), # Greek
- "en-US" => load_locale("en-US"), # English (US)
- "eo" => load_locale("eo"), # Esperanto
- "es" => load_locale("es"), # Spanish
- "eu" => load_locale("eu"), # Basque
- "fa" => load_locale("fa"), # Persian
- "fi" => load_locale("fi"), # Finnish
- "fr" => load_locale("fr"), # French
- "he" => load_locale("he"), # Hebrew
- "hr" => load_locale("hr"), # Croatian
- "hu-HU" => load_locale("hu-HU"), # Hungarian
- "id" => load_locale("id"), # Indonesian
- "is" => load_locale("is"), # Icelandic
- "it" => load_locale("it"), # Italian
- "ja" => load_locale("ja"), # Japanese
- "ko" => load_locale("ko"), # Korean
- "lt" => load_locale("lt"), # Lithuanian
- "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål
- "nl" => load_locale("nl"), # Dutch
- "pl" => load_locale("pl"), # Polish
- "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil)
- "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal)
- "ro" => load_locale("ro"), # Romanian
- "ru" => load_locale("ru"), # Russian
- "si" => load_locale("si"), # Sinhala
- "sk" => load_locale("sk"), # Slovak
- "sr" => load_locale("sr"), # Serbian
- "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic)
- "sv-SE" => load_locale("sv-SE"), # Swedish
- "tr" => load_locale("tr"), # Turkish
- "uk" => load_locale("uk"), # Ukrainian
- "vi" => load_locale("vi"), # Vietnamese
- "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified)
- "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional)
+# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete]
+# "eu" => load_locale("eu"), # Basque [Incomplete]
+# "sk" => load_locale("sk"), # Slovak [Incomplete]
+LOCALES_LIST = {
+ "ar" => "العربية", # Arabic
+ "cs" => "Čeština", # Czech
+ "da" => "Dansk", # Danish
+ "de" => "Deutsch", # German
+ "el" => "Ελληνικά", # Greek
+ "en-US" => "English", # English
+ "eo" => "Esperanto", # Esperanto
+ "es" => "Español", # Spanish
+ "fa" => "فارسی", # Persian
+ "fi" => "Suomi", # Finnish
+ "fr" => "Français", # French
+ "he" => "עברית", # Hebrew
+ "hr" => "Hrvatski", # Croatian
+ "hu-HU" => "Magyar Nyelv", # Hungarian
+ "id" => "Bahasa Indonesia", # Indonesian
+ "is" => "Íslenska", # Icelandic
+ "it" => "Italiano", # Italian
+ "ja" => "日本語", # Japanese
+ "ko" => "한국어", # Korean
+ "lt" => "Lietuvių", # Lithuanian
+ "nb-NO" => "Norsk bokmål", # Norwegian Bokmål
+ "nl" => "Nederlands", # Dutch
+ "pl" => "Polski", # Polish
+ "pt" => "Português", # Portuguese
+ "pt-BR" => "Português Brasileiro", # Portuguese (Brazil)
+ "pt-PT" => "Português de Portugal", # Portuguese (Portugal)
+ "ro" => "Română", # Romanian
+ "ru" => "русский", # Russian
+ "sr" => "srpski (latinica)", # Serbian (Latin)
+ "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic)
+ "sv-SE" => "Svenska", # Swedish
+ "tr" => "Türkçe", # Turkish
+ "uk" => "Українська", # Ukrainian
+ "vi" => "Tiếng Việt", # Vietnamese
+ "zh-CN" => "汉语", # Chinese (Simplified)
+ "zh-TW" => "漢語", # Chinese (Traditional)
}
-def load_locale(name)
- return JSON.parse(File.read("locales/#{name}.json")).as_h
+LOCALES = load_all_locales()
+
+CONTENT_REGIONS = {
+ "AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY",
+ "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE",
+ "EG", "ES", "FI", "FR", "GB", "GE", "GH", "GR", "GT", "HK", "HN", "HR", "HU",
+ "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KR", "KW",
+ "KZ", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", "MK", "MT", "MX",
+ "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK",
+ "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK",
+ "SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN",
+ "YE", "ZA", "ZW",
+}
+
+# Enum for the different types of number formats
+enum NumberFormatting
+ None # Print the number as-is
+ Separator # Use a separator for thousands
+ Short # Use short notation (k/M/B)
+ HtmlSpan # Surround with <span id="count"></span>
end
-def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text : String | Nil = nil)
- # if locale && !locale[translation]?
- # puts "Could not find translation for #{translation.dump}"
- # end
-
- if locale && locale[translation]?
- case locale[translation]
- when .as_h?
- match_length = 0
-
- locale[translation].as_h.each do |key, value|
- if md = text.try &.match(/#{key}/)
- if md[0].size >= match_length
- translation = value.as_s
- match_length = md[0].size
- end
+def load_all_locales
+ locales = {} of String => Hash(String, JSON::Any)
+
+ LOCALES_LIST.each_key do |name|
+ locales[name] = JSON.parse(File.read("locales/#{name}.json")).as_h
+ end
+
+ return locales
+end
+
+def translate(locale : String?, key : String, text : String | Nil = nil) : String
+ # Log a warning if "key" doesn't exist in en-US locale and return
+ # that key as the text, so this is more or less transparent to the user.
+ if !LOCALES["en-US"].has_key?(key)
+ LOGGER.warn("i18n: Missing translation key \"#{key}\"")
+ return key
+ end
+
+ # Default to english, whenever the locale doesn't exist,
+ # or the key requested has not been translated
+ if locale && LOCALES.has_key?(locale) && LOCALES[locale].has_key?(key)
+ raw_data = LOCALES[locale][key]
+ else
+ raw_data = LOCALES["en-US"][key]
+ end
+
+ case raw_data
+ when .as_h?
+ # Init
+ translation = ""
+ match_length = 0
+
+ raw_data.as_h.each do |key, value|
+ if md = text.try &.match(/#{key}/)
+ if md[0].size >= match_length
+ translation = value.as_s
+ match_length = md[0].size
end
end
- when .as_s?
- if !locale[translation].as_s.empty?
- translation = locale[translation].as_s
- end
- else
- raise "Invalid translation #{translation}"
end
+ when .as_s?
+ translation = raw_data.as_s
+ else
+ raise "Invalid translation \"#{raw_data}\""
end
if text
@@ -78,7 +115,43 @@ def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text
return translation
end
-def translate_bool(locale : Hash(String, JSON::Any) | Nil, translation : Bool)
+def translate_count(locale : String, key : String, count : Int, format = NumberFormatting::None) : String
+ # Fallback on english if locale doesn't exist
+ locale = "en-US" if !LOCALES.has_key?(locale)
+
+ # Retrieve suffix
+ suffix = I18next::Plurals::RESOLVER.get_suffix(locale, count)
+ plural_key = key + suffix
+
+ if LOCALES[locale].has_key?(plural_key)
+ translation = LOCALES[locale][plural_key].as_s
+ else
+ # Try #1: Fallback to singular in the same locale
+ singular_suffix = I18next::Plurals::RESOLVER.get_suffix(locale, 1)
+
+ if LOCALES[locale].has_key?(key + singular_suffix)
+ translation = LOCALES[locale][key + singular_suffix].as_s
+ elsif locale != "en-US"
+ # Try #2: Fallback to english
+ translation = translate_count("en-US", key, count)
+ else
+ # Return key if we're already in english, as the tranlation is missing
+ LOGGER.warn("i18n: Missing translation key \"#{key}\"")
+ return key
+ end
+ end
+
+ case format
+ when .separator? then count_txt = number_with_separator(count)
+ when .short? then count_txt = number_to_short_text(count)
+ when .html_span? then count_txt = "<span id=\"count\">" + count.to_s + "</span>"
+ else count_txt = count.to_s
+ end
+
+ return translation.gsub("{{count}}", count_txt)
+end
+
+def translate_bool(locale : String?, translation : Bool)
case translation
when true
return translate(locale, "Yes")
diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr
new file mode 100644
index 00000000..e84f88fb
--- /dev/null
+++ b/src/invidious/helpers/i18next.cr
@@ -0,0 +1,511 @@
+# I18next-compatible implementation of plural forms
+#
+module I18next::Plurals
+ # -----------------------------------
+ # I18next plural forms definition
+ # -----------------------------------
+
+ enum PluralForms
+ # One singular, one plural forms
+ Single_gt_one = 1 # E.g: French
+ Single_not_one = 2 # E.g: English
+
+ # No plural forms (E.g: Azerbaijani)
+ None = 3
+
+ # One singular, two plural forms
+ Dual_Slavic = 4 # E.g: Russian
+
+ # Special cases (rules used by only one or two language(s))
+ Special_Arabic = 5
+ Special_Czech_Slovak = 6
+ Special_Polish_Kashubian = 7
+ Special_Welsh = 8
+ Special_Irish = 10
+ Special_Scottish_Gaelic = 11
+ Special_Icelandic = 12
+ Special_Javanese = 13
+ Special_Cornish = 14
+ Special_Lithuanian = 15
+ Special_Latvian = 16
+ Special_Macedonian = 17
+ Special_Mandinka = 18
+ Special_Maltese = 19
+ Special_Romanian = 20
+ Special_Slovenian = 21
+ Special_Hebrew = 22
+ Special_Odia = 23
+ end
+
+ private PLURAL_SETS = {
+ PluralForms::Single_gt_one => [
+ "ach", "ak", "am", "arn", "br", "fil", "fr", "gun", "ln", "mfe", "mg",
+ "mi", "oc", "pt", "pt-BR", "tg", "tl", "ti", "tr", "uz", "wa",
+ ],
+ PluralForms::Single_not_one => [
+ "af", "an", "ast", "az", "bg", "bn", "ca", "da", "de", "dev", "el", "en",
+ "eo", "es", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi",
+ "hu", "hy", "ia", "it", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr",
+ "nah", "nap", "nb", "ne", "nl", "nn", "no", "nso", "pa", "pap", "pms",
+ "ps", "pt-PT", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw",
+ "ta", "te", "tk", "ur", "yo",
+ ],
+ PluralForms::None => [
+ "ay", "bo", "cgg", "fa", "ht", "id", "ja", "jbo", "ka", "km", "ko", "ky",
+ "lo", "ms", "sah", "su", "th", "tt", "ug", "vi", "wo", "zh",
+ ],
+ PluralForms::Dual_Slavic => [
+ "be", "bs", "cnr", "dz", "hr", "ru", "sr", "uk",
+ ],
+ }
+
+ private PLURAL_SINGLES = {
+ "ar" => PluralForms::Special_Arabic,
+ "cs" => PluralForms::Special_Czech_Slovak,
+ "csb" => PluralForms::Special_Polish_Kashubian,
+ "cy" => PluralForms::Special_Welsh,
+ "ga" => PluralForms::Special_Irish,
+ "gd" => PluralForms::Special_Scottish_Gaelic,
+ "he" => PluralForms::Special_Hebrew,
+ "is" => PluralForms::Special_Icelandic,
+ "iw" => PluralForms::Special_Hebrew,
+ "jv" => PluralForms::Special_Javanese,
+ "kw" => PluralForms::Special_Cornish,
+ "lt" => PluralForms::Special_Lithuanian,
+ "lv" => PluralForms::Special_Latvian,
+ "mk" => PluralForms::Special_Macedonian,
+ "mnk" => PluralForms::Special_Mandinka,
+ "mt" => PluralForms::Special_Maltese,
+ "or" => PluralForms::Special_Odia,
+ "pl" => PluralForms::Special_Polish_Kashubian,
+ "ro" => PluralForms::Special_Romanian,
+ "sk" => PluralForms::Special_Czech_Slovak,
+ "sl" => PluralForms::Special_Slovenian,
+ }
+
+ # These are the v1 and v2 compatible suffixes.
+ # The array indices matches the PluralForms enum above.
+ private NUMBERS = [
+ [1, 2], # 1
+ [1, 2], # 2
+ [1], # 3
+ [1, 2, 5], # 4
+ [0, 1, 2, 3, 11, 100], # 5
+ [1, 2, 5], # 6
+ [1, 2, 5], # 7
+ [1, 2, 3, 8], # 8
+ [1, 2], # 9 (not used)
+ [1, 2, 3, 7, 11], # 10
+ [1, 2, 3, 20], # 11
+ [1, 2], # 12
+ [0, 1], # 13
+ [1, 2, 3, 4], # 14
+ [1, 2, 10], # 15
+ [1, 2, 0], # 16
+ [1, 2], # 17
+ [0, 1, 2], # 18
+ [1, 2, 11, 20], # 19
+ [1, 2, 20], # 20
+ [5, 1, 2, 3], # 21
+ [1, 2, 20, 21], # 22
+ [2, 1], # 23 (Odia)
+ ]
+
+ # -----------------------------------
+ # I18next plural resolver class
+ # -----------------------------------
+
+ RESOLVER = Resolver.new
+
+ class Resolver
+ private property forms = {} of String => PluralForms
+ property version : UInt8 = 3
+
+ # Options
+ property simplify_plural_suffix : Bool = true
+
+ def initialize(version : Int = 3)
+ # Sanity checks
+ # V4 isn't supported, as it requires a full CLDR database.
+ if version > 4 || version == 0
+ raise "Invalid i18next version: v#{version}."
+ elsif version == 4
+ # Logger.error("Unsupported i18next version: v4. Falling back to v3")
+ @version = 3_u8
+ else
+ @version = version.to_u8
+ end
+
+ self.init_rules
+ end
+
+ def init_rules
+ # Look into sets
+ PLURAL_SETS.each do |form, langs|
+ langs.each { |lang| self.forms[lang] = form }
+ end
+
+ # Add plurals from the "singles" set
+ self.forms.merge!(PLURAL_SINGLES)
+ end
+
+ def get_plural_form(locale : String) : PluralForms
+ # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code,
+ # except for pt-BR and pt-PT which needs to be kept as-is.
+ if !locale.matches?(/^pt-(BR|PT)$/)
+ locale = locale.split('-')[0]
+ end
+
+ return self.forms[locale] if self.forms[locale]?
+
+ # If nothing was found, then use the most common form, i.e
+ # one singular and one plural, as in english. Not perfect,
+ # but better than yielding an exception at the user.
+ return PluralForms::Single_not_one
+ end
+
+ def get_suffix(locale : String, count : Int) : String
+ # Checked count must be absolute. In i18next, `rule.noAbs` is used to
+ # determine if comparison should be done on a signed or unsigned integer,
+ # but this variable is never set, resulting in the comparison always
+ # being done on absolute numbers.
+ return get_suffix_retrocompat(locale, count.abs)
+ end
+
+ # Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check
+ # from original i18next code
+ private def is_simple_plural(form : PluralForms) : Bool
+ case form
+ when .single_gt_one? then return true
+ when .single_not_one? then return true
+ when .special_icelandic? then return true
+ when .special_macedonian? then return true
+ else
+ return false
+ end
+ end
+
+ private def get_suffix_retrocompat(locale : String, count : Int) : String
+ # Get plural form
+ plural_form = get_plural_form(locale)
+
+ # Languages with no plural have the "_0" suffix
+ return "_0" if plural_form.none?
+
+ # Get the index and suffix for this number
+ idx = SuffixIndex.get_index(plural_form, count)
+
+ # Simple plurals are handled differently in all versions (but v4)
+ if @simplify_plural_suffix && is_simple_plural(plural_form)
+ return (idx == 1) ? "_plural" : ""
+ end
+
+ # More complex plurals
+ # TODO: support v1 and v2
+ # TODO: support `options.prepend` (v2 and v3)
+ # this.options.prepend && suffix.toString() ? this.options.prepend + suffix.toString() : suffix.toString()
+ #
+ # case @version
+ # when 1
+ # suffix = SUFFIXES_V1_V2[plural_form.to_i][idx]
+ # return (suffix == 1) ? "" : return "_plural_#{suffix}"
+ # when 2
+ # return "_#{suffix}"
+ # else # v3
+ return "_#{idx}"
+ # end
+ end
+ end
+
+ # -----------------------------
+ # Plural functions
+ # -----------------------------
+
+ module SuffixIndex
+ def self.get_index(plural_form : PluralForms, count : Int) : UInt8
+ case plural_form
+ when .single_gt_one? then return (count > 1) ? 1_u8 : 0_u8
+ when .single_not_one? then return (count != 1) ? 1_u8 : 0_u8
+ when .none? then return 0_u8
+ when .dual_slavic? then return dual_slavic(count)
+ when .special_arabic? then return special_arabic(count)
+ when .special_czech_slovak? then return special_czech_slovak(count)
+ when .special_polish_kashubian? then return special_polish_kashubian(count)
+ when .special_welsh? then return special_welsh(count)
+ when .special_irish? then return special_irish(count)
+ when .special_scottish_gaelic? then return special_scottish_gaelic(count)
+ when .special_icelandic? then return special_icelandic(count)
+ when .special_javanese? then return special_javanese(count)
+ when .special_cornish? then return special_cornish(count)
+ when .special_lithuanian? then return special_lithuanian(count)
+ when .special_latvian? then return special_latvian(count)
+ when .special_macedonian? then return special_macedonian(count)
+ when .special_mandinka? then return special_mandinka(count)
+ when .special_maltese? then return special_maltese(count)
+ when .special_romanian? then return special_romanian(count)
+ when .special_slovenian? then return special_slovenian(count)
+ when .special_hebrew? then return special_hebrew(count)
+ when .special_odia? then return special_odia(count)
+ else
+ # default, if nothing matched above
+ return 0_u8
+ end
+ end
+
+ # Plural form of Slavic languages (E.g: Russian)
+ #
+ # Corresponds to i18next rule #4
+ # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)
+ #
+ def self.dual_slavic(count : Int) : UInt8
+ n_mod_10 = count % 10
+ n_mod_100 = count % 100
+
+ if n_mod_10 == 1 && n_mod_100 != 11
+ return 0_u8
+ elsif n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20)
+ return 1_u8
+ else
+ return 2_u8
+ end
+ end
+
+ # Plural form for Arabic language
+ #
+ # Corresponds to i18next rule #5
+ # Rule: (n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5)
+ #
+ def self.special_arabic(count : Int) : UInt8
+ return count.to_u8 if (count == 0 || count == 1 || count == 2)
+
+ n_mod_100 = count % 100
+
+ return 3_u8 if (n_mod_100 >= 3 && n_mod_100 <= 10)
+ return 4_u8 if (n_mod_100 >= 11)
+ return 5_u8
+ end
+
+ # Plural form for Czech and Slovak languages
+ #
+ # Corresponds to i18next rule #6
+ # Rule: ((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2)
+ #
+ def self.special_czech_slovak(count : Int) : UInt8
+ return 0_u8 if (count == 1)
+ return 1_u8 if (count >= 2 && count <= 4)
+ return 2_u8
+ end
+
+ # Plural form for Polish and Kashubian languages
+ #
+ # Corresponds to i18next rule #7
+ # Rule: (n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)
+ #
+ def self.special_polish_kashubian(count : Int) : UInt8
+ return 0_u8 if (count == 1)
+
+ n_mod_10 = count % 10
+ n_mod_100 = count % 100
+
+ if n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20)
+ return 1_u8
+ else
+ return 2_u8
+ end
+ end
+
+ # Plural form for Welsh language
+ #
+ # Corresponds to i18next rule #8
+ # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3)
+ #
+ def self.special_welsh(count : Int) : UInt8
+ return 0_u8 if (count == 1)
+ return 1_u8 if (count == 2)
+ return 2_u8 if (count != 8 && count != 11)
+ return 3_u8
+ end
+
+ # Plural form for Irish language
+ #
+ # Corresponds to i18next rule #10
+ # Rule: (n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4)
+ #
+ def self.special_irish(count : Int) : UInt8
+ return 0_u8 if (count == 1)
+ return 1_u8 if (count == 2)
+ return 2_u8 if (count < 7)
+ return 3_u8 if (count < 11)
+ return 4_u8
+ end
+
+ # Plural form for Gaelic language
+ #
+ # Corresponds to i18next rule #11
+ # Rule: ((n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3)
+ #
+ def self.special_scottish_gaelic(count : Int) : UInt8
+ return 0_u8 if (count == 1 || count == 11)
+ return 1_u8 if (count == 2 || count == 12)
+ return 2_u8 if (count > 2 && count < 20)
+ return 3_u8
+ end
+
+ # Plural form for Icelandic language
+ #
+ # Corresponds to i18next rule #12
+ # Rule: (n%10!=1 || n%100==11)
+ #
+ def self.special_icelandic(count : Int) : UInt8
+ if (count % 10) != 1 || (count % 100) == 11
+ return 1_u8
+ else
+ return 0_u8
+ end
+ end
+
+ # Plural form for Javanese language
+ #
+ # Corresponds to i18next rule #13
+ # Rule: (n !== 0)
+ #
+ def self.special_javanese(count : Int) : UInt8
+ return (count != 0) ? 1_u8 : 0_u8
+ end
+
+ # Plural form for Cornish language
+ #
+ # Corresponds to i18next rule #14
+ # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3)
+ #
+ def self.special_cornish(count : Int) : UInt8
+ return 0_u8 if count == 1
+ return 1_u8 if count == 2
+ return 2_u8 if count == 3
+ return 3_u8
+ end
+
+ # Plural form for Lithuanian language
+ #
+ # Corresponds to i18next rule #15
+ # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2)
+ #
+ def self.special_lithuanian(count : Int) : UInt8
+ n_mod_10 = count % 10
+ n_mod_100 = count % 100
+
+ if n_mod_10 == 1 && n_mod_100 != 11
+ return 0_u8
+ elsif n_mod_10 >= 2 && (n_mod_100 < 10 || n_mod_100 >= 20)
+ return 1_u8
+ else
+ return 2_u8
+ end
+ end
+
+ # Plural form for Latvian language
+ #
+ # Corresponds to i18next rule #16
+ # Rule: (n%10==1 && n%100!=11 ? 0 : n !== 0 ? 1 : 2)
+ #
+ def self.special_latvian(count : Int) : UInt8
+ if (count % 10) == 1 && (count % 100) != 11
+ return 0_u8
+ elsif count != 0
+ return 1_u8
+ else
+ return 2_u8
+ end
+ end
+
+ # Plural form for Macedonian language
+ #
+ # Corresponds to i18next rule #17
+ # Rule: (n==1 || n%10==1 && n%100!=11 ? 0 : 1)
+ #
+ def self.special_macedonian(count : Int) : UInt8
+ if count == 1 || ((count % 10) == 1 && (count % 100) != 11)
+ return 0_u8
+ else
+ return 1_u8
+ end
+ end
+
+ # Plural form for Mandinka language
+ #
+ # Corresponds to i18next rule #18
+ # Rule: (n==0 ? 0 : n==1 ? 1 : 2)
+ #
+ def self.special_mandinka(count : Int) : UInt8
+ return (count == 0 || count == 1) ? count.to_u8 : 2_u8
+ end
+
+ # Plural form for Maltese language
+ #
+ # Corresponds to i18next rule #19
+ # Rule: (n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3)
+ #
+ def self.special_maltese(count : Int) : UInt8
+ return 0_u8 if count == 1
+ return 1_u8 if count == 0
+
+ n_mod_100 = count % 100
+ return 1_u8 if (n_mod_100 > 1 && n_mod_100 < 11)
+ return 2_u8 if (n_mod_100 > 10 && n_mod_100 < 20)
+ return 3_u8
+ end
+
+ # Plural form for Romanian language
+ #
+ # Corresponds to i18next rule #20
+ # Rule: (n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2)
+ #
+ def self.special_romanian(count : Int) : UInt8
+ return 0_u8 if count == 1
+ return 1_u8 if count == 0
+
+ n_mod_100 = count % 100
+ return 1_u8 if (n_mod_100 > 0 && n_mod_100 < 20)
+ return 2_u8
+ end
+
+ # Plural form for Slovenian language
+ #
+ # Corresponds to i18next rule #21
+ # Rule: (n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0)
+ #
+ def self.special_slovenian(count : Int) : UInt8
+ n_mod_100 = count % 100
+ return 1_u8 if (n_mod_100 == 1)
+ return 2_u8 if (n_mod_100 == 2)
+ return 3_u8 if (n_mod_100 == 3 || n_mod_100 == 4)
+ return 0_u8
+ end
+
+ # Plural form for Hebrew language
+ #
+ # Corresponds to i18next rule #22
+ # Rule: (n==1 ? 0 : n==2 ? 1 : (n<0 || n>10) && n%10==0 ? 2 : 3)
+ #
+ def self.special_hebrew(count : Int) : UInt8
+ return 0_u8 if (count == 1)
+ return 1_u8 if (count == 2)
+
+ if (count < 0 || count > 10) && (count % 10) == 0
+ return 2_u8
+ else
+ return 3_u8
+ end
+ end
+
+ # Plural form for Odia ("or") language
+ #
+ # This one is a bit special. It should use rule #2 (like english)
+ # but the "numbers" (suffixes?) it has are inverted, so we'll make a
+ # special rule for it.
+ #
+ def self.special_odia(count : Int) : UInt8
+ return (count == 1) ? 0_u8 : 1_u8
+ end
+ end
+end
diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr
index 5d91a258..e2e50905 100644
--- a/src/invidious/helpers/logger.cr
+++ b/src/invidious/helpers/logger.cr
@@ -17,7 +17,19 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
elapsed_time = Time.measure { call_next(context) }
elapsed_text = elapsed_text(elapsed_time)
- info("#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}")
+ # Default: full path with parameters
+ requested_url = context.request.resource
+
+ # Try not to log search queries passed as GET parameters during normal use
+ # (They will still be logged if log level is 'Debug' or 'Trace')
+ if @level > LogLevel::Debug && (
+ requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=")
+ )
+ # Log only the path
+ requested_url = context.request.path
+ end
+
+ info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}")
context
end
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
new file mode 100644
index 00000000..bfbc237c
--- /dev/null
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -0,0 +1,263 @@
+struct SearchVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property published : Time
+ property views : Int64
+ property description_html : String
+ property length_seconds : Int32
+ property live_now : Bool
+ property premium : Bool
+ property premiere_timestamp : Time?
+
+ def to_xml(auto_generated, query_params, xml : XML::Builder)
+ query_params["v"] = self.id
+
+ xml.element("entry") do
+ xml.element("id") { xml.text "yt:video:#{self.id}" }
+ xml.element("yt:videoId") { xml.text self.id }
+ xml.element("yt:channelId") { xml.text self.ucid }
+ xml.element("title") { xml.text self.title }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}")
+
+ xml.element("author") do
+ if auto_generated
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
+ else
+ xml.element("name") { xml.text author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
+ end
+ end
+
+ xml.element("content", type: "xhtml") do
+ xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
+ xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do
+ xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
+ end
+
+ xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
+ end
+ end
+
+ xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
+
+ xml.element("media:group") do
+ xml.element("media:title") { xml.text self.title }
+ xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
+ width: "320", height: "180")
+ xml.element("media:description") { xml.text html_to_content(self.description_html) }
+ end
+
+ xml.element("media:community") do
+ xml.element("media:statistics", views: self.views)
+ end
+ end
+ end
+
+ def to_xml(auto_generated, query_params, _xml : Nil)
+ XML.build do |xml|
+ to_xml(auto_generated, query_params, xml)
+ end
+ end
+
+ def to_json(locale : String?, json : JSON::Builder)
+ json.object do
+ json.field "type", "video"
+ json.field "title", self.title
+ json.field "videoId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, self.id)
+ end
+
+ json.field "description", html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+
+ json.field "viewCount", self.views
+ json.field "published", self.published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
+ json.field "lengthSeconds", self.length_seconds
+ json.field "liveNow", self.live_now
+ json.field "premium", self.premium
+ json.field "isUpcoming", self.is_upcoming
+
+ if self.premiere_timestamp
+ json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
+ end
+ end
+ end
+
+ # TODO: remove the locale and follow the crystal convention
+ def to_json(locale : String?, _json : Nil)
+ JSON.build do |json|
+ to_json(locale, json)
+ end
+ end
+
+ def to_json(json : JSON::Builder)
+ to_json(nil, json)
+ end
+
+ def is_upcoming
+ premiere_timestamp ? true : false
+ end
+end
+
+struct SearchPlaylistVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property length_seconds : Int32
+end
+
+struct SearchPlaylist
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property video_count : Int32
+ property videos : Array(SearchPlaylistVideo)
+ property thumbnail : String?
+
+ def to_json(locale : String?, json : JSON::Builder)
+ json.object do
+ json.field "type", "playlist"
+ json.field "title", self.title
+ json.field "playlistId", self.id
+ json.field "playlistThumbnail", self.thumbnail
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoCount", self.video_count
+ json.field "videos" do
+ json.array do
+ self.videos.each do |video|
+ json.object do
+ json.field "title", video.title
+ json.field "videoId", video.id
+ json.field "lengthSeconds", video.length_seconds
+
+ json.field "videoThumbnails" do
+ generate_thumbnails(json, video.id)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ # TODO: remove the locale and follow the crystal convention
+ def to_json(locale : String?, _json : Nil)
+ JSON.build do |json|
+ to_json(locale, json)
+ end
+ end
+
+ def to_json(json : JSON::Builder)
+ to_json(nil, json)
+ end
+end
+
+struct SearchChannel
+ include DB::Serializable
+
+ property author : String
+ property ucid : String
+ property author_thumbnail : String
+ property subscriber_count : Int32
+ property video_count : Int32
+ property description_html : String
+ property auto_generated : Bool
+
+ def to_json(locale : String?, json : JSON::Builder)
+ json.object do
+ json.field "type", "channel"
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+
+ json.field "autoGenerated", self.auto_generated
+ json.field "subCount", self.subscriber_count
+ json.field "videoCount", self.video_count
+
+ json.field "description", html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+ end
+ end
+
+ # TODO: remove the locale and follow the crystal convention
+ def to_json(locale : String?, _json : Nil)
+ JSON.build do |json|
+ to_json(locale, json)
+ end
+ end
+
+ def to_json(json : JSON::Builder)
+ to_json(nil, json)
+ end
+end
+
+class Category
+ include DB::Serializable
+
+ property title : String
+ property contents : Array(SearchItem) | Array(Video)
+ property url : String?
+ property description_html : String
+ property badges : Array(Tuple(String, String))?
+
+ def to_json(locale : String?, json : JSON::Builder)
+ json.object do
+ json.field "type", "category"
+ json.field "title", self.title
+ json.field "contents" do
+ json.array do
+ self.contents.each do |item|
+ item.to_json(locale, json)
+ end
+ end
+ end
+ end
+ end
+
+ # TODO: remove the locale and follow the crystal convention
+ def to_json(locale : String?, _json : Nil)
+ JSON.build do |json|
+ to_json(locale, json)
+ end
+ end
+
+ def to_json(json : JSON::Builder)
+ to_json(nil, json)
+ end
+end
+
+alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category
diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr
index d8b1de65..ee09415b 100644
--- a/src/invidious/helpers/signatures.cr
+++ b/src/invidious/helpers/signatures.cr
@@ -30,7 +30,7 @@ struct DecryptFunction
case op_body
when "{a.reverse()"
- operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse }
+ operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse }
when "{a.splice(0,b)"
operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a }
else
diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr
index be9d36ab..630c2fd2 100644
--- a/src/invidious/helpers/static_file_handler.cr
+++ b/src/invidious/helpers/static_file_handler.cr
@@ -173,7 +173,7 @@ module Kemal
return
end
- if @cached_files.sum { |element| element[1][:data].bytesize } + (size = File.size(file_path)) < CACHE_LIMIT
+ if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT
data = Bytes.new(size)
File.open(file_path) do |file|
file.read(data)
diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr
index a09ce90b..8b076e39 100644
--- a/src/invidious/helpers/tokens.cr
+++ b/src/invidious/helpers/tokens.cr
@@ -1,8 +1,8 @@
require "crypto/subtle"
-def generate_token(email, scopes, expire, key, db)
+def generate_token(email, scopes, expire, key)
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
- PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc)
+ Invidious::Database::SessionIDs.insert(session, email)
token = {
"session" => session,
@@ -19,7 +19,7 @@ def generate_token(email, scopes, expire, key, db)
return token.to_json
end
-def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
+def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false)
expire = Time.utc + expire
token = {
@@ -30,7 +30,7 @@ def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = fa
if use_nonce
nonce = Random::Secure.hex(16)
- db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire)
+ Invidious::Database::Nonces.insert(nonce, expire)
token["nonce"] = nonce
end
@@ -46,7 +46,7 @@ def sign_token(key, hash)
next if key == "signature"
if value.is_a?(JSON::Any) && value.as_a?
- value = value.as_a.map { |i| i.as_s }
+ value = value.as_a.map(&.as_s)
end
case value
@@ -63,7 +63,7 @@ def sign_token(key, hash)
return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
end
-def validate_request(token, session, request, key, db, locale = nil)
+def validate_request(token, session, request, key, locale = nil)
case token
when String
token = JSON.parse(URI.decode_www_form(token)).as_h
@@ -82,7 +82,7 @@ def validate_request(token, session, request, key, db, locale = nil)
raise InfoException.new("Erroneous token")
end
- scopes = token["scopes"].as_a.map { |v| v.as_s }
+ scopes = token["scopes"].as_a.map(&.as_s)
scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
if !scopes_include_scope(scopes, scope)
raise InfoException.new("Invalid scope")
@@ -92,9 +92,9 @@ def validate_request(token, session, request, key, db, locale = nil)
raise InfoException.new("Invalid signature")
end
- if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
+ if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s))
if nonce[1] > Time.utc
- db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
+ Invidious::Database::Nonces.update_set_expired(nonce[0])
else
raise InfoException.new("Erroneous token")
end
@@ -105,11 +105,11 @@ end
def scope_includes_scope(scope, subset)
methods, endpoint = scope.split(":")
- methods = methods.split(";").map { |method| method.upcase }.reject { |method| method.empty? }.sort
+ methods = methods.split(";").map(&.upcase).reject(&.empty?).sort!
endpoint = endpoint.downcase
subset_methods, subset_endpoint = subset.split(":")
- subset_methods = subset_methods.split(";").map { |method| method.upcase }.sort
+ subset_methods = subset_methods.split(";").map(&.upcase).sort!
subset_endpoint = subset_endpoint.downcase
if methods.empty?
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 6ee07d7a..09181c10 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -1,70 +1,3 @@
-require "lsquic"
-require "db"
-
-def add_yt_headers(request)
- request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36"
- request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
- request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
- request.headers["accept-language"] ||= "en-us,en;q=0.5"
- return if request.resource.starts_with? "/sorry/index"
- request.headers["x-youtube-client-name"] ||= "1"
- request.headers["x-youtube-client-version"] ||= "2.20200609"
- # Preserve original cookies and add new YT consent cookie for EU servers
- request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+"
- if !CONFIG.cookies.empty?
- request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
- end
-end
-
-struct YoutubeConnectionPool
- property! url : URI
- property! capacity : Int32
- property! timeout : Float64
- property pool : DB::Pool(QUIC::Client | HTTP::Client)
-
- def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true)
- @url = url
- @pool = build_pool(use_quic)
- end
-
- def client(region = nil, &block)
- if region
- conn = make_client(url, region)
- response = yield conn
- else
- conn = pool.checkout
- begin
- response = yield conn
- rescue ex
- conn.close
- conn = QUIC::Client.new(url)
- conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
- conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
- conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
- response = yield conn
- ensure
- pool.release(conn)
- end
- end
-
- response
- end
-
- private def build_pool(use_quic)
- DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
- if use_quic
- conn = QUIC::Client.new(url)
- else
- conn = HTTP::Client.new(url)
- end
- conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
- conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
- conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
- conn
- end
- end
-end
-
# See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
def ci_lower_bound(pos, n)
if n == 0
@@ -85,42 +18,18 @@ def elapsed_text(elapsed)
"#{(millis * 1000).round(2)}µs"
end
-def make_client(url : URI, region = nil)
- # TODO: Migrate any applicable endpoints to QUIC
- client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
- client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
- client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
- client.read_timeout = 10.seconds
- client.connect_timeout = 10.seconds
-
- if region
- PROXY_LIST[region]?.try &.sample(40).each do |proxy|
- begin
- proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
- client.set_proxy(proxy)
- break
- rescue ex
- end
- end
- end
-
- return client
-end
-
-def make_client(url : URI, region = nil, &block)
- client = make_client(url, region)
- begin
- yield client
- ensure
- client.close
- end
-end
-
def decode_length_seconds(string)
- length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i
+ length_seconds = string.gsub(/[^0-9:]/, "")
+ return 0_i32 if length_seconds.empty?
+
+ length_seconds = length_seconds.split(":").map { |x| x.to_i? || 0 }
length_seconds = [0] * (3 - length_seconds.size) + length_seconds
- length_seconds = Time::Span.new hours: length_seconds[0], minutes: length_seconds[1], seconds: length_seconds[2]
- length_seconds = length_seconds.total_seconds.to_i
+
+ length_seconds = Time::Span.new(
+ hours: length_seconds[0],
+ minutes: length_seconds[1],
+ seconds: length_seconds[2]
+ ).total_seconds.to_i32
return length_seconds
end
@@ -214,22 +123,20 @@ def recode_date(time : Time, locale)
span = Time.utc - time
if span.total_days > 365.0
- span = translate(locale, "`x` years", (span.total_days.to_i // 365).to_s)
+ return translate_count(locale, "generic_count_years", span.total_days.to_i // 365)
elsif span.total_days > 30.0
- span = translate(locale, "`x` months", (span.total_days.to_i // 30).to_s)
+ return translate_count(locale, "generic_count_months", span.total_days.to_i // 30)
elsif span.total_days > 7.0
- span = translate(locale, "`x` weeks", (span.total_days.to_i // 7).to_s)
+ return translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7)
elsif span.total_hours > 24.0
- span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
+ return translate_count(locale, "generic_count_days", span.total_days.to_i)
elsif span.total_minutes > 60.0
- span = translate(locale, "`x` hours", (span.total_hours.to_i).to_s)
+ return translate_count(locale, "generic_count_hours", span.total_hours.to_i)
elsif span.total_seconds > 60.0
- span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s)
+ return translate_count(locale, "generic_count_minutes", span.total_minutes.to_i)
else
- span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s)
+ return translate_count(locale, "generic_count_seconds", span.total_seconds.to_i)
end
-
- return span
end
def number_with_separator(number)
@@ -397,22 +304,9 @@ def parse_range(range)
return 0_i64, nil
end
-def convert_theme(theme)
- case theme
- when "true"
- "dark"
- when "false"
- "light"
- when "", nil
- nil
- else
- theme
- end
-end
-
def fetch_random_instance
begin
- instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io"))
+ instance_api_client = make_client(URI.parse("https://api.invidious.io"))
# Timeouts
instance_api_client.connect_timeout = 10.seconds
diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr
index 7a8ab84e..dc785bae 100644
--- a/src/invidious/jobs/pull_popular_videos_job.cr
+++ b/src/invidious/jobs/pull_popular_videos_job.cr
@@ -1,11 +1,4 @@
class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob
- QUERY = <<-SQL
- SELECT DISTINCT ON (ucid) *
- FROM channel_videos
- WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
- GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
- ORDER BY ucid, published DESC
- SQL
POPULAR_VIDEOS = Atomic.new([] of ChannelVideo)
private getter db : DB::Database
@@ -14,9 +7,9 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob
def begin
loop do
- videos = db.query_all(QUERY, as: ChannelVideo)
- .sort_by(&.published)
- .reverse
+ videos = Invidious::Database::ChannelVideos.select_popular_videos
+ .sort_by!(&.published)
+ .reverse!
POPULAR_VIDEOS.set(videos)
diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr
index fbe6d381..941089c1 100644
--- a/src/invidious/jobs/refresh_channels_job.cr
+++ b/src/invidious/jobs/refresh_channels_job.cr
@@ -9,11 +9,11 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
lim_fibers = max_fibers
active_fibers = 0
active_channel = Channel(Bool).new
- backoff = 1.seconds
+ backoff = 2.minutes
loop do
LOGGER.debug("RefreshChannelsJob: Refreshing all channels")
- db.query("SELECT id FROM channels ORDER BY updated") do |rs|
+ PG_DB.query("SELECT id FROM channels ORDER BY updated") do |rs|
rs.each do
id = rs.read(String)
@@ -30,16 +30,16 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
spawn do
begin
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
- channel = fetch_channel(id, db, CONFIG.full_refresh)
+ channel = fetch_channel(id, CONFIG.full_refresh)
lim_fibers = max_fibers
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB")
- db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
+ Invidious::Database::Channels.update_author(id, channel.author)
rescue ex
LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}")
if ex.message == "Deleted or invalid channel"
- db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
+ Invidious::Database::Channels.update_mark_deleted(id)
else
lim_fibers = 1
LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s")
@@ -58,8 +58,9 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
end
end
- LOGGER.debug("RefreshChannelsJob: Done, sleeping for one minute")
- sleep 1.minute
+ # TODO: make this configurable
+ LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes")
+ sleep 30.minutes
Fiber.yield
end
end
diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr
index 926c27fa..4b52c959 100644
--- a/src/invidious/jobs/refresh_feeds_job.cr
+++ b/src/invidious/jobs/refresh_feeds_job.cr
@@ -25,7 +25,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
spawn do
begin
# Drop outdated views
- column_array = get_column_array(db, view_name)
+ column_array = Invidious::Database.get_column_array(db, view_name)
ChannelVideo.type_array.each_with_index do |name, i|
if name != column_array[i]?
LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}")
diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr
index 6569c0a1..a113bd77 100644
--- a/src/invidious/jobs/statistics_refresh_job.cr
+++ b/src/invidious/jobs/statistics_refresh_job.cr
@@ -47,12 +47,14 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
private def refresh_stats
users = STATISTICS.dig("usage", "users").as(Hash(String, Int64))
- users["total"] = db.query_one("SELECT count(*) FROM users", as: Int64)
- users["activeHalfyear"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64)
- users["activeMonth"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64)
+
+ users["total"] = Invidious::Database::Statistics.count_users_total
+ users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_1m
+ users["activeMonth"] = Invidious::Database::Statistics.count_users_active_6m
+
STATISTICS["metadata"] = {
"updatedAt" => Time.utc.to_unix,
- "lastChannelRefreshedAt" => db.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64,
+ "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64,
}
end
end
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index 55b01174..3f342b92 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -72,7 +72,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
videos += next_page.videos
end
- videos.uniq! { |video| video.id }
+ videos.uniq!(&.id)
videos = videos.first(50)
return Mix.new({
title: mix_title,
@@ -97,7 +97,7 @@ def template_mix(mix)
<li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<div class="thumbnail">
- <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p>
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index f56cc2ea..a09e6cdb 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -11,7 +11,7 @@ struct PlaylistVideo
property index : Int64
property live_now : Bool
- def to_xml(auto_generated, xml : XML::Builder)
+ def to_xml(xml : XML::Builder)
xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{self.id}" }
xml.element("yt:videoId") { xml.text self.id }
@@ -20,13 +20,8 @@ struct PlaylistVideo
xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}")
xml.element("author") do
- if auto_generated
- xml.element("name") { xml.text self.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
- else
- xml.element("name") { xml.text author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
- end
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
end
xml.element("content", type: "xhtml") do
@@ -47,17 +42,11 @@ struct PlaylistVideo
end
end
- def to_xml(auto_generated, xml : XML::Builder? = nil)
- if xml
- to_xml(auto_generated, xml)
- else
- XML.build do |json|
- to_xml(auto_generated, xml)
- end
- end
+ def to_xml(_xml : Nil = nil)
+ XML.build { |xml| to_xml(xml) }
end
- def to_json(locale, json : JSON::Builder, index : Int32?)
+ def to_json(json : JSON::Builder, index : Int32? = nil)
json.object do
json.field "title", self.title
json.field "videoId", self.id
@@ -81,14 +70,8 @@ struct PlaylistVideo
end
end
- def to_json(locale, json : JSON::Builder? = nil, index : Int32? = nil)
- if json
- to_json(locale, json, index: index)
- else
- JSON.build do |json|
- to_json(locale, json, index: index)
- end
- end
+ def to_json(_json : Nil, index : Int32? = nil)
+ JSON.build { |json| to_json(json, index: index) }
end
end
@@ -107,7 +90,7 @@ struct Playlist
property updated : Time
property thumbnail : String?
- def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil)
json.object do
json.field "type", "playlist"
json.field "title", self.title
@@ -142,21 +125,21 @@ struct Playlist
json.field "videos" do
json.array do
- videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
- videos.each_with_index do |video, index|
- video.to_json(locale, json)
+ videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id)
+ videos.each do |video|
+ video.to_json(json)
end
end
end
end
end
- def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil)
if json
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
else
JSON.build do |json|
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
end
end
end
@@ -196,7 +179,7 @@ struct InvidiousPlaylist
end
end
- def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil)
json.object do
json.field "type", "invidiousPlaylist"
json.field "title", self.title
@@ -217,32 +200,33 @@ struct InvidiousPlaylist
json.field "videos" do
json.array do
- if !offset || offset == 0
- index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64)
+ if (!offset || offset == 0) && !video_id.nil?
+ index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id)
offset = self.index.index(index) || 0
end
- videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
+ videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id)
videos.each_with_index do |video, index|
- video.to_json(locale, json, offset + index)
+ video.to_json(json, offset + index)
end
end
end
end
end
- def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil)
+ def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil)
if json
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
else
JSON.build do |json|
- to_json(offset, locale, json, continuation: continuation)
+ to_json(offset, locale, json, video_id: video_id)
end
end
end
def thumbnail
- @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------"
+ # TODO: Get playlist thumbnail from playlist data rather than first video
+ @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------"
"/vi/#{@thumbnail_id}/mqdefault.jpg"
end
@@ -259,11 +243,11 @@ struct InvidiousPlaylist
end
def description_html
- HTML.escape(self.description).gsub("\n", "<br>")
+ HTML.escape(self.description)
end
end
-def create_playlist(db, title, privacy, user)
+def create_playlist(title, privacy, user)
plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}"
playlist = InvidiousPlaylist.new({
@@ -278,15 +262,12 @@ def create_playlist(db, title, privacy, user)
index: [] of Int64,
})
- playlist_array = playlist.to_a
- args = arg_array(playlist_array)
-
- db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array)
+ Invidious::Database::Playlists.insert(playlist)
return playlist
end
-def subscribe_playlist(db, user, playlist)
+def subscribe_playlist(user, playlist)
playlist = InvidiousPlaylist.new({
title: playlist.title.byte_slice(0, 150),
id: playlist.id,
@@ -299,10 +280,7 @@ def subscribe_playlist(db, user, playlist)
index: [] of Int64,
})
- playlist_array = playlist.to_a
- args = arg_array(playlist_array)
-
- db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array)
+ Invidious::Database::Playlists.insert(playlist)
return playlist
end
@@ -322,21 +300,19 @@ def produce_playlist_continuation(id, index)
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i, padding: false) }
- data_wrapper = {"1:varint" => request_count, "15:string" => "PT:#{data}"}
- .try { |i| Protodec::Any.cast_json(i) }
- .try { |i| Protodec::Any.from_json(i) }
- .try { |i| Base64.urlsafe_encode(i) }
- .try { |i| URI.encode_www_form(i) }
-
object = {
"80226972:embedded" => {
- "2:string" => plid,
- "3:string" => data_wrapper,
+ "2:string" => plid,
+ "3:base64" => {
+ "1:varint" => request_count,
+ "15:string" => "PT:#{data}",
+ "104:embedded" => {"1:0:varint" => 0_i64},
+ },
"35:string" => id,
},
}
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
@@ -344,9 +320,9 @@ def produce_playlist_continuation(id, index)
return continuation
end
-def get_playlist(db, plid, locale, refresh = true, force_refresh = false)
+def get_playlist(plid, locale, refresh = true, force_refresh = false)
if plid.starts_with? "IV"
- if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if playlist = Invidious::Database::Playlists.select(id: plid)
return playlist
else
raise InfoException.new("Playlist does not exist.")
@@ -369,7 +345,7 @@ def fetch_playlist(plid, locale)
playlist_info = playlist_sidebar_renderer[0]["playlistSidebarPrimaryInfoRenderer"]?
raise InfoException.new("Could not extract playlist info") if !playlist_info
- title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || ""
+ title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
desc_item = playlist_info["description"]?
@@ -426,7 +402,7 @@ def fetch_playlist(plid, locale)
})
end
-def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil)
+def get_playlist_videos(playlist, offset, locale = nil, video_id = nil)
# Show empy playlist if requested page is out of range
# (e.g, when a new playlist has been created, offset will be negative)
if offset >= playlist.video_count || offset < 0
@@ -434,20 +410,28 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil)
end
if playlist.is_a? InvidiousPlaylist
- db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3",
- playlist.id, playlist.index, offset, as: PlaylistVideo)
+ Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100)
else
- if offset >= 100
- # Normalize offset to match youtube's behavior (100 videos chunck per request)
- offset = (offset / 100).to_i64 * 100_i64
+ if video_id
+ initial_data = YoutubeAPI.next({
+ "videoId" => video_id,
+ "playlistId" => playlist.id,
+ })
+ offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
+ end
+
+ videos = [] of PlaylistVideo
+ until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
+ # 100 videos per request
ctoken = produce_playlist_continuation(playlist.id, offset)
initial_data = YoutubeAPI.browse(ctoken)
- else
- initial_data = YoutubeAPI.browse("VL" + playlist.id, params: "")
+ videos += extract_playlist_videos(initial_data)
+
+ offset += 100
end
- return extract_playlist_videos(initial_data)
+ return videos
end
end
@@ -523,10 +507,10 @@ def template_playlist(playlist)
playlist["videos"].as_a.each do |video|
html += <<-END_HTML
- <li class="pure-menu-item">
- <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}">
+ <li class="pure-menu-item" id="#{video["videoId"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<div class="thumbnail">
- <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p>
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index 93bee55c..b6183001 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -13,7 +13,7 @@ module Invidious::Routes::API::Manifest
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
begin
- video = get_video(id, PG_DB, region: region)
+ video = get_video(id, region: region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex
@@ -47,7 +47,7 @@ module Invidious::Routes::API::Manifest
end
audio_streams = video.audio_streams
- video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse
+ video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse!
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
@@ -160,7 +160,7 @@ module Invidious::Routes::API::Manifest
manifest = response.body
if local
- manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
+ manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
path = URI.parse(match).path
path = path.lchop("/videoplayback/")
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index b4e9e9c8..fda655ef 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -22,12 +22,11 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
begin
- preferences = Preferences.from_json(env.request.body || "{}")
+ user.preferences = Preferences.from_json(env.request.body || "{}")
rescue
- preferences = user.preferences
end
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+ Invidious::Database::Users.update_preferences(user)
env.response.status_code = 204
end
@@ -36,7 +35,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "application/json"
user = env.get("user").as(User)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
max_results = env.params.query["max_results"]?.try &.to_i?
max_results ||= user.preferences.max_results
@@ -45,7 +44,7 @@ module Invidious::Routes::API::V1::Authenticated
page = env.params.query["page"]?.try &.to_i?
page ||= 1
- videos, notifications = get_subscription_feed(PG_DB, user, max_results, page)
+ videos, notifications = get_subscription_feed(user, max_results, page)
JSON.build do |json|
json.object do
@@ -72,13 +71,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "application/json"
user = env.get("user").as(User)
- if user.subscriptions.empty?
- values = "'{}'"
- else
- values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}"
- end
-
- subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel)
+ subscriptions = Invidious::Database::Channels.select(user.subscriptions)
JSON.build do |json|
json.array do
@@ -99,8 +92,8 @@ module Invidious::Routes::API::V1::Authenticated
ucid = env.params.url["ucid"]
if !user.subscriptions.includes? ucid
- get_channel(ucid, PG_DB, false, false)
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
+ get_channel(ucid, false, false)
+ Invidious::Database::Users.subscribe_channel(user, ucid)
end
# For Google accounts, access tokens don't have enough information to
@@ -116,18 +109,18 @@ module Invidious::Routes::API::V1::Authenticated
ucid = env.params.url["ucid"]
- PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email)
+ Invidious::Database::Users.unsubscribe_channel(user, ucid)
env.response.status_code = 204
end
def self.list_playlists(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
- playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist)
+ playlists = Invidious::Database::Playlists.select_all(author: user.email)
JSON.build do |json|
json.array do
@@ -141,7 +134,7 @@ module Invidious::Routes::API::V1::Authenticated
def self.create_playlist(env)
env.response.content_type = "application/json"
user = env.get("user").as(User)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
if !title
@@ -153,11 +146,11 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(400, "Invalid privacy setting.")
end
- if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
+ if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
return error_json(400, "User cannot have more than 100 playlists.")
end
- playlist = create_playlist(PG_DB, title, privacy, user)
+ playlist = create_playlist(title, privacy, user)
env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
env.response.status_code = 201
{
@@ -167,14 +160,17 @@ module Invidious::Routes::API::V1::Authenticated
end
def self.update_playlist_attribute(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
- plid = env.params.url["plid"]
+ plid = env.params.url["plid"]?
+ if !plid || plid.empty?
+ return error_json(400, "A playlist ID is required")
+ end
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
return error_json(404, "Playlist does not exist.")
end
@@ -195,19 +191,20 @@ module Invidious::Routes::API::V1::Authenticated
updated = playlist.updated
end
- PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid)
+ Invidious::Database::Playlists.update(plid, title, privacy, description, updated)
+
env.response.status_code = 204
end
def self.delete_playlist(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
plid = env.params.url["plid"]
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
return error_json(404, "Playlist does not exist.")
end
@@ -216,21 +213,20 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(403, "Invalid user")
end
- PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
- PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid)
+ Invidious::Database::Playlists.delete(plid)
env.response.status_code = 204
end
def self.insert_video_into_playlist(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
plid = env.params.url["plid"]
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
return error_json(404, "Playlist does not exist.")
end
@@ -249,7 +245,7 @@ module Invidious::Routes::API::V1::Authenticated
end
begin
- video = get_video(video_id, PG_DB)
+ video = get_video(video_id)
rescue ex
return error_json(500, ex)
end
@@ -266,19 +262,19 @@ module Invidious::Routes::API::V1::Authenticated
index: Random::Secure.rand(0_i64..Int64::MAX),
})
- video_array = playlist_video.to_a
- args = arg_array(video_array)
-
- PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
- PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid)
+ Invidious::Database::PlaylistVideos.insert(playlist_video)
+ Invidious::Database::Playlists.update_video_added(plid, playlist_video.index)
env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}"
env.response.status_code = 201
- playlist_video.to_json(locale, index: playlist.index.size)
+
+ JSON.build do |json|
+ playlist_video.to_json(json, index: playlist.index.size)
+ end
end
def self.delete_video_in_playlist(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
@@ -286,7 +282,7 @@ module Invidious::Routes::API::V1::Authenticated
plid = env.params.url["plid"]
index = env.params.url["index"].to_i64(16)
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email && playlist.privacy.private?
return error_json(404, "Playlist does not exist.")
end
@@ -299,8 +295,8 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(404, "Playlist does not contain index")
end
- PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
- PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid)
+ Invidious::Database::PlaylistVideos.delete(index)
+ Invidious::Database::Playlists.update_video_removed(plid, index)
env.response.status_code = 204
end
@@ -315,7 +311,7 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
scopes = env.get("scopes").as(Array(String))
- tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time})
+ tokens = Invidious::Database::SessionIDs.select_all(user.email)
JSON.build do |json|
json.array do
@@ -331,15 +327,15 @@ module Invidious::Routes::API::V1::Authenticated
def self.register_token(env)
user = env.get("user").as(User)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
case env.request.headers["Content-Type"]?
when "application/x-www-form-urlencoded"
- scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
+ scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
callback_url = env.params.body["callbackUrl"]?
expire = env.params.body["expire"]?.try &.to_i?
when "application/json"
- scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s }
+ scopes = env.params.json["scopes"].as(Array).map(&.as_s)
callback_url = env.params.json["callbackUrl"]?.try &.as(String)
expire = env.params.json["expire"]?.try &.as(Int64)
else
@@ -357,7 +353,7 @@ module Invidious::Routes::API::V1::Authenticated
if sid = env.get?("sid").try &.as(String)
env.response.content_type = "text/html"
- csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true)
+ csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
return templated "authorize_token"
else
env.response.content_type = "application/json"
@@ -371,7 +367,7 @@ module Invidious::Routes::API::V1::Authenticated
end
end
- access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB)
+ access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY)
if callback_url
access_token = URI.encode_www_form(access_token)
@@ -393,7 +389,7 @@ module Invidious::Routes::API::V1::Authenticated
end
def self.unregister_token(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
user = env.get("user").as(User)
scopes = env.get("scopes").as(Array(String))
@@ -403,9 +399,9 @@ module Invidious::Routes::API::V1::Authenticated
# Allow tokens to revoke other tokens with correct scope
if session == env.get("session").as(String)
- PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
+ Invidious::Database::SessionIDs.delete(sid: session)
elsif scopes_include_scope(scopes, "GET:tokens")
- PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
+ Invidious::Database::SessionIDs.delete(sid: session)
else
return error_json(400, "Cannot revoke session #{session}")
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index da39661c..322ac42e 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -1,6 +1,6 @@
module Invidious::Routes::API::V1::Channels
def self.home(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Channels
json.field "relatedChannels" do
json.array do
- channel.related_channels.each do |related_channel|
+ fetch_related_channels(channel).each do |related_channel|
json.object do
json.field "author", related_channel.author
json.field "authorId", related_channel.ucid
@@ -124,7 +124,7 @@ module Invidious::Routes::API::V1::Channels
end
def self.latest(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -146,7 +146,7 @@ module Invidious::Routes::API::V1::Channels
end
def self.videos(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -182,7 +182,7 @@ module Invidious::Routes::API::V1::Channels
end
def self.playlists(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -219,7 +219,7 @@ module Invidious::Routes::API::V1::Channels
end
def self.community(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -242,7 +242,7 @@ module Invidious::Routes::API::V1::Channels
end
def self.search(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr
index bb8f661b..41865f34 100644
--- a/src/invidious/routes/api/v1/feeds.cr
+++ b/src/invidious/routes/api/v1/feeds.cr
@@ -1,6 +1,6 @@
module Invidious::Routes::API::V1::Feeds
def self.trending(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -25,7 +25,7 @@ module Invidious::Routes::API::V1::Feeds
end
def self.popular(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index cf95bd9b..ac0576a0 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -1,7 +1,7 @@
module Invidious::Routes::API::V1::Misc
# Stats API endpoint for Invidious
def self.stats(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
if !CONFIG.statistics_enabled
@@ -15,7 +15,7 @@ module Invidious::Routes::API::V1::Misc
# user playlists and Invidious playlists. This means that we can't
# reasonably split them yet. This should be addressed in APIv2
def self.get_playlist(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
plid = env.params.url["plid"]
@@ -24,7 +24,7 @@ module Invidious::Routes::API::V1::Misc
offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 }
offset ||= 0
- continuation = env.params.query["continuation"]?
+ video_id = env.params.query["continuation"]?
format = env.params.query["format"]?
format ||= "json"
@@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Misc
end
begin
- playlist = get_playlist(PG_DB, plid, locale)
+ playlist = get_playlist(plid, locale)
rescue ex : InfoException
return error_json(404, ex)
rescue ex
@@ -46,12 +46,32 @@ module Invidious::Routes::API::V1::Misc
return error_json(404, "Playlist does not exist.")
end
- response = playlist.to_json(offset, locale, continuation: continuation)
+ # includes into the playlist a maximum of 20 videos, before the offset
+ if offset > 0
+ lookback = offset < 50 ? offset : 50
+ response = playlist.to_json(offset - lookback, locale)
+ json_response = JSON.parse(response)
+ else
+ # Unless the continuation is really the offset 0, it becomes expensive.
+ # It happens when the offset is not set.
+ # First we find the actual offset, and then we lookback
+ # it shouldn't happen often though
+
+ lookback = 0
+ response = playlist.to_json(offset, locale, video_id: video_id)
+ json_response = JSON.parse(response)
+
+ if json_response["videos"].as_a[0]["index"] != offset
+ offset = json_response["videos"].as_a[0]["index"].as_i
+ lookback = offset < 50 ? offset : 50
+ response = playlist.to_json(offset - lookback, locale)
+ json_response = JSON.parse(response)
+ end
+ end
if format == "html"
- response = JSON.parse(response)
- playlist_html = template_playlist(response)
- index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
+ playlist_html = template_playlist(json_response)
+ index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
response = {
"playlistHtml" => playlist_html,
@@ -64,7 +84,7 @@ module Invidious::Routes::API::V1::Misc
end
def self.mixes(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr
index f3a6fa06..a3b6c795 100644
--- a/src/invidious/routes/api/v1/search.cr
+++ b/src/invidious/routes/api/v1/search.cr
@@ -1,6 +1,6 @@
module Invidious::Routes::API::V1::Search
def self.search(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]?
env.response.content_type = "application/json"
@@ -20,7 +20,7 @@ module Invidious::Routes::API::V1::Search
duration = env.params.query["duration"]?.try &.downcase
duration ||= ""
- features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase }
+ features = env.params.query["features"]?.try &.split(",").map(&.downcase)
features ||= [] of String
content_type = env.params.query["type"]?.try &.downcase
@@ -43,7 +43,7 @@ module Invidious::Routes::API::V1::Search
end
def self.search_suggestions(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]?
env.response.content_type = "application/json"
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 575e6fdf..3a013ba0 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -1,6 +1,6 @@
module Invidious::Routes::API::V1::Videos
def self.videos(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Videos
region = env.params.query["region"]?
begin
- video = get_video(id, PG_DB, region: region)
+ video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
@@ -16,11 +16,11 @@ module Invidious::Routes::API::V1::Videos
return error_json(500, ex)
end
- video.to_json(locale)
+ video.to_json(locale, nil)
end
def self.captions(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -36,7 +36,7 @@ module Invidious::Routes::API::V1::Videos
# getting video info.
begin
- video = get_video(id, PG_DB, region: region)
+ video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
@@ -58,7 +58,7 @@ module Invidious::Routes::API::V1::Videos
captions.each do |caption|
json.object do
json.field "label", caption.name
- json.field "languageCode", caption.languageCode
+ json.field "languageCode", caption.language_code
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
end
end
@@ -73,7 +73,7 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "text/vtt; charset=UTF-8"
if lang
- caption = captions.select { |caption| caption.languageCode == lang }
+ caption = captions.select { |caption| caption.language_code == lang }
else
caption = captions.select { |caption| caption.name == label }
end
@@ -84,7 +84,7 @@ module Invidious::Routes::API::V1::Videos
caption = caption[0]
end
- url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target
+ url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target
# Auto-generated captions often have cues that aren't aligned properly with the video,
# as well as some other markup that makes it cumbersome, so we try to fix that here
@@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Videos
str << <<-END_VTT
WEBVTT
Kind: captions
- Language: #{tlang || caption.languageCode}
+ Language: #{tlang || caption.language_code}
END_VTT
@@ -149,7 +149,7 @@ module Invidious::Routes::API::V1::Videos
# thumbnails for individual scenes in a video.
# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
def self.storyboards(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
@@ -157,7 +157,7 @@ module Invidious::Routes::API::V1::Videos
region = env.params.query["region"]?
begin
- video = get_video(id, PG_DB, region: region)
+ video = get_video(id, region: region)
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
@@ -223,7 +223,7 @@ module Invidious::Routes::API::V1::Videos
end
def self.annotations(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "text/xml"
@@ -239,7 +239,7 @@ module Invidious::Routes::API::V1::Videos
case source
when "archive"
- if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation))
+ if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id))
annotations = cached_annotation.annotations
else
index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0')
@@ -271,7 +271,7 @@ module Invidious::Routes::API::V1::Videos
annotations = response.body
- cache_annotation(PG_DB, id, annotations)
+ cache_annotation(id, annotations)
end
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
@@ -293,7 +293,7 @@ module Invidious::Routes::API::V1::Videos
end
def self.comments(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]?
env.response.content_type = "application/json"
@@ -330,18 +330,13 @@ module Invidious::Routes::API::V1::Videos
begin
comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by)
- content_html = template_reddit_comments(comments, locale)
-
- content_html = fill_links(content_html, "https", "www.reddit.com")
- content_html = replace_links(content_html)
rescue ex
comments = nil
reddit_thread = nil
- content_html = ""
end
if !reddit_thread || !comments
- haltf env, 404
+ return error_json(404, "No reddit threads found")
end
if format == "json"
@@ -350,6 +345,9 @@ module Invidious::Routes::API::V1::Videos
return reddit_thread.to_json
else
+ content_html = template_reddit_comments(comments, locale)
+ content_html = fill_links(content_html, "https", "www.reddit.com")
+ content_html = replace_links(content_html)
response = {
"title" => reddit_thread.title,
"permalink" => reddit_thread.permalink,
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 6a32988e..6cb1e1f7 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -1,3 +1,5 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Channels
def self.home(env)
self.videos(env)
@@ -27,8 +29,8 @@ module Invidious::Routes::Channels
item.author
end
end
- items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist))
- items.each { |item| item.author = "" }
+ items = items.select(SearchPlaylist).map(&.as(SearchPlaylist))
+ items.each(&.author = "")
else
sort_options = {"newest", "oldest", "popular"}
sort_by ||= "newest"
@@ -55,8 +57,8 @@ module Invidious::Routes::Channels
end
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
- items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) }
- items.each { |item| item.author = "" }
+ items = items.select(SearchPlaylist).map(&.as(SearchPlaylist))
+ items.each(&.author = "")
templated "playlists"
end
@@ -102,7 +104,7 @@ module Invidious::Routes::Channels
# Redirects brand url channels to a normal /channel/:ucid route
def self.brand_redirect(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
# /attribution_link endpoint needs both the `a` and `u` parameter
# and in order to avoid detection from YouTube we should only send the required ones
@@ -146,7 +148,7 @@ module Invidious::Routes::Channels
end
private def self.fetch_basic_information(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
if user
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 5fc8a61f..ab722ae2 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -1,12 +1,14 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Embed
def self.redirect(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
begin
- playlist = get_playlist(PG_DB, plid, locale: locale)
+ playlist = get_playlist(plid, locale: locale)
offset = env.params.query["index"]?.try &.to_i? || 0
- videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
+ videos = get_playlist_videos(playlist, offset: offset, locale: locale)
rescue ex
return error_template(500, ex)
end
@@ -24,11 +26,11 @@ module Invidious::Routes::Embed
end
def self.show(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
id = env.params.url["id"]
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
- continuation = process_continuation(PG_DB, env.params.query, plid, id)
+ continuation = process_continuation(env.params.query, plid, id)
if md = env.params.query["playlist"]?
.try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/)
@@ -58,9 +60,9 @@ module Invidious::Routes::Embed
if plid
begin
- playlist = get_playlist(PG_DB, plid, locale: locale)
+ playlist = get_playlist(plid, locale: locale)
offset = env.params.query["index"]?.try &.to_i? || 0
- videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
+ videos = get_playlist_videos(playlist, offset: offset, locale: locale)
rescue ex
return error_template(500, ex)
end
@@ -117,7 +119,7 @@ module Invidious::Routes::Embed
subscriptions ||= [] of String
begin
- video = get_video(id, PG_DB, region: params.region)
+ video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex
@@ -135,7 +137,7 @@ module Invidious::Routes::Embed
# end
if notifications && notifications.includes? id
- PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email)
+ Invidious::Database::Users.remove_notification(user.as(User), id)
env.get("user").as(User).notifications.delete(id)
notifications.delete(id)
end
@@ -166,11 +168,11 @@ module Invidious::Routes::Embed
preferred_captions = captions.select { |caption|
params.preferred_captions.includes?(caption.name) ||
- params.preferred_captions.includes?(caption.languageCode.split("-")[0])
+ params.preferred_captions.includes?(caption.language_code.split("-")[0])
}
preferred_captions.sort_by! { |caption|
(params.preferred_captions.index(caption.name) ||
- params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil!
+ params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil!
}
captions = captions - preferred_captions
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index c88e96cf..fd8c25ce 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -1,10 +1,12 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Feeds
def self.view_all_playlists_redirect(env)
env.redirect "/feed/playlists"
end
def self.playlists(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
referer = get_referer(env)
@@ -13,13 +15,14 @@ module Invidious::Routes::Feeds
user = user.as(User)
- items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
+ # TODO: make a single DB call and separate the items here?
+ items_created = Invidious::Database::Playlists.select_like_iv(user.email)
items_created.map! do |item|
item.author = ""
item
end
- items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
+ items_saved = Invidious::Database::Playlists.select_not_like_iv(user.email)
items_saved.map! do |item|
item.author = ""
item
@@ -29,7 +32,7 @@ module Invidious::Routes::Feeds
end
def self.popular(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
if CONFIG.popular_enabled
templated "feeds/popular"
@@ -40,13 +43,13 @@ module Invidious::Routes::Feeds
end
def self.trending(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
trending_type = env.params.query["type"]?
trending_type ||= "Default"
region = env.params.query["region"]?
- region ||= "US"
+ region ||= env.get("preferences").as(Preferences).region
begin
trending, plid = fetch_trending(trending_type, region, locale)
@@ -58,7 +61,7 @@ module Invidious::Routes::Feeds
end
def self.subscriptions(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -81,7 +84,7 @@ module Invidious::Routes::Feeds
headers["Cookie"] = env.request.headers["Cookie"]
if !user.password
- user, sid = get_user(sid, headers, PG_DB)
+ user, sid = get_user(sid, headers)
end
max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE)
@@ -91,14 +94,13 @@ module Invidious::Routes::Feeds
page = env.params.query["page"]?.try &.to_i?
page ||= 1
- videos, notifications = get_subscription_feed(PG_DB, user, max_results, page)
+ videos, notifications = get_subscription_feed(user, max_results, page)
# "updated" here is used for delivering new notifications, so if
# we know a user has looked at their feed e.g. in the past 10 minutes,
# they've already seen a video posted 20 minutes ago, and don't need
# to be notified.
- PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc,
- user.email)
+ Invidious::Database::Users.clear_notifications(user)
user.notifications = [] of String
env.set "user", user
@@ -106,7 +108,7 @@ module Invidious::Routes::Feeds
end
def self.history(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
referer = get_referer(env)
@@ -135,7 +137,7 @@ module Invidious::Routes::Feeds
# RSS feeds
def self.rss_channel(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml"
@@ -207,7 +209,7 @@ module Invidious::Routes::Feeds
end
def self.rss_private(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml"
@@ -218,7 +220,7 @@ module Invidious::Routes::Feeds
haltf env, status_code: 403
end
- user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User)
+ user = Invidious::Database::Users.select(token: token.strip)
if !user
haltf env, status_code: 403
end
@@ -232,7 +234,7 @@ module Invidious::Routes::Feeds
params = HTTP::Params.parse(env.params.query["params"]? || "")
- videos, notifications = get_subscription_feed(PG_DB, user, max_results, page)
+ videos, notifications = get_subscription_feed(user, max_results, page)
XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
@@ -251,7 +253,7 @@ module Invidious::Routes::Feeds
end
def self.rss_playlist(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml"
@@ -262,8 +264,8 @@ module Invidious::Routes::Feeds
path = env.request.path
if plid.starts_with? "IV"
- if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale)
+ if playlist = Invidious::Database::Playlists.select(id: plid)
+ videos = get_playlist_videos(playlist, offset: 0, locale: locale)
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
@@ -279,9 +281,7 @@ module Invidious::Routes::Feeds
xml.element("name") { xml.text playlist.author }
end
- videos.each do |video|
- video.to_xml(false, xml)
- end
+ videos.each &.to_xml(xml)
end
end
else
@@ -364,7 +364,7 @@ module Invidious::Routes::Feeds
if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]?
PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid)
elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]?
- PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid)
+ Invidious::Database::Playlists.update_subscription_time(plid)
else
haltf env, status_code: 400
end
@@ -374,7 +374,7 @@ module Invidious::Routes::Feeds
end
def self.push_notifications_post(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
token = env.params.url["token"]
body = env.request.body.not_nil!.gets_to_end
@@ -393,7 +393,7 @@ module Invidious::Routes::Feeds
published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
- video = get_video(id, PG_DB, force_refresh: true)
+ video = get_video(id, force_refresh: true)
# Deliver notifications to `/api/v1/auth/notifications`
payload = {
@@ -416,13 +416,8 @@ module Invidious::Routes::Feeds
views: video.views,
})
- was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
- ON CONFLICT (id) DO UPDATE SET title = $2, published = $3,
- updated = $4, ucid = $5, author = $6, length_seconds = $7,
- live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
-
- PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1),
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert
+ was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
+ Invidious::Database::Users.add_notification(video) if was_insert
end
end
diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr
new file mode 100644
index 00000000..594a7869
--- /dev/null
+++ b/src/invidious/routes/images.cr
@@ -0,0 +1,309 @@
+module Invidious::Routes::Images
+ # Avatars, banners and other large image assets.
+ def self.ggpht(env)
+ url = env.request.path.lchop("/ggpht")
+
+ headers = (
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ HTTP::Headers{":authority" => "yt3.ggpht.com"}
+ else
+ HTTP::Headers.new
+ end
+ {% else %}
+ HTTP::Headers.new
+ {% end %}
+ )
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ # We're encapsulating this into a proc in order to easily reuse this
+ # portion of the code for each request block below.
+ request_proc = ->(response : HTTP::Client::Response) {
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300
+ env.response.headers.delete("Transfer-Encoding")
+ return
+ end
+
+ proxy_file(response, env)
+ }
+
+ begin
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ YT_POOL.client &.get(url, headers) do |resp|
+ return request_proc.call(resp)
+ end
+ else
+ HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ end
+ {% else %}
+ # This can likely be optimized into a (small) pool sometime in the future.
+ HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ {% end %}
+ rescue ex
+ end
+ end
+
+ def self.options_storyboard(env)
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+ env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
+ env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
+ end
+
+ def self.get_storyboard(env)
+ authority = env.params.url["authority"]
+ id = env.params.url["id"]
+ storyboard = env.params.url["storyboard"]
+ index = env.params.url["index"]
+
+ url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
+
+ headers = HTTP::Headers.new
+
+ {% unless flag?(:disable_quic) %}
+ headers[":authority"] = "#{authority}.ytimg.com"
+ {% end %}
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ request_proc = ->(response : HTTP::Client::Response) {
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Connection"] = "close"
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300
+ return env.response.headers.delete("Transfer-Encoding")
+ end
+
+ proxy_file(response, env)
+ }
+
+ begin
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ YT_POOL.client &.get(url, headers) do |resp|
+ return request_proc.call(resp)
+ end
+ else
+ HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ end
+ {% else %}
+ # This can likely be optimized into a (small) pool sometime in the future.
+ HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ {% end %}
+ rescue ex
+ end
+ end
+
+ # ??? maybe also for storyboards?
+ def self.s_p_image(env)
+ id = env.params.url["id"]
+ name = env.params.url["name"]
+ url = env.request.resource
+
+ headers = (
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ HTTP::Headers{":authority" => "i9.ytimg.com"}
+ else
+ HTTP::Headers.new
+ end
+ {% else %}
+ HTTP::Headers.new
+ {% end %}
+ )
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ request_proc = ->(response : HTTP::Client::Response) {
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ return env.response.headers.delete("Transfer-Encoding")
+ end
+
+ proxy_file(response, env)
+ }
+
+ begin
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ YT_POOL.client &.get(url, headers) do |resp|
+ return request_proc.call(resp)
+ end
+ else
+ HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ end
+ {% else %}
+ # This can likely be optimized into a (small) pool sometime in the future.
+ HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ {% end %}
+ rescue ex
+ end
+ end
+
+ def self.yts_image(env)
+ headers = HTTP::Headers.new
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ YT_POOL.client &.get(env.request.resource, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+ end
+
+ def self.thumbnails(env)
+ id = env.params.url["id"]
+ name = env.params.url["name"]
+
+ headers = (
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ HTTP::Headers{":authority" => "i.ytimg.com"}
+ else
+ HTTP::Headers.new
+ end
+ {% else %}
+ HTTP::Headers.new
+ {% end %}
+ )
+
+ if name == "maxres.jpg"
+ build_thumbnails(id).each do |thumb|
+ thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
+ # Logic here is short enough that manually typing them out should be fine.
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ if YT_POOL.client &.head(thumbnail_resource_path, headers).status_code == 200
+ name = thumb[:url] + ".jpg"
+ break
+ end
+ else
+ if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200
+ name = thumb[:url] + ".jpg"
+ break
+ end
+ end
+ {% else %}
+ # This can likely be optimized into a (small) pool sometime in the future.
+ if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200
+ name = thumb[:url] + ".jpg"
+ break
+ end
+ {% end %}
+ end
+ end
+
+ url = "/vi/#{id}/#{name}"
+
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ request_proc = ->(response : HTTP::Client::Response) {
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ return env.response.headers.delete("Transfer-Encoding")
+ end
+
+ proxy_file(response, env)
+ }
+
+ begin
+ {% unless flag?(:disable_quic) %}
+ if CONFIG.use_quic
+ YT_POOL.client &.get(url, headers) do |resp|
+ return request_proc.call(resp)
+ end
+ else
+ HTTP::Client.get("https://i.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ end
+ {% else %}
+ # This can likely be optimized into a (small) pool sometime in the future.
+ HTTP::Client.get("https://i.ytimg.com#{url}") do |resp|
+ return request_proc.call(resp)
+ end
+ {% end %}
+ rescue ex
+ end
+ end
+end
diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr
index f052d3f4..64da3e4e 100644
--- a/src/invidious/routes/login.cr
+++ b/src/invidious/routes/login.cr
@@ -1,6 +1,8 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Login
def self.login_page(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
@@ -29,7 +31,7 @@ module Invidious::Routes::Login
end
def self.login(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env, "/feed/subscriptions")
@@ -51,7 +53,13 @@ module Invidious::Routes::Login
# See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
begin
- client = QUIC::Client.new(LOGIN_URL)
+ client = nil # Declare variable
+ {% unless flag?(:disable_quic) %}
+ client = CONFIG.use_quic ? QUIC::Client.new(LOGIN_URL) : HTTP::Client.new(LOGIN_URL)
+ {% else %}
+ client = HTTP::Client.new(LOGIN_URL)
+ {% end %}
+
headers = HTTP::Headers.new
login_page = client.get("/ServiceLogin")
@@ -267,7 +275,7 @@ module Invidious::Routes::Login
raise "Couldn't get SID."
end
- user, sid = get_user(sid, headers, PG_DB)
+ user, sid = get_user(sid, headers)
# We are now logged in
traceback << "done.<br/>"
@@ -295,8 +303,8 @@ module Invidious::Routes::Login
end
if env.request.cookies["PREFS"]?
- preferences = env.get("preferences").as(Preferences)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+ user.preferences = env.get("preferences").as(Preferences)
+ Invidious::Database::Users.update_preferences(user)
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
@@ -319,7 +327,7 @@ module Invidious::Routes::Login
return error_template(401, "Password is a required field")
end
- user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User)
+ user = Invidious::Database::Users.select(email: email)
if user
if !user.password
@@ -328,7 +336,7 @@ module Invidious::Routes::Login
if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
+ Invidious::Database::SessionIDs.insert(sid, email)
if Kemal.config.ssl || CONFIG.https_only
secure = true
@@ -385,15 +393,15 @@ module Invidious::Routes::Login
prompt = ""
if captcha_type == "image"
- captcha = generate_captcha(HMAC_KEY, PG_DB)
+ captcha = generate_captcha(HMAC_KEY)
else
- captcha = generate_text_captcha(HMAC_KEY, PG_DB)
+ captcha = generate_text_captcha(HMAC_KEY)
end
return templated "login"
end
- tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v }
+ tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
answer ||= ""
captcha_type ||= "image"
@@ -404,7 +412,7 @@ module Invidious::Routes::Login
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
begin
- validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(tokens[0], answer, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
@@ -417,9 +425,9 @@ module Invidious::Routes::Login
found_valid_captcha = false
error_exception = Exception.new
- tokens.each_with_index do |token, i|
+ tokens.each do |token|
begin
- validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, answer, env.request, HMAC_KEY, locale)
found_valid_captcha = true
rescue ex
error_exception = ex
@@ -441,13 +449,8 @@ module Invidious::Routes::Login
end
end
- user_array = user.to_a
- user_array[4] = user_array[4].to_json # User preferences
-
- args = arg_array(user_array)
-
- PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array)
- PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
+ Invidious::Database::Users.insert(user)
+ Invidious::Database::SessionIDs.insert(sid, email)
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
@@ -467,8 +470,8 @@ module Invidious::Routes::Login
end
if env.request.cookies["PREFS"]?
- preferences = env.get("preferences").as(Preferences)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+ user.preferences = env.get("preferences").as(Preferences)
+ Invidious::Database::Users.update_preferences(user)
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
@@ -483,7 +486,7 @@ module Invidious::Routes::Login
end
def self.signout(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -498,12 +501,12 @@ module Invidious::Routes::Login
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
- PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid)
+ Invidious::Database::SessionIDs.delete(sid: sid)
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr
index 82c40a95..d6bd9571 100644
--- a/src/invidious/routes/misc.cr
+++ b/src/invidious/routes/misc.cr
@@ -1,7 +1,9 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Misc
def self.home(env)
preferences = env.get("preferences").as(Preferences)
- locale = LOCALES[preferences.locale]?
+ locale = preferences.locale
user = env.get? "user"
case preferences.default_home
@@ -27,22 +29,17 @@ module Invidious::Routes::Misc
end
def self.privacy(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
templated "privacy"
end
def self.licenses(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
rendered "licenses"
end
def self.cross_instance_redirect(env)
referer = get_referer(env)
-
- if !env.get("preferences").as(Preferences).automatic_instance_redirect
- return env.redirect("https://redirect.invidious.io#{referer}")
- end
-
instance_url = fetch_random_instance
env.redirect "https://#{instance_url}#{referer}"
end
diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr
new file mode 100644
index 00000000..272a3dc7
--- /dev/null
+++ b/src/invidious/routes/notifications.cr
@@ -0,0 +1,78 @@
+module Invidious::Routes::Notifications
+ # /modify_notifications
+ # will "ding" all subscriptions.
+ # /modify_notifications?receive_all_updates=false&receive_no_updates=false
+ # will "unding" all subscriptions.
+ def self.modify(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/")
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "false"
+ redirect = redirect == "true"
+
+ if !user
+ if redirect
+ return env.redirect referer
+ else
+ return error_json(403, "No such user")
+ end
+ end
+
+ user = user.as(User)
+
+ if !user.password
+ channel_req = {} of String => String
+
+ channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true"
+ channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || ""
+ channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true"
+
+ channel_req.reject! { |k, v| v != "true" && v != "false" }
+
+ headers = HTTP::Headers.new
+ headers["Cookie"] = env.request.headers["Cookie"]
+
+ html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
+
+ cookies = HTTP::Cookies.from_client_headers(headers)
+ html.cookies.each do |cookie|
+ if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
+ if cookies[cookie.name]?
+ cookies[cookie.name] = cookie
+ else
+ cookies << cookie
+ end
+ end
+ end
+ headers = cookies.add_request_headers(headers)
+
+ if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
+ session_token = match["session_token"]
+ else
+ return env.redirect referer
+ end
+
+ headers["content-type"] = "application/x-www-form-urlencoded"
+ channel_req["session_token"] = session_token
+
+ subs = XML.parse_html(html.body)
+ subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel|
+ channel_id = channel.content.lstrip("/channel/").not_nil!
+ channel_req["channel_id"] = channel_id
+
+ YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req)
+ end
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
+end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index 05a198d8..d437b79c 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -1,6 +1,8 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Playlists
def self.new(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -10,13 +12,13 @@ module Invidious::Routes::Playlists
user = user.as(User)
sid = sid.as(String)
- csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY)
templated "create_playlist"
end
def self.create(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -29,7 +31,7 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
@@ -44,17 +46,17 @@ module Invidious::Routes::Playlists
return error_template(400, "Invalid privacy setting.")
end
- if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
+ if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
return error_template(400, "User cannot have more than 100 playlists.")
end
- playlist = create_playlist(PG_DB, title, privacy, user)
+ playlist = create_playlist(title, privacy, user)
env.redirect "/playlist?list=#{playlist.id}"
end
def self.subscribe(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
referer = get_referer(env)
@@ -64,14 +66,14 @@ module Invidious::Routes::Playlists
user = user.as(User)
playlist_id = env.params.query["list"]
- playlist = get_playlist(PG_DB, playlist_id, locale)
- subscribe_playlist(PG_DB, user, playlist)
+ playlist = get_playlist(playlist_id, locale)
+ subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}"
end
def self.delete_page(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -83,18 +85,22 @@ module Invidious::Routes::Playlists
sid = sid.as(String)
plid = env.params.query["list"]?
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !plid || plid.empty?
+ return error_template(400, "A playlist ID is required")
+ end
+
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email
return env.redirect referer
end
- csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY)
templated "delete_playlist"
end
def self.delete(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -110,24 +116,23 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email
return env.redirect referer
end
- PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
- PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid)
+ Invidious::Database::Playlists.delete(plid)
env.redirect "/feed/playlists"
end
def self.edit(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -147,7 +152,7 @@ module Invidious::Routes::Playlists
page ||= 1
begin
- playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true)
if !playlist || playlist.author != user.email
return env.redirect referer
end
@@ -156,18 +161,18 @@ module Invidious::Routes::Playlists
end
begin
- videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
+ videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale)
rescue ex
videos = [] of PlaylistVideo
end
- csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY)
templated "edit_playlist"
end
def self.update(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -183,12 +188,12 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid)
if !playlist || playlist.author != user.email
return env.redirect referer
end
@@ -205,13 +210,13 @@ module Invidious::Routes::Playlists
updated = playlist.updated
end
- PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid)
+ Invidious::Database::Playlists.update(plid, title, privacy, description, updated)
env.redirect "/playlist?list=#{plid}"
end
def self.add_playlist_items_page(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -231,7 +236,7 @@ module Invidious::Routes::Playlists
page ||= 1
begin
- playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true)
if !playlist || playlist.author != user.email
return env.redirect referer
end
@@ -243,7 +248,7 @@ module Invidious::Routes::Playlists
if query
begin
search_query, count, items, operators = process_search_query(query, page, user, region: nil)
- videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) }
+ videos = items.select(SearchVideo).map(&.as(SearchVideo))
rescue ex
videos = [] of SearchVideo
count = 0
@@ -258,7 +263,7 @@ module Invidious::Routes::Playlists
end
def self.playlist_ajax(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
@@ -281,7 +286,7 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
@@ -309,7 +314,7 @@ module Invidious::Routes::Playlists
begin
playlist_id = env.params.query["playlist_id"]
- playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist)
+ playlist = get_playlist(playlist_id, locale).as(InvidiousPlaylist)
raise "Invalid user" if playlist.author != user.email
rescue ex
if redirect
@@ -340,7 +345,7 @@ module Invidious::Routes::Playlists
video_id = env.params.query["video_id"]
begin
- video = get_video(video_id, PG_DB)
+ video = get_video(video_id)
rescue ex
if redirect
return error_template(500, ex)
@@ -361,15 +366,12 @@ module Invidious::Routes::Playlists
index: Random::Secure.rand(0_i64..Int64::MAX),
})
- video_array = playlist_video.to_a
- args = arg_array(video_array)
-
- PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
- PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id)
+ Invidious::Database::PlaylistVideos.insert(playlist_video)
+ Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
when "action_remove_video"
index = env.params.query["set_video_id"]
- PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
- PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id)
+ Invidious::Database::PlaylistVideos.delete(index)
+ Invidious::Database::Playlists.update_video_removed(playlist_id, index)
when "action_move_video_before"
# TODO: Playlist stub
else
@@ -385,7 +387,7 @@ module Invidious::Routes::Playlists
end
def self.show(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
user = env.get?("user").try &.as(User)
referer = get_referer(env)
@@ -403,7 +405,7 @@ module Invidious::Routes::Playlists
end
begin
- playlist = get_playlist(PG_DB, plid, locale)
+ playlist = get_playlist(plid, locale)
rescue ex
return error_template(500, ex)
end
@@ -420,7 +422,7 @@ module Invidious::Routes::Playlists
end
begin
- videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
+ videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale)
rescue ex
return error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}")
end
@@ -433,7 +435,7 @@ module Invidious::Routes::Playlists
end
def self.mix(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
rdid = env.params.query["list"]?
if !rdid
diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr
index 0f26ec15..faae03bc 100644
--- a/src/invidious/routes/preferences.cr
+++ b/src/invidious/routes/preferences.cr
@@ -1,6 +1,8 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::PreferencesRoute
def self.show(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env)
@@ -10,7 +12,7 @@ module Invidious::Routes::PreferencesRoute
end
def self.update(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env)
video_loop = env.params.body["video_loop"]?.try &.as(String)
@@ -68,6 +70,10 @@ module Invidious::Routes::PreferencesRoute
vr_mode ||= "off"
vr_mode = vr_mode == "on"
+ save_player_pos = env.params.body["save_player_pos"]?.try &.as(String)
+ save_player_pos ||= "off"
+ save_player_pos = save_player_pos == "on"
+
show_nick = env.params.body["show_nick"]?.try &.as(String)
show_nick ||= "off"
show_nick = show_nick == "on"
@@ -100,6 +106,8 @@ module Invidious::Routes::PreferencesRoute
automatic_instance_redirect ||= "off"
automatic_instance_redirect = automatic_instance_redirect == "on"
+ region = env.params.body["region"]?.try &.as(String)
+
locale = env.params.body["locale"]?.try &.as(String)
locale ||= CONFIG.default_user_preferences.locale
@@ -150,6 +158,7 @@ module Invidious::Routes::PreferencesRoute
default_home: default_home,
feed_menu: feed_menu,
automatic_instance_redirect: automatic_instance_redirect,
+ region: region,
related_videos: related_videos,
sort: sort,
speed: speed,
@@ -160,11 +169,13 @@ module Invidious::Routes::PreferencesRoute
extend_desc: extend_desc,
vr_mode: vr_mode,
show_nick: show_nick,
- }.to_json).to_json
+ save_player_pos: save_player_pos,
+ }.to_json)
if user = env.get? "user"
user = user.as(User)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
+ user.preferences = preferences
+ Invidious::Database::Users.update_preferences(user)
if CONFIG.admins.includes? user.email
CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home
@@ -198,6 +209,8 @@ module Invidious::Routes::PreferencesRoute
statistics_enabled ||= "off"
CONFIG.statistics_enabled = statistics_enabled == "on"
+ CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.try &.as(String)
+
File.write("config/config.yml", CONFIG.to_yaml)
end
else
@@ -208,10 +221,10 @@ module Invidious::Routes::PreferencesRoute
end
if CONFIG.domain
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
else
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
secure: secure, http_only: true)
end
end
@@ -220,7 +233,7 @@ module Invidious::Routes::PreferencesRoute
end
def self.toggle_theme(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env, unroll: false)
redirect = env.params.query["redirect"]?
@@ -229,18 +242,15 @@ module Invidious::Routes::PreferencesRoute
if user = env.get? "user"
user = user.as(User)
- preferences = user.preferences
- case preferences.dark_mode
+ case user.preferences.dark_mode
when "dark"
- preferences.dark_mode = "light"
+ user.preferences.dark_mode = "light"
else
- preferences.dark_mode = "dark"
+ user.preferences.dark_mode = "dark"
end
- preferences = preferences.to_json
-
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
+ Invidious::Database::Users.update_preferences(user)
else
preferences = env.get("preferences").as(Preferences)
@@ -275,4 +285,191 @@ module Invidious::Routes::PreferencesRoute
"{}"
end
end
+
+ def self.data_control(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ referer = get_referer(env)
+
+ if !user
+ return env.redirect referer
+ end
+
+ user = user.as(User)
+
+ templated "data_control"
+ end
+
+ def self.update_data_control(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ referer = get_referer(env)
+
+ if user
+ user = user.as(User)
+
+ # TODO: Find a way to prevent browser timeout
+
+ HTTP::FormData.parse(env.request) do |part|
+ body = part.body.gets_to_end
+ type = part.headers["Content-Type"]
+
+ next if body.empty?
+
+ # TODO: Unify into single import based on content-type
+ case part.name
+ when "import_invidious"
+ body = JSON.parse(body)
+
+ if body["subscriptions"]?
+ user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
+ user.subscriptions.uniq!
+
+ user.subscriptions = get_batch_channels(user.subscriptions, false, false)
+
+ Invidious::Database::Users.update_subscriptions(user)
+ end
+
+ if body["watch_history"]?
+ user.watched += body["watch_history"].as_a.map(&.as_s)
+ user.watched.uniq!
+ Invidious::Database::Users.update_watch_history(user)
+ end
+
+ if body["preferences"]?
+ user.preferences = Preferences.from_json(body["preferences"].to_json)
+ Invidious::Database::Users.update_preferences(user)
+ end
+
+ if playlists = body["playlists"]?.try &.as_a?
+ playlists.each do |item|
+ title = item["title"]?.try &.as_s?.try &.delete("<>")
+ description = item["description"]?.try &.as_s?.try &.delete("\r")
+ privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
+
+ next if !title
+ next if !description
+ next if !privacy
+
+ playlist = create_playlist(title, privacy, user)
+ Invidious::Database::Playlists.update_description(playlist.id, description)
+
+ videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
+ raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
+
+ video_id = video_id.try &.as_s?
+ next if !video_id
+
+ begin
+ video = get_video(video_id)
+ rescue ex
+ next
+ end
+
+ playlist_video = PlaylistVideo.new({
+ title: video.title,
+ id: video.id,
+ author: video.author,
+ ucid: video.ucid,
+ length_seconds: video.length_seconds,
+ published: video.published,
+ plid: playlist.id,
+ live_now: video.live_now,
+ index: Random::Secure.rand(0_i64..Int64::MAX),
+ })
+
+ Invidious::Database::PlaylistVideos.insert(playlist_video)
+ Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
+ end
+ end
+ end
+ when "import_youtube"
+ filename = part.filename || ""
+ extension = filename.split(".").last
+
+ if extension == "xml" || type == "application/xml" || type == "text/xml"
+ subscriptions = XML.parse(body)
+ user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
+ channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
+ end
+ elsif extension == "json" || type == "application/json"
+ subscriptions = JSON.parse(body)
+ user.subscriptions += subscriptions.as_a.compact_map do |entry|
+ entry["snippet"]["resourceId"]["channelId"].as_s
+ end
+ elsif extension == "csv" || type == "text/csv"
+ subscriptions = parse_subscription_export_csv(body)
+ user.subscriptions += subscriptions
+ else
+ haltf(env, status_code: 415,
+ response: error_template(415, "Invalid subscription file uploaded")
+ )
+ end
+
+ user.subscriptions.uniq!
+ user.subscriptions = get_batch_channels(user.subscriptions, false, false)
+
+ Invidious::Database::Users.update_subscriptions(user)
+ when "import_freetube"
+ user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
+ md["channel_id"]
+ end
+ user.subscriptions.uniq!
+
+ user.subscriptions = get_batch_channels(user.subscriptions, false, false)
+
+ Invidious::Database::Users.update_subscriptions(user)
+ when "import_newpipe_subscriptions"
+ body = JSON.parse(body)
+ user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
+ if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
+ next match["channel"]
+ elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
+ response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
+ html = XML.parse_html(response.body)
+ ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
+ next ucid if ucid
+ end
+
+ nil
+ end
+ user.subscriptions.uniq!
+
+ user.subscriptions = get_batch_channels(user.subscriptions, false, false)
+
+ Invidious::Database::Users.update_subscriptions(user)
+ when "import_newpipe"
+ Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
+ file.each_entry do |entry|
+ if entry.filename == "newpipe.db"
+ tempfile = File.tempfile(".db")
+ File.write(tempfile.path, entry.io.gets_to_end)
+ db = DB.open("sqlite3://" + tempfile.path)
+
+ user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
+ user.watched.uniq!
+
+ Invidious::Database::Users.update_watch_history(user)
+
+ user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
+ user.subscriptions.uniq!
+
+ user.subscriptions = get_batch_channels(user.subscriptions, false, false)
+
+ Invidious::Database::Users.update_subscriptions(user)
+
+ db.close
+ tempfile.delete
+ end
+ end
+ end
+ else nil # Ignore
+ end
+ end
+ end
+
+ env.redirect referer
+ end
end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 610d5031..5e606adf 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -1,6 +1,8 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Search
def self.opensearch(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/opensearchdescription+xml"
XML.build(indent: " ", encoding: "UTF-8") do |xml|
@@ -16,7 +18,7 @@ module Invidious::Routes::Search
end
def self.results(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
query = env.params.query["search_query"]?
query ||= env.params.query["q"]?
@@ -35,7 +37,7 @@ module Invidious::Routes::Search
end
def self.search(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]?
query = env.params.query["search_query"]?
@@ -53,6 +55,8 @@ module Invidious::Routes::Search
begin
search_query, count, videos, operators = process_search_query(query, page, user, region: region)
+ 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
return error_template(500, ex)
end
diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr
new file mode 100644
index 00000000..29152afb
--- /dev/null
+++ b/src/invidious/routes/subscriptions.cr
@@ -0,0 +1,168 @@
+module Invidious::Routes::Subscriptions
+ def self.toggle_subscription(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/")
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+
+ if !user
+ if redirect
+ return env.redirect referer
+ else
+ return error_json(403, "No such user")
+ end
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
+ action = "action_create_subscription_to_channel"
+ elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
+ action = "action_remove_subscriptions"
+ else
+ return env.redirect referer
+ end
+
+ channel_id = env.params.query["c"]?
+ channel_id ||= ""
+
+ if !user.password
+ # Sync subscriptions with YouTube
+ subscribe_ajax(channel_id, action, env.request.headers)
+ end
+
+ case action
+ when "action_create_subscription_to_channel"
+ if !user.subscriptions.includes? channel_id
+ get_channel(channel_id, false, false)
+ Invidious::Database::Users.subscribe_channel(user, channel_id)
+ end
+ when "action_remove_subscriptions"
+ Invidious::Database::Users.unsubscribe_channel(user, channel_id)
+ else
+ return error_json(400, "Unsupported action #{action}")
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
+
+ def self.subscription_manager(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ if !user
+ return env.redirect referer
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ if !user.password
+ # Refresh account
+ headers = HTTP::Headers.new
+ headers["Cookie"] = env.request.headers["Cookie"]
+
+ user, sid = get_user(sid, headers)
+ end
+
+ action_takeout = env.params.query["action_takeout"]?.try &.to_i?
+ action_takeout ||= 0
+ action_takeout = action_takeout == 1
+
+ format = env.params.query["format"]?
+ format ||= "rss"
+
+ subscriptions = Invidious::Database::Channels.select(user.subscriptions)
+ subscriptions.sort_by!(&.author.downcase)
+
+ if action_takeout
+ if format == "json"
+ env.response.content_type = "application/json"
+ env.response.headers["content-disposition"] = "attachment"
+ playlists = Invidious::Database::Playlists.select_like_iv(user.email)
+
+ return JSON.build do |json|
+ json.object do
+ json.field "subscriptions", user.subscriptions
+ json.field "watch_history", user.watched
+ json.field "preferences", user.preferences
+ json.field "playlists" do
+ json.array do
+ playlists.each do |playlist|
+ json.object do
+ json.field "title", playlist.title
+ json.field "description", html_to_content(playlist.description_html)
+ json.field "privacy", playlist.privacy.to_s
+ json.field "videos" do
+ json.array do
+ Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id|
+ json.string video_id
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ else
+ env.response.content_type = "application/xml"
+ env.response.headers["content-disposition"] = "attachment"
+ export = XML.build do |xml|
+ xml.element("opml", version: "1.1") do
+ xml.element("body") do
+ if format == "newpipe"
+ title = "YouTube Subscriptions"
+ else
+ title = "Invidious Subscriptions"
+ end
+
+ xml.element("outline", text: title, title: title) do
+ subscriptions.each do |channel|
+ if format == "newpipe"
+ xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}"
+ else
+ xml_url = "#{HOST_URL}/feed/channel/#{channel.id}"
+ end
+
+ xml.element("outline", text: channel.author, title: channel.author,
+ "type": "rss", xmlUrl: xml_url)
+ end
+ end
+ end
+ end
+ end
+
+ return export.gsub(%(<?xml version="1.0"?>\n), "")
+ end
+ end
+
+ templated "subscription_manager"
+ end
+end
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index acbf62b4..8a58b034 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -1,7 +1,7 @@
module Invidious::Routes::VideoPlayback
# /videoplayback
def self.get_video_playback(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
query_params = env.params.query
fvip = query_params["fvip"]? || "3"
@@ -20,7 +20,7 @@ module Invidious::Routes::VideoPlayback
host = "https://r#{fvip}---#{mns.pop}.googlevideo.com"
end
- url = "/videoplayback?#{query_params.to_s}"
+ url = "/videoplayback?#{query_params}"
headers = HTTP::Headers.new
REQUEST_HEADERS_WHITELIST.each do |header|
@@ -240,7 +240,7 @@ module Invidious::Routes::VideoPlayback
download_widget = JSON.parse(env.params.query["download_widget"])
id = download_widget["id"].as_s
- title = download_widget["title"].as_s
+ title = URI.decode_www_form(download_widget["title"].as_s)
if label = download_widget["label"]?
return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}"
@@ -263,7 +263,7 @@ module Invidious::Routes::VideoPlayback
haltf env, status_code: 400, response: "TESTING"
end
- video = get_video(id, PG_DB, region: region)
+ video = get_video(id, region: region)
fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag }
url = fmt.try &.["url"]?.try &.as_s
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index f07b1358..7d048ce8 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -1,6 +1,8 @@
+{% skip_file if flag?(:api_only) %}
+
module Invidious::Routes::Watch
def self.handle(env)
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ locale = env.get("preferences").as(Preferences).locale
region = env.params.query["region"]?
if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
@@ -37,7 +39,7 @@ module Invidious::Routes::Watch
end
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
- continuation = process_continuation(PG_DB, env.params.query, plid, id)
+ continuation = process_continuation(env.params.query, plid, id)
nojs = env.params.query["nojs"]?
@@ -58,7 +60,7 @@ module Invidious::Routes::Watch
env.params.query.delete_all("listen")
begin
- video = get_video(id, PG_DB, region: params.region)
+ video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex
@@ -74,11 +76,11 @@ module Invidious::Routes::Watch
env.params.query.delete_all("iv_load_policy")
if watched && !watched.includes? id
- PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email)
+ Invidious::Database::Users.mark_watched(user.as(User), id)
end
if notifications && notifications.includes? id
- PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email)
+ Invidious::Database::Users.remove_notification(user.as(User), id)
env.get("user").as(User).notifications.delete(id)
notifications.delete(id)
end
@@ -151,11 +153,11 @@ module Invidious::Routes::Watch
preferred_captions = captions.select { |caption|
params.preferred_captions.includes?(caption.name) ||
- params.preferred_captions.includes?(caption.languageCode.split("-")[0])
+ params.preferred_captions.includes?(caption.language_code.split("-")[0])
}
preferred_captions.sort_by! { |caption|
(params.preferred_captions.index(caption.name) ||
- params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil!
+ params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil!
}
captions = captions - preferred_captions
@@ -198,4 +200,70 @@ module Invidious::Routes::Watch
return env.redirect url
end
+
+ def self.mark_watched(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/feed/subscriptions")
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+
+ if !user
+ if redirect
+ return env.redirect referer
+ else
+ return error_json(403, "No such user")
+ end
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ id = env.params.query["id"]?
+ if !id
+ env.response.status_code = 400
+ return
+ end
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ if env.params.query["action_mark_watched"]?
+ action = "action_mark_watched"
+ elsif env.params.query["action_mark_unwatched"]?
+ action = "action_mark_unwatched"
+ else
+ return env.redirect referer
+ end
+
+ case action
+ when "action_mark_watched"
+ if !user.watched.includes? id
+ Invidious::Database::Users.mark_watched(user, id)
+ end
+ when "action_mark_unwatched"
+ Invidious::Database::Users.mark_unwatched(user, id)
+ else
+ return error_json(400, "Unsupported action #{action}")
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
end
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index e0cddeb5..7551f22d 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -73,7 +73,7 @@ macro define_v1_api_routes
Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
- Invidious::Routing.get "/api/v1//mixes/:rdid", {{namespace}}::Misc, :mixes
+ Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
end
macro define_api_manifest_routes
diff --git a/src/invidious/search.cr b/src/invidious/search.cr
index a3fcc7a3..0f6dc6eb 100644
--- a/src/invidious/search.cr
+++ b/src/invidious/search.cr
@@ -1,233 +1,10 @@
-struct SearchVideo
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property ucid : String
- property published : Time
- property views : Int64
- property description_html : String
- property length_seconds : Int32
- property live_now : Bool
- property premium : Bool
- property premiere_timestamp : Time?
-
- def to_xml(auto_generated, query_params, xml : XML::Builder)
- query_params["v"] = self.id
-
- xml.element("entry") do
- xml.element("id") { xml.text "yt:video:#{self.id}" }
- xml.element("yt:videoId") { xml.text self.id }
- xml.element("yt:channelId") { xml.text self.ucid }
- xml.element("title") { xml.text self.title }
- xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}")
-
- xml.element("author") do
- if auto_generated
- xml.element("name") { xml.text self.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
- else
- xml.element("name") { xml.text author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
- end
- end
-
- xml.element("content", type: "xhtml") do
- xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
- xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do
- xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
- end
-
- xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
- end
- end
-
- xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
-
- xml.element("media:group") do
- xml.element("media:title") { xml.text self.title }
- xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
- width: "320", height: "180")
- xml.element("media:description") { xml.text html_to_content(self.description_html) }
- end
-
- xml.element("media:community") do
- xml.element("media:statistics", views: self.views)
- end
- end
- end
-
- def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil)
- if xml
- to_xml(HOST_URL, auto_generated, query_params, xml)
- else
- XML.build do |json|
- to_xml(HOST_URL, auto_generated, query_params, xml)
- end
- end
- end
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "video"
- json.field "title", self.title
- json.field "videoId", self.id
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "videoThumbnails" do
- generate_thumbnails(json, self.id)
- end
+class ChannelSearchException < InfoException
+ getter channel : String
- json.field "description", html_to_content(self.description_html)
- json.field "descriptionHtml", self.description_html
-
- json.field "viewCount", self.views
- json.field "published", self.published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
- json.field "lengthSeconds", self.length_seconds
- json.field "liveNow", self.live_now
- json.field "premium", self.premium
- json.field "isUpcoming", self.is_upcoming
-
- if self.premiere_timestamp
- json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
- end
- end
- end
-
- def to_json(locale, json : JSON::Builder | Nil = nil)
- if json
- to_json(locale, json)
- else
- JSON.build do |json|
- to_json(locale, json)
- end
- end
- end
-
- def is_upcoming
- premiere_timestamp ? true : false
+ def initialize(@channel)
end
end
-struct SearchPlaylistVideo
- include DB::Serializable
-
- property title : String
- property id : String
- property length_seconds : Int32
-end
-
-struct SearchPlaylist
- include DB::Serializable
-
- property title : String
- property id : String
- property author : String
- property ucid : String
- property video_count : Int32
- property videos : Array(SearchPlaylistVideo)
- property thumbnail : String?
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "playlist"
- json.field "title", self.title
- json.field "playlistId", self.id
- json.field "playlistThumbnail", self.thumbnail
-
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "videoCount", self.video_count
- json.field "videos" do
- json.array do
- self.videos.each do |video|
- json.object do
- json.field "title", video.title
- json.field "videoId", video.id
- json.field "lengthSeconds", video.length_seconds
-
- json.field "videoThumbnails" do
- generate_thumbnails(json, video.id)
- end
- end
- end
- end
- end
- end
- end
-
- def to_json(locale, json : JSON::Builder | Nil = nil)
- if json
- to_json(locale, json)
- else
- JSON.build do |json|
- to_json(locale, json)
- end
- end
- end
-end
-
-struct SearchChannel
- include DB::Serializable
-
- property author : String
- property ucid : String
- property author_thumbnail : String
- property subscriber_count : Int32
- property video_count : Int32
- property description_html : String
- property auto_generated : Bool
-
- def to_json(locale, json : JSON::Builder)
- json.object do
- json.field "type", "channel"
- json.field "author", self.author
- json.field "authorId", self.ucid
- json.field "authorUrl", "/channel/#{self.ucid}"
-
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
-
- qualities.each do |quality|
- json.object do
- json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
- json.field "width", quality
- json.field "height", quality
- end
- end
- end
- end
-
- json.field "autoGenerated", self.auto_generated
- json.field "subCount", self.subscriber_count
- json.field "videoCount", self.video_count
-
- json.field "description", html_to_content(self.description_html)
- json.field "descriptionHtml", self.description_html
- end
- end
-
- def to_json(locale, json : JSON::Builder | Nil = nil)
- if json
- to_json(locale, json)
- else
- JSON.build do |json|
- to_json(locale, json)
- end
- end
- end
-end
-
-alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist
-
def channel_search(query, page, channel)
response = YT_POOL.client &.get("/channel/#{channel}")
@@ -235,8 +12,8 @@ def channel_search(query, page, channel)
response = YT_POOL.client &.get("/user/#{channel}")
response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404
initial_data = extract_initial_data(response.body)
- ucid = initial_data["header"]["c4TabbedHeaderRenderer"]?.try &.["channelId"].as_s?
- raise InfoException.new("Impossible to extract channel ID from page") if !ucid
+ ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
+ raise ChannelSearchException.new(channel) if !ucid
else
ucid = channel
end
@@ -244,13 +21,13 @@ def channel_search(query, page, channel)
continuation = produce_channel_search_continuation(ucid, query, page)
response_json = YoutubeAPI.browse(continuation)
- continuationItems = response_json["onResponseReceivedActions"]?
+ continuation_items = response_json["onResponseReceivedActions"]?
.try &.[0]["appendContinuationItemsAction"]["continuationItems"]
- return 0, [] of SearchItem if !continuationItems
+ return 0, [] of SearchItem if !continuation_items
items = [] of SearchItem
- continuationItems.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item|
+ continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item|
extract_item(item["itemSectionRenderer"]["contents"].as_a[0])
.try { |t| items << t }
}
@@ -358,7 +135,7 @@ def produce_search_params(page = 1, sort : String = "relevance", date : String =
object.delete("2:embedded")
end
- params = object.try { |i| Protodec::Any.cast_json(object) }
+ params = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
@@ -391,7 +168,7 @@ def produce_channel_search_continuation(ucid, query, page)
},
}
- continuation = object.try { |i| Protodec::Any.cast_json(object) }
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
@@ -413,7 +190,7 @@ def process_search_query(query, page, user, region)
sort = "relevance"
subscriptions = nil
- operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) }
+ operators = query.split(" ").select(&.match(/\w+:[\w,]+/))
operators.each do |operator|
key, value = operator.downcase.split(":")
@@ -462,5 +239,20 @@ def process_search_query(query, page, user, region)
count, items = search(search_query, search_params, region).as(Tuple)
end
- {search_query, count, items, operators}
+ # Light processing to flatten search results out of Categories.
+ # They should ideally be supported in the future.
+ items_without_category = [] of SearchItem | ChannelVideo
+ items.each do |i|
+ if i.is_a? Category
+ i.contents.each do |nest_i|
+ if !nest_i.is_a? Video
+ items_without_category << nest_i
+ end
+ end
+ else
+ items_without_category << i
+ end
+ end
+
+ {search_query, items_without_category.size, items_without_category, operators}
end
diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr
index 25bab4d2..1f957081 100644
--- a/src/invidious/trending.cr
+++ b/src/invidious/trending.cr
@@ -20,13 +20,3 @@ def fetch_trending(trending_type, region, locale)
return {trending, plid}
end
-
-def extract_plid(url)
- return url.try { |i| URI.parse(i).query }
- .try { |i| HTTP::Params.parse(i)["bp"] }
- .try { |i| URI.decode_www_form(i) }
- .try { |i| Base64.decode(i) }
- .try { |i| IO::Memory.new(i) }
- .try { |i| Protodec::Any.parse(i) }
- .try &.["44:0:embedded"]?.try &.["2:1:string"]?.try &.as_s
-end
diff --git a/src/invidious/user/converters.cr b/src/invidious/user/converters.cr
new file mode 100644
index 00000000..dcbf8c53
--- /dev/null
+++ b/src/invidious/user/converters.cr
@@ -0,0 +1,12 @@
+def convert_theme(theme)
+ case theme
+ when "true"
+ "dark"
+ when "false"
+ "light"
+ when "", nil
+ nil
+ else
+ theme
+ end
+end
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
new file mode 100644
index 00000000..2ae1dcb1
--- /dev/null
+++ b/src/invidious/user/imports.cr
@@ -0,0 +1,27 @@
+require "csv"
+
+def parse_subscription_export_csv(csv_content : String)
+ rows = CSV.new(csv_content, headers: true)
+ subscriptions = Array(String).new
+
+ # Counter to limit the amount of imports.
+ # This is intended to prevent DoS.
+ row_counter = 0
+
+ rows.each do |row|
+ # Limit to 1200
+ row_counter += 1
+ break if row_counter > 1_200
+
+ # Channel ID is the first column in the csv export we can't use the header
+ # name, because the header name is localized depending on the
+ # language the user has set on their account
+ channel_id = row[0].strip
+
+ next if channel_id.empty?
+
+ subscriptions << channel_id
+ end
+
+ return subscriptions
+end
diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr
new file mode 100644
index 00000000..bf7ea401
--- /dev/null
+++ b/src/invidious/user/preferences.cr
@@ -0,0 +1,259 @@
+struct Preferences
+ include JSON::Serializable
+ include YAML::Serializable
+
+ property annotations : Bool = CONFIG.default_user_preferences.annotations
+ property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
+ property autoplay : Bool = CONFIG.default_user_preferences.autoplay
+ property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
+
+ @[JSON::Field(converter: Preferences::StringToArray)]
+ @[YAML::Field(converter: Preferences::StringToArray)]
+ property captions : Array(String) = CONFIG.default_user_preferences.captions
+
+ @[JSON::Field(converter: Preferences::StringToArray)]
+ @[YAML::Field(converter: Preferences::StringToArray)]
+ property comments : Array(String) = CONFIG.default_user_preferences.comments
+ property continue : Bool = CONFIG.default_user_preferences.continue
+ property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay
+
+ @[JSON::Field(converter: Preferences::BoolToString)]
+ @[YAML::Field(converter: Preferences::BoolToString)]
+ property dark_mode : String = CONFIG.default_user_preferences.dark_mode
+ property latest_only : Bool = CONFIG.default_user_preferences.latest_only
+ property listen : Bool = CONFIG.default_user_preferences.listen
+ property local : Bool = CONFIG.default_user_preferences.local
+ property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
+ property show_nick : Bool = CONFIG.default_user_preferences.show_nick
+
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property locale : String = CONFIG.default_user_preferences.locale
+ property region : String? = CONFIG.default_user_preferences.region
+
+ @[JSON::Field(converter: Preferences::ClampInt)]
+ property max_results : Int32 = CONFIG.default_user_preferences.max_results
+ property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only
+
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property player_style : String = CONFIG.default_user_preferences.player_style
+
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property quality : String = CONFIG.default_user_preferences.quality
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property quality_dash : String = CONFIG.default_user_preferences.quality_dash
+ property default_home : String? = CONFIG.default_user_preferences.default_home
+ property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
+ property related_videos : Bool = CONFIG.default_user_preferences.related_videos
+
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property sort : String = CONFIG.default_user_preferences.sort
+ property speed : Float32 = CONFIG.default_user_preferences.speed
+ property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode
+ property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only
+ property video_loop : Bool = CONFIG.default_user_preferences.video_loop
+ property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc
+ property volume : Int32 = CONFIG.default_user_preferences.volume
+ property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos
+
+ module BoolToString
+ def self.to_json(value : String, json : JSON::Builder)
+ json.string value
+ end
+
+ def self.from_json(value : JSON::PullParser) : String
+ begin
+ result = value.read_string
+
+ if result.empty?
+ CONFIG.default_user_preferences.dark_mode
+ else
+ result
+ end
+ rescue ex
+ if value.read_bool
+ "dark"
+ else
+ "light"
+ end
+ end
+ end
+
+ def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
+ yaml.scalar value
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
+ unless node.is_a?(YAML::Nodes::Scalar)
+ node.raise "Expected scalar, not #{node.class}"
+ end
+
+ case node.value
+ when "true"
+ "dark"
+ when "false"
+ "light"
+ when ""
+ CONFIG.default_user_preferences.dark_mode
+ else
+ node.value
+ end
+ end
+ end
+
+ module ClampInt
+ def self.to_json(value : Int32, json : JSON::Builder)
+ json.number value
+ end
+
+ def self.from_json(value : JSON::PullParser) : Int32
+ value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
+ end
+
+ def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
+ yaml.scalar value
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
+ node.value.clamp(0, MAX_ITEMS_PER_PAGE)
+ end
+ end
+
+ module FamilyConverter
+ def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
+ case value
+ when Socket::Family::UNSPEC
+ yaml.scalar nil
+ when Socket::Family::INET
+ yaml.scalar "ipv4"
+ when Socket::Family::INET6
+ yaml.scalar "ipv6"
+ when Socket::Family::UNIX
+ raise "Invalid socket family #{value}"
+ end
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
+ if node.is_a?(YAML::Nodes::Scalar)
+ case node.value.downcase
+ when "ipv4"
+ Socket::Family::INET
+ when "ipv6"
+ Socket::Family::INET6
+ else
+ Socket::Family::UNSPEC
+ end
+ else
+ node.raise "Expected scalar, not #{node.class}"
+ end
+ end
+ end
+
+ module URIConverter
+ def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
+ yaml.scalar value.normalize!
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
+ if node.is_a?(YAML::Nodes::Scalar)
+ URI.parse node.value
+ else
+ node.raise "Expected scalar, not #{node.class}"
+ end
+ end
+ end
+
+ module ProcessString
+ def self.to_json(value : String, json : JSON::Builder)
+ json.string value
+ end
+
+ def self.from_json(value : JSON::PullParser) : String
+ HTML.escape(value.read_string[0, 100])
+ end
+
+ def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
+ yaml.scalar value
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
+ HTML.escape(node.value[0, 100])
+ end
+ end
+
+ module StringToArray
+ def self.to_json(value : Array(String), json : JSON::Builder)
+ json.array do
+ value.each do |element|
+ json.string element
+ end
+ end
+ end
+
+ def self.from_json(value : JSON::PullParser) : Array(String)
+ begin
+ result = [] of String
+ value.read_array do
+ result << HTML.escape(value.read_string[0, 100])
+ end
+ rescue ex
+ result = [HTML.escape(value.read_string[0, 100]), ""]
+ end
+
+ result
+ end
+
+ def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
+ yaml.sequence do
+ value.each do |element|
+ yaml.scalar element
+ end
+ end
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
+ begin
+ unless node.is_a?(YAML::Nodes::Sequence)
+ node.raise "Expected sequence, not #{node.class}"
+ end
+
+ result = [] of String
+ node.nodes.each do |item|
+ unless item.is_a?(YAML::Nodes::Scalar)
+ node.raise "Expected scalar, not #{item.class}"
+ end
+
+ result << HTML.escape(item.value[0, 100])
+ end
+ rescue ex
+ if node.is_a?(YAML::Nodes::Scalar)
+ result = [HTML.escape(node.value[0, 100]), ""]
+ else
+ result = ["", ""]
+ end
+ end
+
+ result
+ end
+ end
+
+ module StringToCookies
+ def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
+ (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
+ unless node.is_a?(YAML::Nodes::Scalar)
+ node.raise "Expected scalar, not #{node.class}"
+ end
+
+ cookies = HTTP::Cookies.new
+ node.value.split(";").each do |cookie|
+ next if cookie.strip.empty?
+ name, value = cookie.split("=", 2)
+ cookies << HTTP::Cookie.new(name.strip, value.strip)
+ end
+
+ cookies
+ end
+ end
+end
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index aff76b53..49074994 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -29,301 +29,31 @@ struct User
end
end
-struct Preferences
- include JSON::Serializable
- include YAML::Serializable
-
- property annotations : Bool = CONFIG.default_user_preferences.annotations
- property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
- property autoplay : Bool = CONFIG.default_user_preferences.autoplay
- property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
-
- @[JSON::Field(converter: Preferences::StringToArray)]
- @[YAML::Field(converter: Preferences::StringToArray)]
- property captions : Array(String) = CONFIG.default_user_preferences.captions
-
- @[JSON::Field(converter: Preferences::StringToArray)]
- @[YAML::Field(converter: Preferences::StringToArray)]
- property comments : Array(String) = CONFIG.default_user_preferences.comments
- property continue : Bool = CONFIG.default_user_preferences.continue
- property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay
-
- @[JSON::Field(converter: Preferences::BoolToString)]
- @[YAML::Field(converter: Preferences::BoolToString)]
- property dark_mode : String = CONFIG.default_user_preferences.dark_mode
- property latest_only : Bool = CONFIG.default_user_preferences.latest_only
- property listen : Bool = CONFIG.default_user_preferences.listen
- property local : Bool = CONFIG.default_user_preferences.local
- property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
- property show_nick : Bool = CONFIG.default_user_preferences.show_nick
-
- @[JSON::Field(converter: Preferences::ProcessString)]
- property locale : String = CONFIG.default_user_preferences.locale
-
- @[JSON::Field(converter: Preferences::ClampInt)]
- property max_results : Int32 = CONFIG.default_user_preferences.max_results
- property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only
-
- @[JSON::Field(converter: Preferences::ProcessString)]
- property player_style : String = CONFIG.default_user_preferences.player_style
-
- @[JSON::Field(converter: Preferences::ProcessString)]
- property quality : String = CONFIG.default_user_preferences.quality
- @[JSON::Field(converter: Preferences::ProcessString)]
- property quality_dash : String = CONFIG.default_user_preferences.quality_dash
- property default_home : String? = CONFIG.default_user_preferences.default_home
- property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
- property related_videos : Bool = CONFIG.default_user_preferences.related_videos
-
- @[JSON::Field(converter: Preferences::ProcessString)]
- property sort : String = CONFIG.default_user_preferences.sort
- property speed : Float32 = CONFIG.default_user_preferences.speed
- property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode
- property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only
- property video_loop : Bool = CONFIG.default_user_preferences.video_loop
- property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc
- property volume : Int32 = CONFIG.default_user_preferences.volume
-
- module BoolToString
- def self.to_json(value : String, json : JSON::Builder)
- json.string value
- end
-
- def self.from_json(value : JSON::PullParser) : String
- begin
- result = value.read_string
-
- if result.empty?
- CONFIG.default_user_preferences.dark_mode
- else
- result
- end
- rescue ex
- if value.read_bool
- "dark"
- else
- "light"
- end
- end
- end
-
- def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
- yaml.scalar value
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
- unless node.is_a?(YAML::Nodes::Scalar)
- node.raise "Expected scalar, not #{node.class}"
- end
-
- case node.value
- when "true"
- "dark"
- when "false"
- "light"
- when ""
- CONFIG.default_user_preferences.dark_mode
- else
- node.value
- end
- end
- end
-
- module ClampInt
- def self.to_json(value : Int32, json : JSON::Builder)
- json.number value
- end
-
- def self.from_json(value : JSON::PullParser) : Int32
- value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
- end
-
- def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
- yaml.scalar value
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
- node.value.clamp(0, MAX_ITEMS_PER_PAGE)
- end
- end
-
- module FamilyConverter
- def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
- case value
- when Socket::Family::UNSPEC
- yaml.scalar nil
- when Socket::Family::INET
- yaml.scalar "ipv4"
- when Socket::Family::INET6
- yaml.scalar "ipv6"
- when Socket::Family::UNIX
- raise "Invalid socket family #{value}"
- end
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
- if node.is_a?(YAML::Nodes::Scalar)
- case node.value.downcase
- when "ipv4"
- Socket::Family::INET
- when "ipv6"
- Socket::Family::INET6
- else
- Socket::Family::UNSPEC
- end
- else
- node.raise "Expected scalar, not #{node.class}"
- end
- end
- end
-
- module URIConverter
- def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
- yaml.scalar value.normalize!
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
- if node.is_a?(YAML::Nodes::Scalar)
- URI.parse node.value
- else
- node.raise "Expected scalar, not #{node.class}"
- end
- end
- end
-
- module ProcessString
- def self.to_json(value : String, json : JSON::Builder)
- json.string value
- end
-
- def self.from_json(value : JSON::PullParser) : String
- HTML.escape(value.read_string[0, 100])
- end
-
- def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
- yaml.scalar value
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
- HTML.escape(node.value[0, 100])
- end
- end
-
- module StringToArray
- def self.to_json(value : Array(String), json : JSON::Builder)
- json.array do
- value.each do |element|
- json.string element
- end
- end
- end
-
- def self.from_json(value : JSON::PullParser) : Array(String)
- begin
- result = [] of String
- value.read_array do
- result << HTML.escape(value.read_string[0, 100])
- end
- rescue ex
- result = [HTML.escape(value.read_string[0, 100]), ""]
- end
-
- result
- end
-
- def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
- yaml.sequence do
- value.each do |element|
- yaml.scalar element
- end
- end
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
- begin
- unless node.is_a?(YAML::Nodes::Sequence)
- node.raise "Expected sequence, not #{node.class}"
- end
-
- result = [] of String
- node.nodes.each do |item|
- unless item.is_a?(YAML::Nodes::Scalar)
- node.raise "Expected scalar, not #{item.class}"
- end
-
- result << HTML.escape(item.value[0, 100])
- end
- rescue ex
- if node.is_a?(YAML::Nodes::Scalar)
- result = [HTML.escape(node.value[0, 100]), ""]
- else
- result = ["", ""]
- end
- end
-
- result
- end
- end
-
- module StringToCookies
- def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
- (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
- unless node.is_a?(YAML::Nodes::Scalar)
- node.raise "Expected scalar, not #{node.class}"
- end
-
- cookies = HTTP::Cookies.new
- node.value.split(";").each do |cookie|
- next if cookie.strip.empty?
- name, value = cookie.split("=", 2)
- cookies << HTTP::Cookie.new(name.strip, value.strip)
- end
-
- cookies
- end
- end
-end
-
-def get_user(sid, headers, db, refresh = true)
- if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
- user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+def get_user(sid, headers, refresh = true)
+ if email = Invidious::Database::SessionIDs.select_email(sid)
+ user = Invidious::Database::Users.select!(email: email)
if refresh && Time.utc - user.updated > 1.minute
- user, sid = fetch_user(sid, headers, db)
- user_array = user.to_a
- user_array[4] = user_array[4].to_json # User preferences
- args = arg_array(user_array)
-
- db.exec("INSERT INTO users VALUES (#{args}) \
- ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array)
+ user, sid = fetch_user(sid, headers)
- db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
- ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
+ Invidious::Database::Users.insert(user, update_on_conflict: true)
+ Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
begin
view_name = "subscriptions_#{sha256(user.email)}"
- db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
+ PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
rescue ex
end
end
else
- user, sid = fetch_user(sid, headers, db)
- user_array = user.to_a
- user_array[4] = user_array[4].to_json # User preferences
- args = arg_array(user.to_a)
-
- db.exec("INSERT INTO users VALUES (#{args}) \
- ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array)
+ user, sid = fetch_user(sid, headers)
- db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
- ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
+ Invidious::Database::Users.insert(user, update_on_conflict: true)
+ Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
begin
view_name = "subscriptions_#{sha256(user.email)}"
- db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
+ PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
rescue ex
end
end
@@ -331,7 +61,7 @@ def get_user(sid, headers, db, refresh = true)
return user, sid
end
-def fetch_user(sid, headers, db)
+def fetch_user(sid, headers)
feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
feed = XML.parse_html(feed.body)
@@ -344,7 +74,7 @@ def fetch_user(sid, headers, db)
end
end
- channels = get_batch_channels(channels, db, false, false)
+ channels = get_batch_channels(channels, false, false)
email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"]))
if email
@@ -388,7 +118,7 @@ def create_user(sid, email, password)
return user, sid
end
-def generate_captcha(key, db)
+def generate_captcha(key)
second = Random::Secure.rand(12)
second_angle = second * 30
second = second * 5
@@ -440,16 +170,16 @@ def generate_captcha(key, db)
return {
question: image,
- tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)},
+ tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
}
end
-def generate_text_captcha(key, db)
+def generate_text_captcha(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, db, use_nonce: true)
+ generate_response(answer.as_s, {":login"}, key, use_nonce: true)
end
return {
@@ -490,33 +220,29 @@ def subscribe_ajax(channel_id, action, env_headers)
end
end
-def get_subscription_feed(db, user, max_results = 40, page = 1)
+def get_subscription_feed(user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit
- notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
- as: Array(String))
+ notifications = Invidious::Database::Users.select_notifications(user)
view_name = "subscriptions_#{sha256(user.email)}"
if user.preferences.notifications_only && !notifications.empty?
# Only show notifications
-
- args = arg_array(notifications)
-
- notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo)
+ notifications = Invidious::Database::ChannelVideos.select(notifications)
videos = [] of ChannelVideo
- notifications.sort_by! { |video| video.published }.reverse!
+ notifications.sort_by!(&.published).reverse!
case user.preferences.sort
when "alphabetically"
- notifications.sort_by! { |video| video.title }
+ notifications.sort_by!(&.title)
when "alphabetically - reverse"
- notifications.sort_by! { |video| video.title }.reverse!
+ notifications.sort_by!(&.title).reverse!
when "channel name"
- notifications.sort_by! { |video| video.author }
+ notifications.sort_by!(&.author)
when "channel name - reverse"
- notifications.sort_by! { |video| video.author }.reverse!
+ notifications.sort_by!(&.author).reverse!
else nil # Ignore
end
else
@@ -537,7 +263,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
end
- videos.sort_by! { |video| video.published }.reverse!
+ videos.sort_by!(&.published).reverse!
else
if user.preferences.unseen_only
# Only show unwatched
@@ -557,20 +283,19 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
case user.preferences.sort
when "published - reverse"
- videos.sort_by! { |video| video.published }
+ videos.sort_by!(&.published)
when "alphabetically"
- videos.sort_by! { |video| video.title }
+ videos.sort_by!(&.title)
when "alphabetically - reverse"
- videos.sort_by! { |video| video.title }.reverse!
+ videos.sort_by!(&.title).reverse!
when "channel name"
- videos.sort_by! { |video| video.author }
+ videos.sort_by!(&.author)
when "channel name - reverse"
- videos.sort_by! { |video| video.author }.reverse!
+ videos.sort_by!(&.author).reverse!
else nil # Ignore
end
- notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String))
-
+ notifications = Invidious::Database::Users.select_notifications(user)
notifications = videos.select { |v| notifications.includes? v.id }
videos = videos - notifications
end
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index d9c07142..499ed94d 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -246,6 +246,7 @@ struct VideoPreferences
property video_start : Float64 | Int32
property volume : Int32
property vr_mode : Bool
+ property save_player_pos : Bool
end
struct Video
@@ -275,7 +276,7 @@ struct Video
end
end
- def to_json(locale, json : JSON::Builder)
+ def to_json(locale : String?, json : JSON::Builder)
json.object do
json.field "type", "video"
@@ -426,7 +427,7 @@ struct Video
self.captions.each do |caption|
json.object do
json.field "label", caption.name
- json.field "languageCode", caption.languageCode
+ json.field "language_code", caption.language_code
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
end
end
@@ -474,14 +475,13 @@ struct Video
end
end
- def to_json(locale, json : JSON::Builder | Nil = nil)
- if json
- to_json(locale, json)
- else
- JSON.build do |json|
- to_json(locale, json)
- end
- end
+ # TODO: remove the locale and follow the crystal convention
+ def to_json(locale : String?, _json : Nil)
+ JSON.build { |json| to_json(locale, json) }
+ end
+
+ def to_json(json : JSON::Builder | Nil = nil)
+ to_json(nil, json)
end
def title
@@ -703,10 +703,10 @@ struct Video
return @captions.as(Array(Caption)) if @captions
captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption|
name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"]
- languageCode = caption["languageCode"].to_s
- baseUrl = caption["baseUrl"].to_s
+ language_code = caption["languageCode"].to_s
+ base_url = caption["baseUrl"].to_s
- caption = Caption.new(name.to_s, languageCode, baseUrl)
+ caption = Caption.new(name.to_s, language_code, base_url)
caption.name = caption.name.split(" - ")[0]
caption
end
@@ -785,16 +785,16 @@ end
struct Caption
property name
- property languageCode
- property baseUrl
+ property language_code
+ property base_url
getter name : String
- getter languageCode : String
- getter baseUrl : String
+ getter language_code : String
+ getter base_url : String
setter name
- def initialize(@name, @languageCode, @baseUrl)
+ def initialize(@name, @language_code, @base_url)
end
end
@@ -858,8 +858,16 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
else
client_config.client_type = YoutubeAPI::ClientType::Android
end
- stream_data = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
- params["streamingData"] = stream_data["streamingData"]? || JSON::Any.new("")
+ android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
+
+ # Sometime, the video is available from the web client, but not on Android, so check
+ # that here, and fallback to the streaming data from the web client if needed.
+ # See: https://github.com/iv-org/invidious/issues/2549
+ if android_player["playabilityStatus"]["status"] == "OK"
+ params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
+ else
+ params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
+ end
end
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
@@ -878,42 +886,84 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
}
).try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any)
- primary_results = player_response.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]?
- .try &.["results"]?.try &.["contents"]?
- sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]?
- .try &.["videoPrimaryInfoRenderer"]?
- .try &.["sentimentBar"]?
- .try &.["sentimentBarRenderer"]?
- .try &.["tooltip"]?
- .try &.as_s
-
- likes, dislikes = sentiment_bar.try &.split(" / ", 2).map &.gsub(/\D/, "").to_i64 || {0_i64, 0_i64}
- params["likes"] = JSON::Any.new(likes)
- params["dislikes"] = JSON::Any.new(dislikes)
-
- params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
- .try &.["videoSecondaryInfoRenderer"]?.try &.["description"]?.try &.["runs"]?
- .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "<br/>") } || "<p></p>")
-
- metadata = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
- .try &.["videoSecondaryInfoRenderer"]?
- .try &.["metadataRowContainer"]?
- .try &.["metadataRowContainerRenderer"]?
- .try &.["rows"]?
- .try &.as_a
+ # Top level elements
+
+ primary_results = player_response
+ .dig?("contents", "twoColumnWatchNextResults", "results", "results", "contents")
+
+ video_primary_renderer = primary_results
+ .try &.as_a.find(&.["videoPrimaryInfoRenderer"]?)
+ .try &.["videoPrimaryInfoRenderer"]
+
+ video_secondary_renderer = primary_results
+ .try &.as_a.find(&.["videoSecondaryInfoRenderer"]?)
+ .try &.["videoSecondaryInfoRenderer"]
+
+ # Likes/dislikes
+
+ toplevel_buttons = video_primary_renderer
+ .try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
+
+ if toplevel_buttons
+ likes_button = toplevel_buttons.as_a
+ .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE")
+ .try &.["toggleButtonRenderer"]
+
+ if likes_button
+ likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
+ .try &.dig?("accessibility", "accessibilityData", "label")
+ likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
+
+ LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
+ LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
+ end
+
+ dislikes_button = toplevel_buttons.as_a
+ .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE")
+ .try &.["toggleButtonRenderer"]
+
+ if dislikes_button
+ dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?)
+ .try &.dig?("accessibility", "accessibilityData", "label")
+ dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt
+
+ LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"")
+ LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes
+ end
+ end
+
+ if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64)
+ if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? }
+ dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64
+ LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}")
+ end
+ end
+
+ params["likes"] = JSON::Any.new(likes || 0_i64)
+ params["dislikes"] = JSON::Any.new(dislikes || 0_i64)
+
+ # Description
+
+ description_html = video_secondary_renderer.try &.dig?("description", "runs")
+ .try &.as_a.try { |t| content_to_comment_html(t) }
+
+ params["descriptionHtml"] = JSON::Any.new(description_html || "<p></p>")
+
+ # Video metadata
+
+ metadata = video_secondary_renderer
+ .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
+ .try &.as_a
params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("")
params["genreUrl"] = JSON::Any.new(nil)
metadata.try &.each do |row|
title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s
- contents = row["metadataRowRenderer"]?
- .try &.["contents"]?
- .try &.as_a[0]?
+ contents = row.dig?("metadataRowRenderer", "contents", 0)
if title.try &.== "Category"
- contents = contents.try &.["runs"]?
- .try &.as_a[0]?
+ contents = contents.try &.dig?("runs", 0)
params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "")
params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]?
@@ -928,21 +978,23 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
end
end
- author_info = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
- .try &.["videoSecondaryInfoRenderer"]?.try &.["owner"]?.try &.["videoOwnerRenderer"]?
+ # Author infos
- params["authorThumbnail"] = JSON::Any.new(author_info.try &.["thumbnail"]?
- .try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"]?
- .try &.as_s || "")
+ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
+ author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url")
+
+ params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "")
params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]?
- .try { |t| t["simpleText"]? || t["runs"]?.try &.[0]?.try &.["text"]? }.try &.as_s.split(" ", 2)[0] || "-")
+ .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }.try &.as_s.split(" ", 2)[0] || "-")
+
+ # Return data
- params
+ return params
end
-def get_video(id, db, refresh = true, region = nil, force_refresh = false)
- if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region
+def get_video(id, refresh = true, region = nil, force_refresh = false)
+ if (video = Invidious::Database::Videos.select(id)) && !region
# If record was last updated over 10 minutes ago, or video has since premiered,
# refresh (expire param in response lasts for 6 hours)
if (refresh &&
@@ -951,17 +1003,15 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false)
force_refresh
begin
video = fetch_video(id, region)
- db.exec("UPDATE videos SET (id, info, updated) = ($1, $2, $3) WHERE id = $1", video.id, video.info.to_json, video.updated)
+ Invidious::Database::Videos.update(video)
rescue ex
- db.exec("DELETE FROM videos * WHERE id = $1", id)
+ Invidious::Database::Videos.delete(id)
raise ex
end
end
else
video = fetch_video(id, region)
- if !region
- db.exec("INSERT INTO videos VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", video.id, video.info.to_json, video.updated)
- end
+ Invidious::Database::Videos.insert(video) if !region
end
return video
@@ -1006,7 +1056,7 @@ def itag_to_metadata?(itag : JSON::Any)
return VIDEO_FORMATS[itag.to_s]?
end
-def process_continuation(db, query, plid, id)
+def process_continuation(query, plid, id)
continuation = nil
if plid
if index = query["index"]?.try &.to_i?
@@ -1023,13 +1073,13 @@ end
def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i?
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
- comments = query["comments"]?.try &.split(",").map { |a| a.downcase }
+ comments = query["comments"]?.try &.split(",").map(&.downcase)
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe }
local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe }
player_style = query["player_style"]?
- preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
+ preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
quality = query["quality"]?
quality_dash = query["quality_dash"]?
region = query["region"]?
@@ -1039,6 +1089,7 @@ def process_video_params(query, preferences)
extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe }
volume = query["volume"]?.try &.to_i?
vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe }
+ save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
if preferences
# region ||= preferences.region
@@ -1059,6 +1110,7 @@ def process_video_params(query, preferences)
extend_desc ||= preferences.extend_desc.to_unsafe
volume ||= preferences.volume
vr_mode ||= preferences.vr_mode.to_unsafe
+ save_player_pos ||= preferences.save_player_pos.to_unsafe
end
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
@@ -1078,6 +1130,7 @@ def process_video_params(query, preferences)
extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe
volume ||= CONFIG.default_user_preferences.volume
vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe
+ save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
annotations = annotations == 1
autoplay = autoplay == 1
@@ -1089,6 +1142,7 @@ def process_video_params(query, preferences)
video_loop = video_loop == 1
extend_desc = extend_desc == 1
vr_mode = vr_mode == 1
+ save_player_pos = save_player_pos == 1
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
@@ -1139,6 +1193,7 @@ def process_video_params(query, preferences)
video_start: video_start,
volume: volume,
vr_mode: vr_mode,
+ save_player_pos: save_player_pos,
})
return params
diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr
index 09eacbc8..c62861b0 100644
--- a/src/invidious/views/add_playlist_items.ecr
+++ b/src/invidious/views/add_playlist_items.ecr
@@ -41,7 +41,7 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5">
<% if page > 1 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>">
+ <a href="/add_playlist_items?list=<%= plid %>&q=<%= URI.encode_www_form(query.not_nil!) %>&page=<%= page - 1 %>">
<%= translate(locale, "Previous page") %>
</a>
<% end %>
@@ -49,7 +49,7 @@
<div class="pure-u-1 pure-u-lg-3-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
<% if count >= 20 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>">
+ <a href="/add_playlist_items?list=<%= plid %>&q=<%= URI.encode_www_form(query.not_nil!) %>&page=<%= page + 1 %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index 09cfb76e..40b553a9 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -45,7 +45,11 @@
<div class="pure-u-1-3">
<a href="https://www.youtube.com/channel/<%= ucid %>"><%= translate(locale, "View channel on YouTube") %></a>
<div class="pure-u-1 pure-md-1-3">
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
+ <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% else %>
+ <a href="https://redirect.invidious.io<%= env.request.path %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% end %>
</div>
<% if !channel.auto_generated %>
<div class="pure-u-1 pure-md-1-3">
@@ -96,7 +100,7 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5">
<% if page > 1 %>
- <a href="/channel/<%= ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
+ <a href="/channel/<%= ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Previous page") %>
</a>
<% end %>
@@ -104,7 +108,7 @@
<div class="pure-u-1 pure-u-lg-3-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
<% if count == 60 %>
- <a href="/channel/<%= ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
+ <a href="/channel/<%= ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index 15d8ed1e..f0add06b 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -44,7 +44,11 @@
<div class="pure-u-1-3">
<a href="https://www.youtube.com/channel/<%= channel.ucid %>/community"><%= translate(locale, "View channel on YouTube") %></a>
<div class="pure-u-1 pure-md-1-3">
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
+ <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% else %>
+ <a href="https://redirect.invidious.io<%= env.request.resource %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% end %>
</div>
<% if !channel.auto_generated %>
<div class="pure-u-1 pure-md-1-3">
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 68aa1812..5a93d802 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -5,13 +5,13 @@
<a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<center>
- <img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
+ <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
</center>
<% end %>
<p dir="auto"><%= HTML.escape(item.author) %></p>
</a>
- <p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p>
- <% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %>
+ <p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
+ <% if !item.auto_generated %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchPlaylist, InvidiousPlaylist %>
<% if item.id.starts_with? "RD" %>
@@ -23,8 +23,8 @@
<a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
- <p class="length"><%= number_with_separator(item.video_count) %> videos</p>
+ <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
+ <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div>
<% end %>
<p dir="auto"><%= HTML.escape(item.title) %></p>
@@ -36,7 +36,7 @@
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %>
@@ -48,10 +48,10 @@
<p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
</a>
<% when PlaylistVideo %>
- <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>">
+ <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if plid = env.get?("remove_playlist_items") %>
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
@@ -79,6 +79,8 @@
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
</a></div>
+ <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %>
+ <%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
@@ -92,15 +94,16 @@
<% if item.responds_to?(:views) && item.views %>
<div class="flex-right">
- <p dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p>
+ <p dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
</div>
<% end %>
</div>
+ <% when Category %>
<% else %>
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %>
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
@@ -141,19 +144,9 @@
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
</a></div>
- <div class="flex-right">
- <div class="icon-buttons">
- <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>">
- <i class="icon ion-logo-youtube"></i>
- </a>
- <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&amp;listen=1">
- <i class="icon ion-md-headset"></i>
- </a>
- <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>">
- <i class="icon ion-md-jet"></i>
- </a>
- </div>
- </div>
+
+ <% endpoint_params = "?v=#{item.id}" %>
+ <%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
@@ -167,7 +160,7 @@
<% if item.responds_to?(:views) && item.views %>
<div class="flex-right">
- <p class="video-data" dir="auto"><%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %></p>
+ <p class="video-data" dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
</div>
<% end %>
</div>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index 6418f66b..206ba380 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -32,13 +32,11 @@
<% end %>
<% preferred_captions.each do |caption| %>
- <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>&hl=<%= env.get("preferences").as(Preferences).locale %>"
- label="<%= caption.name %>">
+ <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>">
<% end %>
<% captions.each do |caption| %>
- <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>&hl=<%= env.get("preferences").as(Preferences).locale %>"
- label="<%= caption.name %>">
+ <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>">
<% end %>
<% end %>
</video>
diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr
new file mode 100644
index 00000000..1240e5bd
--- /dev/null
+++ b/src/invidious/views/components/search_box.ecr
@@ -0,0 +1,9 @@
+<form class="pure-form" action="/search" method="get">
+ <fieldset>
+ <input type="search" id="searchbox" autocomplete="off" autocorrect="off"
+ autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
+ name="q" placeholder="<%= translate(locale, "search") %>"
+ title="<%= translate(locale, "search") %>"
+ value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
+ </fieldset>
+</form>
diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr
new file mode 100644
index 00000000..ddb6c983
--- /dev/null
+++ b/src/invidious/views/components/video-context-buttons.ecr
@@ -0,0 +1,21 @@
+<div class="flex-right">
+ <div class="icon-buttons">
+ <a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" href="https://www.youtube.com/watch<%=endpoint_params%>">
+ <i class="icon ion-logo-youtube"></i>
+ </a>
+ <a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
+ <i class="icon ion-md-headset"></i>
+ </a>
+
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
+ <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
+ <i class="icon ion-md-jet"></i>
+ </a>
+ <% else %>
+ <a title="<%=translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>">
+ <i class="icon ion-md-jet"></i>
+ </a>
+ <% end %>
+
+ </div>
+</div> \ No newline at end of file
diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr
index 5046abc1..308bd677 100644
--- a/src/invidious/views/edit_playlist.ecr
+++ b/src/invidious/views/edit_playlist.ecr
@@ -11,7 +11,7 @@
<h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3>
<b>
<%= HTML.escape(playlist.author) %> |
- <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> |
+ <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i>
<select name="privacy">
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index 40584979..6c1243c5 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -4,11 +4,11 @@
<div class="pure-g h-box">
<div class="pure-u-1-3">
- <h3><%= translate(locale, "`x` videos", %(<span id="count">#{user.watched.size}</span>)) %></h3>
+ <h3><%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %></h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
- <a href="/feed/subscriptions"><%= translate(locale, "`x` subscriptions", %(<span id="count">#{user.subscriptions.size}</span>)) %></a>
+ <a href="/feed/subscriptions"><%= translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, NumberFormatting::HtmlSpan) %></a>
</h3>
</div>
<div class="pure-u-1-3">
diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr
index 868cfeda..a59344c4 100644
--- a/src/invidious/views/feeds/playlists.ecr
+++ b/src/invidious/views/feeds/playlists.ecr
@@ -6,7 +6,7 @@
<div class="pure-g h-box">
<div class="pure-u-2-3">
- <h3><%= translate(locale, "`x` created playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
+ <h3><%= translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
</div>
<div class="pure-u-1-3" style="text-align:right">
<h3>
@@ -23,7 +23,7 @@
<div class="pure-g h-box">
<div class="pure-u-1">
- <h3><%= translate(locale, "`x` saved playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
+ <h3><%= translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
</div>
</div>
diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr
index 97184e2b..8d56ad14 100644
--- a/src/invidious/views/feeds/subscriptions.ecr
+++ b/src/invidious/views/feeds/subscriptions.ecr
@@ -24,7 +24,7 @@
</div>
<center>
- <%= translate(locale, "`x` unseen notifications", "#{notifications.size}") %>
+ <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
<% if !notifications.empty? %>
diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr
index 1f6618e8..e2963e9f 100644
--- a/src/invidious/views/login.ecr
+++ b/src/invidious/views/login.ecr
@@ -6,21 +6,6 @@
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
- <div class="pure-g">
- <div class="pure-u-1-2">
- <a class="pure-button <% if account_type == "invidious" %>pure-button-disabled<% end %>" href="/login?type=invidious">
- <%= translate(locale, "Log in/register") %>
- </a>
- </div>
- <div class="pure-u-1-2">
- <a class="pure-button <% if account_type == "google" %>pure-button-disabled<% end %>" href="/login?type=google">
- <%= translate(locale, "Log in with Google") %>
- </a>
- </div>
- </div>
-
- <hr>
-
<% case account_type when %>
<% when "google" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post">
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr
index 12f93a72..df3112db 100644
--- a/src/invidious/views/playlist.ecr
+++ b/src/invidious/views/playlist.ecr
@@ -16,7 +16,7 @@
<% else %>
<%= author %> |
<% end %>
- <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> |
+ <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<% case playlist.as(InvidiousPlaylist).privacy when %>
<% when PlaylistPrivacy::Public %>
@@ -30,7 +30,7 @@
<% else %>
<b>
<a href="/channel/<%= playlist.ucid %>"><%= author %></a> |
- <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> |
+ <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
</b>
<% end %>
@@ -41,9 +41,16 @@
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>
- <a href="/redirect?referer=<%= env.get?("current_page") %>">
- <%= translate(locale, "Switch Invidious Instance") %>
- </a>
+
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
+ <a href="/redirect?referer=<%= env.get?("current_page") %>">
+ <%= translate(locale, "Switch Invidious Instance") %>
+ </a>
+ <% else %>
+ <a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>">
+ <%= translate(locale, "Switch Invidious Instance") %>
+ </a>
+ <% end %>
</div>
<% end %>
</div>
@@ -54,7 +61,7 @@
<div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div>
<div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
<% else %>
- <% if PG_DB.query_one?("SELECT id FROM playlists WHERE id = $1", playlist.id, as: String).nil? %>
+ <% if Invidious::Database::Playlists.exists?(playlist.id) %>
<div class="pure-u-1-3"><a href="/subscribe_playlist?list=<%= plid %>"><i class="icon ion-md-add"></i></a></div>
<% else %>
<div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
@@ -67,9 +74,7 @@
</div>
<div class="h-box">
- <div id="descriptionWrapper">
- <p><%= playlist.description_html %></p>
- </div>
+ <div id="descriptionWrapper"><%= playlist.description_html %></div>
</div>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
index d9a17a9b..12dba088 100644
--- a/src/invidious/views/playlists.ecr
+++ b/src/invidious/views/playlists.ecr
@@ -47,7 +47,11 @@
</div>
<div class="pure-u-1 pure-md-1-3">
- <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
+ <a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% else %>
+ <a href="https://redirect.invidious.io<%= env.request.resource %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% end %>
</div>
<div class="pure-u-1 pure-md-1-3">
@@ -96,7 +100,7 @@
<div class="pure-u-1 pure-u-md-4-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
<% if continuation %>
- <a href="/channel/<%= ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
+ <a href="/channel/<%= ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= URI.encode_www_form(sort_by) %><% end %>">
<%= translate(locale, "Next page") %>
</a>
<% end %>
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr
index be021c59..96904259 100644
--- a/src/invidious/views/preferences.ecr
+++ b/src/invidious/views/preferences.ecr
@@ -5,40 +5,40 @@
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
- <legend><%= translate(locale, "Player preferences") %></legend>
+ <legend><%= translate(locale, "preferences_category_player") %></legend>
<div class="pure-control-group">
- <label for="video_loop"><%= translate(locale, "Always loop: ") %></label>
+ <label for="video_loop"><%= translate(locale, "preferences_video_loop_label") %></label>
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="autoplay"><%= translate(locale, "Autoplay: ") %></label>
+ <label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="continue"><%= translate(locale, "Play next by default: ") %></label>
+ <label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if preferences.continue %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="continue_autoplay"><%= translate(locale, "Autoplay next video: ") %></label>
+ <label for="continue_autoplay"><%= translate(locale, "preferences_continue_autoplay_label") %></label>
<input name="continue_autoplay" id="continue_autoplay" type="checkbox" <% if preferences.continue_autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="local"><%= translate(locale, "Proxy videos: ") %></label>
+ <label for="local"><%= translate(locale, "preferences_local_label") %></label>
<input name="local" id="local" type="checkbox" <% if preferences.local && !CONFIG.disabled?("local") %>checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>>
</div>
<div class="pure-control-group">
- <label for="listen"><%= translate(locale, "Listen by default: ") %></label>
+ <label for="listen"><%= translate(locale, "preferences_listen_label") %></label>
<input name="listen" id="listen" type="checkbox" <% if preferences.listen %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="speed"><%= translate(locale, "Default speed: ") %></label>
+ <label for="speed"><%= translate(locale, "preferences_speed_label") %></label>
<select name="speed" id="speed">
<% {2.0, 1.75, 1.5, 1.25, 1.0, 0.75, 0.5, 0.25}.each do |option| %>
<option <% if preferences.speed == option %> selected <% end %>><%= option %></option>
@@ -47,11 +47,11 @@
</div>
<div class="pure-control-group">
- <label for="quality"><%= translate(locale, "Preferred video quality: ") %></label>
+ <label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
<select name="quality" id="quality">
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
- <option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
<% end %>
<% end %>
</select>
@@ -59,23 +59,23 @@
<% if !CONFIG.disabled?("dash") %>
<div class="pure-control-group">
- <label for="quality_dash"><%= translate(locale, "Preferred dash video quality: ") %></label>
+ <label for="quality_dash"><%= translate(locale, "preferences_quality_dash_label") %></label>
<select name="quality_dash" id="quality_dash">
<% {"auto", "best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "worst"}.each do |option| %>
- <option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option>
<% end %>
</select>
</div>
<% end %>
<div class="pure-control-group">
- <label for="volume"><%= translate(locale, "Player volume: ") %></label>
+ <label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
<span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span>
</div>
<div class="pure-control-group">
- <label for="comments[0]"><%= translate(locale, "Default comments: ") %></label>
+ <label for="comments[0]"><%= translate(locale, "preferences_comments_label") %></label>
<% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %>
@@ -86,7 +86,7 @@
</div>
<div class="pure-control-group">
- <label for="captions[0]"><%= translate(locale, "Default captions: ") %></label>
+ <label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label>
<% preferences.captions.each_with_index do |caption, index| %>
<select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]">
<% CAPTION_LANGUAGES.each do |option| %>
@@ -97,38 +97,52 @@
</div>
<div class="pure-control-group">
- <label for="related_videos"><%= translate(locale, "Show related videos: ") %></label>
+ <label for="related_videos"><%= translate(locale, "preferences_related_videos_label") %></label>
<input name="related_videos" id="related_videos" type="checkbox" <% if preferences.related_videos %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="annotations"><%= translate(locale, "Show annotations by default: ") %></label>
+ <label for="annotations"><%= translate(locale, "preferences_annotations_label") %></label>
<input name="annotations" id="annotations" type="checkbox" <% if preferences.annotations %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="extend_desc"><%= translate(locale, "Automatically extend video description: ") %></label>
+ <label for="extend_desc"><%= translate(locale, "preferences_extend_desc_label") %></label>
<input name="extend_desc" id="extend_desc" type="checkbox" <% if preferences.extend_desc %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="vr_mode"><%= translate(locale, "Interactive 360 degree videos") %></label>
+ <label for="vr_mode"><%= translate(locale, "preferences_vr_mode_label") %></label>
<input name="vr_mode" id="vr_mode" type="checkbox" <% if preferences.vr_mode %>checked<% end %>>
</div>
- <legend><%= translate(locale, "Visual preferences") %></legend>
+ <div class="pure-control-group">
+ <label for="save_player_pos"><%= translate(locale, "preferences_save_player_pos_label") %></label>
+ <input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>>
+ </div>
+
+ <legend><%= translate(locale, "preferences_category_visual") %></legend>
<div class="pure-control-group">
- <label for="locale"><%= translate(locale, "Language: ") %></label>
+ <label for="locale"><%= translate(locale, "preferences_locale_label") %></label>
<select name="locale" id="locale">
- <% LOCALES.each_key do |option| %>
- <option value="<%= option %>" <% if preferences.locale == option %> selected <% end %>><%= option %></option>
+ <% LOCALES_LIST.each do |iso_name, full_name| %>
+ <option value="<%= iso_name %>" <% if preferences.locale == iso_name %> selected <% end %>><%= HTML.escape(full_name) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
- <label for="player_style"><%= translate(locale, "Player style: ") %></label>
+ <label for="region"><%= translate(locale, "preferences_region_label") %></label>
+ <select name="region" id="region">
+ <% CONTENT_REGIONS.each do |option| %>
+ <option value="<%= option %>" <% if preferences.region == option %> selected <% end %>><%= option %></option>
+ <% end %>
+ </select>
+ </div>
+
+ <div class="pure-control-group">
+ <label for="player_style"><%= translate(locale, "preferences_player_style_label") %></label>
<select name="player_style" id="player_style">
<% {"invidious", "youtube"}.each do |option| %>
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= translate(locale, option) %></option>
@@ -137,7 +151,7 @@
</div>
<div class="pure-control-group">
- <label for="dark_mode"><%= translate(locale, "Theme: ") %></label>
+ <label for="dark_mode"><%= translate(locale, "preferences_dark_mode_label") %></label>
<select name="dark_mode" id="dark_mode">
<% {"", "light", "dark"}.each do |option| %>
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
@@ -146,7 +160,7 @@
</div>
<div class="pure-control-group">
- <label for="thin_mode"><%= translate(locale, "Thin mode: ") %></label>
+ <label for="thin_mode"><%= translate(locale, "preferences_thin_mode_label") %></label>
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
</div>
@@ -157,7 +171,7 @@
<% end %>
<div class="pure-control-group">
- <label for="default_home"><%= translate(locale, "Default homepage: ") %></label>
+ <label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="default_home" id="default_home">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
@@ -166,7 +180,7 @@
</div>
<div class="pure-control-group">
- <label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label>
+ <label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
@@ -177,33 +191,33 @@
</div>
<% if env.get? "user" %>
<div class="pure-control-group">
- <label for="show_nick"><%= translate(locale, "Show nickname on top: ") %></label>
+ <label for="show_nick"><%= translate(locale, "preferences_show_nick_label") %></label>
<input name="show_nick" id="show_nick" type="checkbox" <% if preferences.show_nick %>checked<% end %>>
</div>
<% end %>
- <legend><%= translate(locale, "Miscellaneous preferences") %></legend>
+ <legend><%= translate(locale, "preferences_category_misc") %></legend>
<div class="pure-control-group">
- <label for="automatic_instance_redirect"><%= translate(locale, "Automaticatic instance redirection (fallback to redirect.invidious.io): ") %></label>
+ <label for="automatic_instance_redirect"><%= translate(locale, "preferences_automatic_instance_redirect_label") %></label>
<input name="automatic_instance_redirect" id="automatic_instance_redirect" type="checkbox" <% if preferences.automatic_instance_redirect %>checked<% end %>>
</div>
<% if env.get? "user" %>
- <legend><%= translate(locale, "Subscription preferences") %></legend>
+ <legend><%= translate(locale, "preferences_category_subscription") %></legend>
<div class="pure-control-group">
- <label for="annotations_subscribed"><%= translate(locale, "Show annotations by default for subscribed channels: ") %></label>
+ <label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="max_results"><%= translate(locale, "Number of videos shown in feed: ") %></label>
+ <label for="max_results"><%= translate(locale, "preferences_max_results_label") %></label>
<input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>">
</div>
<div class="pure-control-group">
- <label for="sort"><%= translate(locale, "Sort videos by: ") %></label>
+ <label for="sort"><%= translate(locale, "preferences_sort_label") %></label>
<select name="sort" id="sort">
<% {"published", "published - reverse", "alphabetically", "alphabetically - reverse", "channel name", "channel name - reverse"}.each do |option| %>
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= translate(locale, option) %></option>
@@ -221,12 +235,12 @@
</div>
<div class="pure-control-group">
- <label for="unseen_only"><%= translate(locale, "Only show unwatched: ") %></label>
+ <label for="unseen_only"><%= translate(locale, "preferences_unseen_only_label") %></label>
<input name="unseen_only" id="unseen_only" type="checkbox" <% if preferences.unseen_only %>checked<% end %>>
</div>
<div class="pure-control-group">
- <label for="notifications_only"><%= translate(locale, "Only show notifications (if there are any): ") %></label>
+ <label for="notifications_only"><%= translate(locale, "preferences_notifications_only_label") %></label>
<input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>>
</div>
@@ -239,10 +253,10 @@
<% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %>
- <legend><%= translate(locale, "Administrator preferences") %></legend>
+ <legend><%= translate(locale, "preferences_category_admin") %></legend>
<div class="pure-control-group">
- <label for="admin_default_home"><%= translate(locale, "Default homepage: ") %></label>
+ <label for="admin_default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="admin_default_home" id="admin_default_home">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
@@ -251,7 +265,7 @@
</div>
<div class="pure-control-group">
- <label for="admin_feed_menu"><%= translate(locale, "Feed menu: ") %></label>
+ <label for="admin_feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
@@ -286,10 +300,15 @@
<label for="statistics_enabled"><%= translate(locale, "Report statistics: ") %></label>
<input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>>
</div>
+
+ <div class="pure-control-group">
+ <label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
+ <input name="modified_source_code_url" id="modified_source_code_url" type="input" <% if CONFIG.modified_source_code_url %>checked<% end %>>
+ </div>
<% end %>
<% if env.get? "user" %>
- <legend><%= translate(locale, "Data preferences") %></legend>
+ <legend><%= translate(locale, "preferences_category_data") %></legend>
<div class="pure-control-group">
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Clear watch history") %></a>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr
index fd176e41..db374548 100644
--- a/src/invidious/views/search.ecr
+++ b/src/invidious/views/search.ecr
@@ -2,7 +2,7 @@
<title><%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious</title>
<% end %>
-<% search_query_encoded = env.get?("search").try { |x| URI.encode(x.as(String), space_to_plus: true) } %>
+<% search_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %>
<!-- Search redirection and filtering UI -->
<% if count == 0 %>
@@ -23,7 +23,7 @@
<% if operator_hash.fetch("date", "all") == date %>
<b><%= translate(locale, date) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?date:[a-z]+/, "") + " date:" + date) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?date:[a-z]+/, "") + " date:" + date) %>&page=<%= page %>">
<%= translate(locale, date) %>
</a>
<% end %>
@@ -38,7 +38,7 @@
<% if operator_hash.fetch("content_type", "all") == content_type %>
<b><%= translate(locale, content_type) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?content_type:[a-z]+/, "") + " content_type:" + content_type) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?content_type:[a-z]+/, "") + " content_type:" + content_type) %>&page=<%= page %>">
<%= translate(locale, content_type) %>
</a>
<% end %>
@@ -53,7 +53,7 @@
<% if operator_hash.fetch("duration", "all") == duration %>
<b><%= translate(locale, duration) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?duration:[a-z]+/, "") + " duration:" + duration) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?duration:[a-z]+/, "") + " duration:" + duration) %>&page=<%= page %>">
<%= translate(locale, duration) %>
</a>
<% end %>
@@ -68,11 +68,11 @@
<% if operator_hash.fetch("features", "all").includes?(feature) %>
<b><%= translate(locale, feature) %></b>
<% elsif operator_hash.has_key?("features") %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/features:/, "features:" + feature + ",")) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/features:/, "features:" + feature + ",")) %>&page=<%= page %>">
<%= translate(locale, feature) %>
</a>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil! + " features:" + feature) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil! + " features:" + feature) %>&page=<%= page %>">
<%= translate(locale, feature) %>
</a>
<% end %>
@@ -87,7 +87,7 @@
<% if operator_hash.fetch("sort", "relevance") == sort %>
<b><%= translate(locale, sort) %></b>
<% else %>
- <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?sort:[a-z]+/, "") + " sort:" + sort) %>&page=<%= page %>">
+ <a href="/search?q=<%= URI.encode_www_form(query.not_nil!.gsub(/ ?sort:[a-z]+/, "") + " sort:" + sort) %>&page=<%= page %>">
<%= translate(locale, sort) %>
</a>
<% end %>
diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr
index 7d2dab83..2424a1cf 100644
--- a/src/invidious/views/search_homepage.ecr
+++ b/src/invidious/views/search_homepage.ecr
@@ -14,11 +14,7 @@
</div>
<div class="pure-u-1-4"></div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
- <form class="pure-form" action="/search" method="get">
- <fieldset>
- <input autofocus type="search" style="width:100%" name="q" placeholder="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
- </fieldset>
- </form>
+ <% autofocus = true %><%= rendered "components/search_box" %>
</div>
<div class="pure-u-1-4"></div>
</div>
diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr
index acf015f5..5fa7d203 100644
--- a/src/invidious/views/subscription_manager.ecr
+++ b/src/invidious/views/subscription_manager.ecr
@@ -6,7 +6,7 @@
<div class="pure-u-1-3">
<h3>
<a href="/feed/subscriptions">
- <%= translate(locale, "`x` subscriptions", %(<span id="count">#{subscriptions.size}</span>)) %>
+ <%= translate_count(locale, "generic_subscriptions_count", subscriptions.size, NumberFormatting::HtmlSpan) %>
</a>
</h3>
</div>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 7be95959..240b523a 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -19,8 +19,10 @@
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
</head>
-<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %>
-<% dark_mode = env.get("preferences").as(Preferences).dark_mode %>
+<%
+ locale = env.get("preferences").as(Preferences).locale
+ dark_mode = env.get("preferences").as(Preferences).dark_mode
+%>
<body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme">
<span style="display:none" id="dark_mode_pref"><%= env.get("preferences").as(Preferences).dark_mode %></span>
@@ -33,11 +35,7 @@
<a href="/" class="index-link pure-menu-heading">Invidious</a>
</div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
- <form class="pure-form" action="/search" method="get">
- <fieldset>
- <input type="search" style="width:100%" name="q" placeholder="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
- </fieldset>
- </form>
+ <% autofocus = false %><%= rendered "components/search_box" %>
</div>
<% end %>
@@ -117,38 +115,45 @@
<footer>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
- <a href="https://github.com/iv-org/invidious">
- <%= translate(locale, "Released under the AGPLv3 on Github.") %>
- </a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-ios-wallet"></i>
- BTC: <a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr</a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-ios-wallet"></i>
- XMR: <a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">Click here</a>
- </div>
- <div class="pure-u-1 pure-u-md-1-3">
- <a href="https://github.com/iv-org/documentation">Documentation</a>
+ <span>
+ <i class="icon ion-logo-github"></i>
+ <% if CONFIG.modified_source_code_url %>
+ <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a>&nbsp;/
+ <a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
+ <% else %>
+ <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
+ <% end %>
+ </span>
+ <span>
+ <i class="icon ion-ios-paper"></i>
+ <a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
+ </span>
</div>
+
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-javascript"></i>
- <a rel="jslicense" href="/licenses">
- <%= translate(locale, "View JavaScript license information.") %>
- </a>
- /
- <i class="icon ion-ios-paper"></i>
- <a href="/privacy">
- <%= translate(locale, "View privacy policy.") %>
- </a>
+ <span>
+ <a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
+ </span>
+ <span>
+ <i class="icon ion-logo-javascript"></i>
+ <a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
+ </span>
+ <span>
+ <i class="icon ion-ios-paper"></i>
+ <a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
+ </span>
</div>
+
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-github"></i>
- <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
+ <span>
+ <i class="icon ion-ios-wallet"></i>
+ <a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a>
+ </span>
+ <span><%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %></span>
</div>
</div>
</footer>
+
</div>
<div class="pure-u-1 pure-u-md-2-24"></div>
</div>
diff --git a/src/invidious/views/token_manager.ecr b/src/invidious/views/token_manager.ecr
index e48aec2f..12e0e8c9 100644
--- a/src/invidious/views/token_manager.ecr
+++ b/src/invidious/views/token_manager.ecr
@@ -5,7 +5,7 @@
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
- <%= translate(locale, "`x` tokens", %(<span id="count">#{tokens.size}</span>)) %>
+ <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %>
</h3>
</div>
<div class="pure-u-1-3"></div>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 68e7eb80..00f5f8b7 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -11,7 +11,7 @@
<meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= title %>">
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg">
-<meta property="og:description" content="<%= video.short_description %>">
+<meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
<meta property="og:type" content="video.other">
<meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">
<meta property="og:video:secure_url" content="<%= HOST_URL %>/embed/<%= video.id %>">
@@ -22,7 +22,7 @@
<meta name="twitter:site" content="@omarroth1">
<meta name="twitter:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta name="twitter:title" content="<%= title %>">
-<meta name="twitter:description" content="<%= video.short_description %>">
+<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>">
<meta name="twitter:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>">
<meta name="twitter:player:width" content="1280">
@@ -103,7 +103,7 @@ we're going to need to do it here in order to allow for translations.
</h3>
<% elsif video.live_now %>
<h3>
- <%= video.premiere_timestamp.try { |t| translate(locale, "Started streaming `x` ago", recode_date((Time.utc - t).ago, locale)) } %>
+ <%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %>
</h3>
<% end %>
</div>
@@ -112,14 +112,18 @@ we're going to need to do it here in order to allow for translations.
<div class="pure-u-1 pure-u-lg-1-5">
<div class="h-box">
<span id="watch-on-youtube">
- <a href="https://www.youtube.com/watch?v=<%= video.id %>"><%= translate(locale, "Watch on YouTube") %></a>
- (<a href="https://www.youtube.com/embed/<%= video.id %>"><%= translate(locale, "Embed") %></a>)
+ <a href="https://www.youtube.com/watch?v=<%= video.id %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
+ (<a href="https://www.youtube.com/embed/<%= video.id %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
</span>
<p id="watch-on-another-invidious-instance">
+ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a href="/redirect?referer=<%= env.get?("current_page") %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% else %>
+ <a href="https://redirect.invidious.io<%= env.request.resource %>"><%= translate(locale, "Switch Invidious Instance") %></a>
+ <% end %>
</p>
<p id="embed-link">
- <a href="<%= embed_link %>"><%= translate(locale, "Embed Link") %></a>
+ <a href="<%= embed_link %>"><%= translate(locale, "videoinfo_invidious_embed_link") %></a>
</p>
<p id="annotations">
<% if params.annotations %>
@@ -134,9 +138,9 @@ we're going to need to do it here in order to allow for translations.
</p>
<% if user %>
- <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %>
+ <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %>
- <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post">
+ <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank">
<div class="pure-control-group">
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
<select style="width:100%" name="playlist_id" id="playlist_id">
@@ -146,6 +150,9 @@ we're going to need to do it here in order to allow for translations.
</select>
</div>
+ <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
+ <input type="hidden" name="action_add_video" value="1">
+ <input type="hidden" name="video_id" value="<%= video.id %>">
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
<b><%= translate(locale, "Add to playlist") %></b>
</button>
@@ -184,8 +191,8 @@ we're going to need to do it here in order to allow for translations.
</option>
<% end %>
<% captions.each do |caption| %>
- <option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.languageCode %>.vtt"}'>
- <%= translate(locale, "Subtitles - `x` (.vtt)", caption.name) %>
+ <option value='{"id":"<%= video.id %>","label":"<%= caption.name %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.language_code %>.vtt"}'>
+ <%= translate(locale, "download_subtitles", translate(locale, caption.name)) %>
</option>
<% end %>
</select>
@@ -250,14 +257,10 @@ we're going to need to do it here in order to allow for translations.
<div id="description-box"> <!-- Description -->
<% if video.description.size < 200 || params.extend_desc %>
- <div id="descriptionWrapper">
- <%= video.description_html %>
- </div>
+ <div id="descriptionWrapper"><%= video.description_html %></div>
<% else %>
<input id="descexpansionbutton" type="checkbox"/>
- <div id="descriptionWrapper">
- <%= video.description_html %>
- </div>
+ <div id="descriptionWrapper"><%= video.description_html %></div>
<label for="descexpansionbutton">
<a></a>
</label>
@@ -291,7 +294,7 @@ we're going to need to do it here in order to allow for translations.
<% if !video.related_videos.empty? %>
<div <% if plid %>style="display:none"<% end %>>
<div class="pure-control-group">
- <label for="continue"><%= translate(locale, "Play next by default: ") %></label>
+ <label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if params.continue %>checked<% end %>>
</div>
<hr>
@@ -303,7 +306,7 @@ we're going to need to do it here in order to allow for translations.
<a href="/watch?v=<%= rv["id"] %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
+ <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
<p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p>
</div>
<% end %>
@@ -320,7 +323,7 @@ we're going to need to do it here in order to allow for translations.
<div class="pure-u-10-24" style="text-align:right">
<% if views = rv["short_view_count_text"]?.try &.delete(", views watching") %>
<% if !views.empty? %>
- <b class="width:100%"><%= translate(locale, "`x` views", views) %></b>
+ <b class="width:100%"><%= translate_count(locale, "generic_views_count", views.to_i? || 0) %></b>
<% end %>
<% end %>
</div>
diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr
new file mode 100644
index 00000000..3feb9233
--- /dev/null
+++ b/src/invidious/yt_backend/connection_pool.cr
@@ -0,0 +1,113 @@
+{% unless flag?(:disable_quic) %}
+ require "lsquic"
+
+ alias HTTPClientType = QUIC::Client | HTTP::Client
+{% else %}
+ alias HTTPClientType = HTTP::Client
+{% end %}
+
+def add_yt_headers(request)
+ request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36"
+ request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
+ request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+ request.headers["accept-language"] ||= "en-us,en;q=0.5"
+ return if request.resource.starts_with? "/sorry/index"
+ request.headers["x-youtube-client-name"] ||= "1"
+ request.headers["x-youtube-client-version"] ||= "2.20200609"
+ # Preserve original cookies and add new YT consent cookie for EU servers
+ request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+"
+ if !CONFIG.cookies.empty?
+ request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
+ end
+end
+
+struct YoutubeConnectionPool
+ property! url : URI
+ property! capacity : Int32
+ property! timeout : Float64
+ property pool : DB::Pool(HTTPClientType)
+
+ def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true)
+ @url = url
+ @pool = build_pool(use_quic)
+ end
+
+ def client(region = nil, &block)
+ if region
+ conn = make_client(url, region)
+ response = yield conn
+ else
+ conn = pool.checkout
+ begin
+ response = yield conn
+ rescue ex
+ conn.close
+ {% unless flag?(:disable_quic) %}
+ conn = CONFIG.use_quic ? QUIC::Client.new(url) : HTTP::Client.new(url)
+ {% else %}
+ conn = HTTP::Client.new(url)
+ {% end %}
+
+ conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
+ conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
+ conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
+ response = yield conn
+ ensure
+ pool.release(conn)
+ end
+ end
+
+ response
+ end
+
+ private def build_pool(use_quic)
+ DB::Pool(HTTPClientType).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
+ conn = nil # Declare
+ {% unless flag?(:disable_quic) %}
+ if use_quic
+ conn = QUIC::Client.new(url)
+ else
+ conn = HTTP::Client.new(url)
+ end
+ {% else %}
+ conn = HTTP::Client.new(url)
+ {% end %}
+
+ conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
+ conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
+ conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
+ conn
+ end
+ end
+end
+
+def make_client(url : URI, region = nil)
+ # TODO: Migrate any applicable endpoints to QUIC
+ client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
+ client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
+ client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
+ client.read_timeout = 10.seconds
+ client.connect_timeout = 10.seconds
+
+ if region
+ PROXY_LIST[region]?.try &.sample(40).each do |proxy|
+ begin
+ proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
+ client.set_proxy(proxy)
+ break
+ rescue ex
+ end
+ end
+ end
+
+ return client
+end
+
+def make_client(url : URI, region = nil, &block)
+ client = make_client(url, region)
+ begin
+ yield client
+ ensure
+ client.close
+ end
+end
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
new file mode 100644
index 00000000..66b3cdef
--- /dev/null
+++ b/src/invidious/yt_backend/extractors.cr
@@ -0,0 +1,604 @@
+# This file contains helper methods to parse the Youtube API json data into
+# neat little packages we can use
+
+# Tuple of Parsers/Extractors so we can easily cycle through them.
+private ITEM_CONTAINER_EXTRACTOR = {
+ Extractors::YouTubeTabs,
+ Extractors::SearchResults,
+ Extractors::Continuation,
+}
+
+private ITEM_PARSERS = {
+ Parsers::VideoRendererParser,
+ Parsers::ChannelRendererParser,
+ Parsers::GridPlaylistRendererParser,
+ Parsers::PlaylistRendererParser,
+ Parsers::CategoryRendererParser,
+}
+
+record AuthorFallback, name : String, id : String
+
+# Namespace for logic relating to parsing InnerTube data into various datastructs.
+#
+# Each of the parsers in this namespace are accessed through the #process() method
+# which validates the given data as applicable to itself. If it is applicable the given
+# data is passed to the private `#parse()` method which returns a datastruct of the given
+# type. Otherwise, nil is returned.
+private module Parsers
+ # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer
+ #
+ # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not**
+ # the watchable video itself.
+ #
+ # See specs for example.
+ #
+ # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module VideoRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?)
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ video_id = item_contents["videoId"].as_s
+ title = extract_text(item_contents["title"]?) || ""
+
+ # Extract author information
+ if author_info = item_contents.dig?("ownerText", "runs", 0)
+ author = author_info["text"].as_s
+ author_id = HelperExtractors.get_browse_id(author_info)
+ elsif author_info = item_contents.dig?("shortBylineText", "runs", 0)
+ author = author_info["text"].as_s
+ author_id = HelperExtractors.get_browse_id(author_info)
+ else
+ author = author_fallback.name
+ author_id = author_fallback.id
+ end
+
+ # For live videos (and possibly recently premiered videos) there is no published information.
+ # Instead, in its place is the amount of people currently watching. This behavior should be replicated
+ # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current
+ # time for publishing isn't a good idea.
+ published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local
+
+ # Typically views are stored under a "simpleText" in the "viewCountText". However, for
+ # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}]
+ # When view count is disabled the "viewCountText" is not present on InnerTube data.
+ # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc)
+ # and count
+ view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64
+ description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
+
+ # The length information generally exist in "lengthText". However, the info can sometimes
+ # be retrieved from "thumbnailOverlays" (e.g when the video is a "shorts" one).
+ if length_container = item_contents["lengthText"]?
+ length_seconds = decode_length_seconds(length_container["simpleText"].as_s)
+ elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?)
+ # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires
+ # a specific pathway then we should add an argument to extract_text that'll make this possible
+ length_text = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText")
+
+ if length_text
+ length_text = length_text.as_s
+
+ if length_text == "SHORTS"
+ # Approximate length to one minute, as "shorts" generally don't exceed that length.
+ # TODO: Add some sort of metadata for the type of video (normal, live, premiere, shorts)
+ length_seconds = 60_i32
+ else
+ length_seconds = decode_length_seconds(length_text)
+ end
+ else
+ length_seconds = 0
+ end
+ else
+ length_seconds = 0
+ end
+
+ live_now = false
+ paid = false
+ premium = false
+
+ premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
+
+ item_contents["badges"]?.try &.as_a.each do |badge|
+ b = badge["metadataBadgeRenderer"]
+ case b["label"].as_s
+ when "LIVE NOW"
+ live_now = true
+ when "New", "4K", "CC"
+ # TODO
+ when "Premium"
+ # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
+ premium = true
+ else nil # Ignore
+ end
+ end
+
+ SearchVideo.new({
+ title: title,
+ id: video_id,
+ author: author,
+ ucid: author_id,
+ published: published,
+ views: view_count,
+ description_html: description_html,
+ length_seconds: length_seconds,
+ live_now: live_now,
+ premium: premium,
+ premiere_timestamp: premiere_timestamp,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer
+ #
+ # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not**
+ # the channel page itself.
+ #
+ # See specs for example.
+ #
+ # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module ChannelRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?)
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ author = extract_text(item_contents["title"]) || author_fallback.name
+ author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id
+
+ author_thumbnail = HelperExtractors.get_thumbnails(item_contents)
+ # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube.
+ # Always simpleText
+ # TODO change default value to nil
+ subscriber_count = item_contents.dig?("subscriberCountText", "simpleText")
+ .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0
+
+ # Auto-generated channels doesn't have videoCountText
+ # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922
+ auto_generated = item_contents["videoCountText"]?.nil?
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || ""
+
+ SearchChannel.new({
+ author: author,
+ ucid: author_id,
+ author_thumbnail: author_thumbnail,
+ subscriber_count: subscriber_count,
+ video_count: video_count,
+ description_html: description_html,
+ auto_generated: auto_generated,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer
+ #
+ # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI.
+ # It is **not** the playlist itself.
+ #
+ # See specs for example.
+ #
+ # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories.
+ #
+ module GridPlaylistRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["gridPlaylistRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = extract_text(item_contents["title"]) || ""
+ plid = item_contents["playlistId"]?.try &.as_s || ""
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents)
+
+ SearchPlaylist.new({
+ title: title,
+ id: plid,
+ author: author_fallback.name,
+ ucid: author_fallback.id,
+ video_count: video_count,
+ videos: [] of SearchPlaylistVideo,
+ thumbnail: playlist_thumbnail,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer
+ #
+ # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself.
+ #
+ # See specs for example.
+ #
+ # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc.
+ #
+ module PlaylistRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["playlistRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = item_contents["title"]["simpleText"]?.try &.as_s || ""
+ plid = item_contents["playlistId"]?.try &.as_s || ""
+
+ video_count = HelperExtractors.get_video_count(item_contents)
+ playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents)
+
+ author_info = item_contents.dig?("shortBylineText", "runs", 0)
+ author = author_info.try &.["text"].as_s || author_fallback.name
+ author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id
+
+ videos = item_contents["videos"]?.try &.as_a.map do |v|
+ v = v["childVideoRenderer"]
+ v_title = v.dig?("title", "simpleText").try &.as_s || ""
+ v_id = v["videoId"]?.try &.as_s || ""
+ v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0
+ SearchPlaylistVideo.new({
+ title: v_title,
+ id: v_id,
+ length_seconds: v_length_seconds,
+ })
+ end || [] of SearchPlaylistVideo
+
+ # TODO: item_contents["publishedTimeText"]?
+
+ SearchPlaylist.new({
+ title: title,
+ id: plid,
+ author: author,
+ ucid: author_id,
+ video_count: video_count,
+ videos: videos,
+ thumbnail: playlist_thumbnail,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer
+ #
+ # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and
+ # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used
+ # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it.
+ #
+ # See specs for example.
+ #
+ # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc.
+ #
+ module CategoryRendererParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["shelfRenderer"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ title = extract_text(item_contents["title"]?) || ""
+ url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url")
+ .try &.as_s
+
+ # Sometimes a category can have badges.
+ badges = [] of Tuple(String, String) # (Badge style, label)
+ item_contents["badges"]?.try &.as_a.each do |badge|
+ badge = badge["metadataBadgeRenderer"]
+ badges << {badge["style"].as_s, badge["label"].as_s}
+ end
+
+ # Category description
+ description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || ""
+
+ # Content parsing
+ contents = [] of SearchItem
+
+ # InnerTube recognizes some "special" categories, which are organized differently.
+ if special_category_container = item_contents["content"]?
+ if content_container = special_category_container["horizontalListRenderer"]?
+ elsif content_container = special_category_container["expandedShelfContentsRenderer"]?
+ elsif content_container = special_category_container["verticalListRenderer"]?
+ else
+ # Anything else, such as `horizontalMovieListRenderer` is currently unsupported.
+ return
+ end
+ else
+ # "Normal" category.
+ content_container = item_contents["contents"]
+ end
+
+ raw_contents = content_container["items"]?.try &.as_a
+ if !raw_contents.nil?
+ raw_contents.each do |item|
+ result = extract_item(item)
+ if !result.nil?
+ contents << result
+ end
+ end
+ end
+
+ Category.new({
+ title: title,
+ contents: contents,
+ description_html: description_html,
+ url: url,
+ badges: badges,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+end
+
+# The following are the extractors for extracting an array of items from
+# the internal Youtube API's JSON response. The result is then packaged into
+# a structure we can more easily use via the parsers above. Their internals are
+# identical to the item parsers.
+
+# Namespace for logic relating to extracting InnerTube's initial response to items we can parse.
+#
+# Each of the extractors in this namespace are accessed through the #process() method
+# which validates the given data as applicable to itself. If it is applicable the given
+# data is passed to the private `#extract()` method which returns an array of
+# parsable items. Otherwise, nil is returned.
+#
+# NOTE perhaps the result from here should be abstracted into a struct in order to
+# get additional metadata regarding the container of the item(s).
+private module Extractors
+ # Extracts items from the selected YouTube tab.
+ #
+ # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer"
+ # and is structured like this:
+ #
+ # "twoColumnBrowseResultsRenderer": {
+ # {"tabs": [
+ # {"tabRenderer": {
+ # "endpoint": {...}
+ # "title": "Playlists",
+ # "selected": true,
+ # "content": {...},
+ # ...
+ # }}
+ # ]}
+ # }]
+ #
+ module YouTubeTabs
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["twoColumnBrowseResultsRenderer"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of JSON::Any
+ content = extract_selected_tab(target["tabs"])["content"]
+
+ content["sectionListRenderer"]["contents"].as_a.each do |renderer_container|
+ renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
+
+ # Category extraction
+ if items_container = renderer_container_contents["shelfRenderer"]?
+ raw_items << renderer_container_contents
+ next
+ elsif items_container = renderer_container_contents["gridRenderer"]?
+ else
+ items_container = renderer_container_contents
+ end
+
+ items_container["items"]?.try &.as_a.each do |item|
+ raw_items << item
+ end
+ end
+
+ return raw_items
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+
+ # Extracts items from the InnerTube response for search results
+ #
+ # Search results are typically stored under "twoColumnSearchResultsRenderer"
+ # and is structured like this:
+ #
+ # "twoColumnSearchResultsRenderer": {
+ # {"primaryContents": {
+ # {"sectionListRenderer": {
+ # "contents": [...],
+ # ...,
+ # "subMenu": {...},
+ # "hideBottomSeparator": true,
+ # "targetId": "search-feed"
+ # }}
+ # }}
+ # }
+ #
+ module SearchResults
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["twoColumnSearchResultsRenderer"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of Array(JSON::Any)
+
+ target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node|
+ if node = node["itemSectionRenderer"]?
+ raw_items << node["contents"].as_a
+ end
+ end
+
+ return raw_items.flatten
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+
+ # Extracts continuation items from a InnerTube response
+ #
+ # Continuation items (on YouTube) are items which are appended to the
+ # end of the page for continuous scrolling. As such, in many cases,
+ # the items are lacking information such as author or category title,
+ # since the original results has already rendered them on the top of the page.
+ #
+ # The way they are structured is too varied to be accurately written down here.
+ # However, they all eventually lead to an array of parsable items after traversing
+ # through the JSON structure.
+ module Continuation
+ def self.process(initial_data : Hash(String, JSON::Any))
+ if target = initial_data["continuationContents"]?
+ self.extract(target)
+ elsif target = initial_data["appendContinuationItemsAction"]?
+ self.extract(target)
+ end
+ end
+
+ private def self.extract(target)
+ raw_items = [] of JSON::Any
+ if content = target["gridContinuation"]?
+ raw_items = content["items"].as_a
+ elsif content = target["continuationItems"]?
+ raw_items = content.as_a
+ end
+
+ return raw_items
+ end
+
+ def self.extractor_name
+ return {{@type.name}}
+ end
+ end
+end
+
+# Helper methods to aid in the parsing of InnerTube to data structs.
+#
+# Mostly used to extract out repeated structures to deal with code
+# repetition.
+private module HelperExtractors
+ # Retrieves the amount of videos present within the given InnerTube data.
+ #
+ # Returns a 0 when it's unable to do so
+ def self.get_video_count(container : JSON::Any) : Int32
+ if box = container["videoCountText"]?
+ return extract_text(box).try &.gsub(/\D/, "").to_i || 0
+ elsif box = container["videoCount"]?
+ return box.as_s.to_i
+ else
+ return 0
+ end
+ end
+
+ # Retrieve lowest quality thumbnail from InnerTube data
+ #
+ # TODO allow configuration of image quality (-1 is highest)
+ #
+ # Raises when it's unable to parse from the given JSON data.
+ def self.get_thumbnails(container : JSON::Any) : String
+ return container.dig("thumbnail", "thumbnails", 0, "url").as_s
+ end
+
+ # ditto
+ #
+ # YouTube sometimes sends the thumbnail as:
+ # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]}
+ def self.get_thumbnails_plural(container : JSON::Any) : String
+ return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s
+ end
+
+ # Retrieves the ID required for querying the InnerTube browse endpoint.
+ # Raises when it's unable to do so
+ def self.get_browse_id(container)
+ return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s
+ end
+end
+
+# Parses an item from Youtube's JSON response into a more usable structure.
+# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel.
+def extract_item(item : JSON::Any, author_fallback : String? = "",
+ author_id_fallback : String? = "")
+ # We "allow" nil values but secretly use empty strings instead. This is to save us the
+ # hassle of modifying every author_fallback and author_id_fallback arg usage
+ # which is more often than not nil.
+ author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "")
+
+ # Cycles through all of the item parsers and attempt to parse the raw YT JSON data.
+ # Each parser automatically validates the data given to see if the data is
+ # applicable to itself. If not nil is returned and the next parser is attemped.
+ ITEM_PARSERS.each do |parser|
+ LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)")
+
+ if result = parser.process(item, author_fallback)
+ LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}")
+
+ return result
+ else
+ LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...")
+ end
+ end
+end
+
+# Parses multiple items from YouTube's initial JSON response into a more usable structure.
+# The end result is an array of SearchItem.
+def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil,
+ author_id_fallback : String? = nil) : Array(SearchItem)
+ items = [] of SearchItem
+
+ if unpackaged_data = initial_data["contents"]?.try &.as_h
+ elsif unpackaged_data = initial_data["response"]?.try &.as_h
+ elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h
+ else
+ unpackaged_data = initial_data
+ end
+
+ # This is identical to the parser cycling of extract_item().
+ ITEM_CONTAINER_EXTRACTOR.each do |extractor|
+ LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)")
+
+ if container = extractor.process(unpackaged_data)
+ LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"")
+ # Extract items in container
+ container.each do |item|
+ if parsed_result = extract_item(item, author_fallback, author_id_fallback)
+ items << parsed_result
+ end
+ end
+
+ break
+ else
+ LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...")
+ end
+ end
+
+ return items
+end
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
new file mode 100644
index 00000000..add5f488
--- /dev/null
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -0,0 +1,67 @@
+# Extracts text from InnerTube response
+#
+# InnerTube can package text in three different formats
+# "runs": [
+# {"text": "something"},
+# {"text": "cont"},
+# ...
+# ]
+#
+# "SimpleText": "something"
+#
+# Or sometimes just none at all as with the data returned from
+# category continuations.
+#
+# In order to facilitate calling this function with `#[]?`:
+# A nil will be accepted. Of course, since nil cannot be parsed,
+# another nil will be returned.
+def extract_text(item : JSON::Any?) : String?
+ if item.nil?
+ return nil
+ end
+
+ if text_container = item["simpleText"]?
+ return text_container.as_s
+ elsif text_container = item["runs"]?
+ return text_container.as_a.map(&.["text"].as_s).join("")
+ else
+ nil
+ end
+end
+
+def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
+ extracted = extract_items(initial_data, author_fallback, author_id_fallback)
+
+ target = [] of SearchItem
+ extracted.each do |i|
+ if i.is_a?(Category)
+ i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
+ else
+ target << i
+ end
+ end
+ return target.select(SearchVideo).map(&.as(SearchVideo))
+end
+
+def extract_selected_tab(tabs)
+ # Extract the selected tab from the array of tabs Youtube returns
+ return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
+end
+
+def fetch_continuation_token(items : Array(JSON::Any))
+ # Fetches the continuation token from an array of items
+ return items.last["continuationItemRenderer"]?
+ .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
+end
+
+def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
+ # Fetches the continuation token from initial data
+ if initial_data["onResponseReceivedActions"]?
+ continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
+ else
+ tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
+ continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
+ end
+
+ return fetch_continuation_token(continuation_items.as_a)
+end
diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/yt_backend/proxy.cr
index 3418d887..2d0fd4ba 100644
--- a/src/invidious/helpers/proxy.cr
+++ b/src/invidious/yt_backend/proxy.cr
@@ -236,7 +236,7 @@ def get_spys_proxies(country_code = "US")
proxies << {ip: ip, port: port, score: score}
end
- proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse
+ proxies = proxies.sort_by!(&.[:score]).reverse!
return proxies
end
@@ -256,7 +256,7 @@ def decrypt_port(p, x)
p = p.gsub(/\b\w+\b/, x)
p = p.split(";")
- p = p.map { |item| item.split("=") }
+ p = p.map(&.split("="))
mapping = {} of String => Int32
p.each do |item|
diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index b3815f6a..85239e72 100644
--- a/src/invidious/helpers/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -405,28 +405,32 @@ module YoutubeAPI
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
- "Accept-Encoding" => "gzip",
+ "Accept-Encoding" => "gzip, deflate",
}
# Logging
LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"")
- LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config.to_s}")
- LOGGER.trace("YoutubeAPI: POST data: #{data.to_s}")
+ LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}")
+ LOGGER.trace("YoutubeAPI: POST data: #{data}")
# Send the POST request
- if client_config.proxy_region
- response = YT_POOL.client(
- client_config.proxy_region,
+ if {{ !flag?(:disable_quic) }} && CONFIG.use_quic
+ # Using QUIC client
+ response = YT_POOL.client(client_config.proxy_region,
&.post(url, headers: headers, body: data.to_json)
)
+ body = response.body
else
- response = YT_POOL.client &.post(
- url, headers: headers, body: data.to_json
- )
+ # Using HTTP client
+ body = YT_POOL.client(client_config.proxy_region) do |client|
+ client.post(url, headers: headers, body: data.to_json) do |response|
+ self._decompress(response.body_io, response.headers["Content-Encoding"]?)
+ end
+ end
end
# Convert result to Hash
- initial_data = JSON.parse(response.body).as_h
+ initial_data = JSON.parse(body).as_h
# Error handling
if initial_data.has_key?("error")
@@ -436,7 +440,7 @@ module YoutubeAPI
# Logging
LOGGER.error("YoutubeAPI: Got error #{code} when requesting #{endpoint}")
LOGGER.error("YoutubeAPI: #{message}")
- LOGGER.info("YoutubeAPI: POST data was: #{data.to_s}")
+ LOGGER.info("YoutubeAPI: POST data was: #{data}")
raise InfoException.new("Could not extract JSON. Youtube API returned \
error #{code} with message:<br>\"#{message}\"")
@@ -444,4 +448,35 @@ module YoutubeAPI
return initial_data
end
+
+ ####################################################################
+ # _decompress(body_io, headers)
+ #
+ # Internal function that reads the Content-Encoding headers and
+ # decompresses the content accordingly.
+ #
+ # We decompress the body ourselves (when using HTTP::Client) because
+ # the auto-decompress feature is broken in the Crystal stdlib.
+ #
+ # Read more:
+ # - https://github.com/iv-org/invidious/issues/2612
+ # - https://github.com/crystal-lang/crystal/issues/11354
+ #
+ def _decompress(body_io : IO, encodings : String?) : String
+ if encodings
+ # Multiple encodings can be combined, and are listed in the order
+ # in which they were applied. E.g: "deflate, gzip" means that the
+ # content must be first "gunzipped", then "defated".
+ encodings.split(',').reverse.each do |enc|
+ case enc.strip(' ')
+ when "gzip"
+ body_io = Compress::Gzip::Reader.new(body_io, sync_close: true)
+ when "deflate"
+ body_io = Compress::Deflate::Reader.new(body_io, sync_close: true)
+ end
+ end
+ end
+
+ return body_io.gets_to_end
+ end
end # End of module