summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.ameba.yml78
-rw-r--r--.github/CODEOWNERS2
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md6
-rw-r--r--.github/workflows/build-nightly-container.yml87
-rw-r--r--.github/workflows/build-stable-container.yml81
-rw-r--r--.github/workflows/ci.yml81
-rw-r--r--.github/workflows/container-release.yml79
-rw-r--r--.github/workflows/stale.yml15
-rw-r--r--CHANGELOG.md1191
-rw-r--r--CHANGELOG_legacy.md844
-rw-r--r--Makefile9
-rw-r--r--README.md15
-rw-r--r--assets/css/carousel.css119
-rw-r--r--assets/css/default.css73
-rw-r--r--assets/css/player.css1
-rw-r--r--assets/js/comments.js174
-rw-r--r--assets/js/handlers.js4
-rw-r--r--assets/js/notifications.js4
-rw-r--r--assets/js/player.js69
-rw-r--r--assets/js/playlist_widget.js6
-rw-r--r--assets/js/post.js3
-rw-r--r--assets/js/subscribe_widget.js4
-rw-r--r--assets/js/watch.js161
-rw-r--r--assets/js/watched_widget.js4
-rw-r--r--assets/site.webmanifest4
-rw-r--r--config/config.example.yml101
-rw-r--r--docker-compose.yml6
-rw-r--r--docker/Dockerfile11
-rw-r--r--docker/Dockerfile.arm649
-rw-r--r--kubernetes/.gitignore1
-rw-r--r--kubernetes/Chart.lock6
-rw-r--r--kubernetes/Chart.yaml22
-rw-r--r--kubernetes/README.md42
-rw-r--r--kubernetes/templates/_helpers.tpl16
-rw-r--r--kubernetes/templates/configmap.yaml11
-rw-r--r--kubernetes/templates/deployment.yaml61
-rw-r--r--kubernetes/templates/hpa.yaml18
-rw-r--r--kubernetes/templates/service.yaml20
-rw-r--r--kubernetes/values.yaml61
-rw-r--r--locales/ar.json34
-rw-r--r--locales/be.json (renamed from locales/la.json)0
-rw-r--r--locales/bg.json497
-rw-r--r--locales/bn.json4
-rw-r--r--locales/ca.json15
-rw-r--r--locales/cs.json19
-rw-r--r--locales/cy.json385
-rw-r--r--locales/da.json41
-rw-r--r--locales/de.json29
-rw-r--r--locales/el.json60
-rw-r--r--locales/en-US.json22
-rw-r--r--locales/eo.json4
-rw-r--r--locales/es.json123
-rw-r--r--locales/eu.json22
-rw-r--r--locales/fa.json97
-rw-r--r--locales/fi.json122
-rw-r--r--locales/fr.json103
-rw-r--r--locales/hi.json18
-rw-r--r--locales/hr.json43
-rw-r--r--locales/hu-HU.json20
-rw-r--r--locales/ia.json45
-rw-r--r--locales/id.json26
-rw-r--r--locales/is.json293
-rw-r--r--locales/it.json99
-rw-r--r--locales/ja.json35
-rw-r--r--locales/ko.json35
-rw-r--r--locales/lmo.json232
-rw-r--r--locales/nb-NO.json22
-rw-r--r--locales/nl.json80
-rw-r--r--locales/pl.json35
-rw-r--r--locales/pt-BR.json339
-rw-r--r--locales/pt-PT.json10
-rw-r--r--locales/pt.json247
-rw-r--r--locales/ro.json3
-rw-r--r--locales/ru.json51
-rw-r--r--locales/sl.json7
-rw-r--r--locales/sq.json42
-rw-r--r--locales/sr.json475
-rw-r--r--locales/sr_Cyrl.json483
-rw-r--r--locales/sv-SE.json200
-rw-r--r--locales/tk.json7
-rw-r--r--locales/tr.json22
-rw-r--r--locales/uk.json23
-rw-r--r--locales/vi.json330
-rw-r--r--locales/zh-CN.json17
-rw-r--r--locales/zh-TW.json21
m---------mocks0
-rw-r--r--shard.lock16
-rw-r--r--shard.yml26
-rw-r--r--spec/helpers/vtt/builder_spec.cr87
-rw-r--r--spec/i18next_plurals_spec.cr61
-rw-r--r--spec/invidious/hashtag_spec.cr16
-rw-r--r--spec/invidious/helpers_spec.cr12
-rw-r--r--spec/invidious/search/iv_filters_spec.cr1
-rw-r--r--spec/invidious/search/yt_filters_spec.cr54
-rw-r--r--spec/invidious/videos/regular_videos_extract_spec.cr46
-rw-r--r--spec/invidious/videos/scheduled_live_extract_spec.cr2
-rw-r--r--src/invidious.cr26
-rw-r--r--src/invidious/channels/about.cr175
-rw-r--r--src/invidious/channels/channels.cr8
-rw-r--r--src/invidious/channels/community.cr37
-rw-r--r--src/invidious/channels/videos.cr202
-rw-r--r--src/invidious/comments/content.cr52
-rw-r--r--src/invidious/comments/youtube.cr223
-rw-r--r--src/invidious/config.cr43
-rw-r--r--src/invidious/database/playlists.cr1
-rw-r--r--src/invidious/database/statistics.cr4
-rw-r--r--src/invidious/frontend/comments_reddit.cr2
-rw-r--r--src/invidious/frontend/comments_youtube.cr52
-rw-r--r--src/invidious/frontend/misc.cr4
-rw-r--r--src/invidious/frontend/watch_page.cr5
-rw-r--r--src/invidious/helpers/crystal_class_overrides.cr8
-rw-r--r--src/invidious/helpers/errors.cr14
-rw-r--r--src/invidious/helpers/handlers.cr62
-rw-r--r--src/invidious/helpers/helpers.cr44
-rw-r--r--src/invidious/helpers/i18n.cr34
-rw-r--r--src/invidious/helpers/i18next.cr79
-rw-r--r--src/invidious/helpers/json_filter.cr248
-rw-r--r--src/invidious/helpers/logger.cr29
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr54
-rw-r--r--src/invidious/helpers/sig_helper.cr349
-rw-r--r--src/invidious/helpers/signatures.cr100
-rw-r--r--src/invidious/helpers/utils.cr70
-rw-r--r--src/invidious/helpers/webvtt.cr81
-rw-r--r--src/invidious/http_server/utils.cr5
-rw-r--r--src/invidious/jobs/bypass_captcha_job.cr135
-rw-r--r--src/invidious/jobs/instance_refresh_job.cr97
-rw-r--r--src/invidious/jobs/statistics_refresh_job.cr16
-rw-r--r--src/invidious/jobs/update_decrypt_function_job.cr14
-rw-r--r--src/invidious/jsonify/api_v1/video_json.cr112
-rw-r--r--src/invidious/mixes.cr4
-rw-r--r--src/invidious/playlists.cr15
-rw-r--r--src/invidious/routes/account.cr18
-rw-r--r--src/invidious/routes/api/manifest.cr42
-rw-r--r--src/invidious/routes/api/v1/channels.cr145
-rw-r--r--src/invidious/routes/api/v1/feeds.cr2
-rw-r--r--src/invidious/routes/api/v1/misc.cr51
-rw-r--r--src/invidious/routes/api/v1/search.cr7
-rw-r--r--src/invidious/routes/api/v1/videos.cr165
-rw-r--r--src/invidious/routes/before_all.cr2
-rw-r--r--src/invidious/routes/channels.cr147
-rw-r--r--src/invidious/routes/embed.cr6
-rw-r--r--src/invidious/routes/feeds.cr30
-rw-r--r--src/invidious/routes/images.cr106
-rw-r--r--src/invidious/routes/misc.cr11
-rw-r--r--src/invidious/routes/playlists.cr31
-rw-r--r--src/invidious/routes/preferences.cr16
-rw-r--r--src/invidious/routes/search.cr6
-rw-r--r--src/invidious/routes/subscriptions.cr14
-rw-r--r--src/invidious/routes/video_playback.cr24
-rw-r--r--src/invidious/routes/watch.cr34
-rw-r--r--src/invidious/routing.cr58
-rw-r--r--src/invidious/search/filters.cr8
-rw-r--r--src/invidious/search/query.cr40
-rw-r--r--src/invidious/trending.cr4
-rw-r--r--src/invidious/user/imports.cr95
-rw-r--r--src/invidious/user/preferences.cr1
-rw-r--r--src/invidious/videos.cr175
-rw-r--r--src/invidious/videos/caption.cr42
-rw-r--r--src/invidious/videos/clip.cr22
-rw-r--r--src/invidious/videos/description.cr22
-rw-r--r--src/invidious/videos/parser.cr127
-rw-r--r--src/invidious/videos/storyboard.cr122
-rw-r--r--src/invidious/videos/transcript.cr129
-rw-r--r--src/invidious/videos/video_preferences.cr6
-rw-r--r--src/invidious/views/channel.ecr4
-rw-r--r--src/invidious/views/community.ecr2
-rw-r--r--src/invidious/views/components/item.ecr45
-rw-r--r--src/invidious/views/components/player.ecr1
-rw-r--r--src/invidious/views/components/search_box.ecr3
-rw-r--r--src/invidious/views/components/subscribe_widget.ecr4
-rw-r--r--src/invidious/views/components/video-context-buttons.ecr2
-rw-r--r--src/invidious/views/feeds/history.ecr2
-rw-r--r--src/invidious/views/playlist.ecr2
-rw-r--r--src/invidious/views/post.ecr48
-rw-r--r--src/invidious/views/template.ecr26
-rw-r--r--src/invidious/views/user/data_control.ecr5
-rw-r--r--src/invidious/views/user/preferences.ecr7
-rw-r--r--src/invidious/views/user/subscription_manager.ecr2
-rw-r--r--src/invidious/views/user/token_manager.ecr2
-rw-r--r--src/invidious/views/watch.ecr48
-rw-r--r--src/invidious/yt_backend/connection_pool.cr137
-rw-r--r--src/invidious/yt_backend/extractors.cr198
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr2
-rw-r--r--src/invidious/yt_backend/proxy.cr316
-rw-r--r--src/invidious/yt_backend/url_sanitizer.cr121
-rw-r--r--src/invidious/yt_backend/youtube_api.cr141
186 files changed, 9004 insertions, 4883 deletions
diff --git a/.ameba.yml b/.ameba.yml
index 96cbc8f0..36d7c48f 100644
--- a/.ameba.yml
+++ b/.ameba.yml
@@ -20,6 +20,13 @@ Lint/ShadowingOuterLocalVar:
Excluded:
- src/invidious/helpers/tokens.cr
+Lint/NotNil:
+ Enabled: false
+
+Lint/SpecFilename:
+ Excluded:
+ - spec/parsers_helper.cr
+
#
# Style
@@ -31,58 +38,35 @@ Style/RedundantBegin:
Style/RedundantReturn:
Enabled: false
+Style/RedundantNext:
+ 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
+Style/ParenthesesAroundCondition:
+ Enabled: false
- # playlist_ajax(env) => [24/10]
- - src/invidious/routes/playlists.cr
+# This requires a rewrite of most data structs (and their usage) in Invidious.
+Naming/QueryBoolMethods:
+ Enabled: false
- # 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
+Naming/AccessorMethodName:
+ Enabled: false
- # to_json(locale, json) => [21/10]
- # extract_video_info(video_id, ...) => [44/10]
- # process_video_params(query, preferences) => [20/10]
- - src/invidious/videos.cr
+Naming/BlockParameterName:
+ Enabled: false
+# Hides TODO comment warnings.
+#
+# Call `bin/ameba --only Documentation/DocumentationAdmonition` to
+# list them
+Documentation/DocumentationAdmonition:
+ Enabled: false
-#src/invidious/playlists.cr:327:5
-#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [19/10]
-# fetch_playlist(plid : String)
+#
+# Metrics
+#
-#src/invidious/playlists.cr:436:5
-#[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [11/10]
-# extract_playlist_videos(initial_data : Hash(String, JSON::Any))
+# Ignore function complexity (number of if/else & case/when branches)
+# For some functions that can hardly be simplified for now
+Metrics/CyclomaticComplexity:
+ Enabled: false
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 7a2c3760..9ca09368 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -6,7 +6,7 @@ docker/ @unixfox
kubernetes/ @unixfox
README.md @thefrenchghosty
-config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
+config/config.example.yml @SamantazFox @unixfox
scripts/ @syeopite
shards.lock @syeopite
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 4c1a6330..02bc3795 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -10,8 +10,10 @@ assignees: ''
<!--
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!
+ * Read the FAQ: https://docs.invidious.io/faq/!
+ * Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues!
+
+ MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
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
diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml
new file mode 100644
index 00000000..5ff3322f
--- /dev/null
+++ b/.github/workflows/build-nightly-container.yml
@@ -0,0 +1,87 @@
+name: Build and release container directly from master
+
+on:
+ push:
+ branches:
+ - "master"
+ paths-ignore:
+ - "*.md"
+ - LICENCE
+ - TRANSLATION
+ - invidious.service
+ - .git*
+ - .editorconfig
+ - screenshots/*
+ - .github/ISSUE_TEMPLATE/*
+ - kubernetes/**
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to registry
+ uses: docker/login-action@v3
+ with:
+ registry: quay.io
+ username: ${{ secrets.QUAY_USERNAME }}
+ password: ${{ secrets.QUAY_PASSWORD }}
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: quay.io/invidious/invidious
+ tags: |
+ type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ labels: |
+ quay.expires-after=12w
+
+ - name: Build and push Docker AMD64 image for Push Event
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: docker/Dockerfile
+ platforms: linux/amd64
+ labels: ${{ steps.meta.outputs.labels }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ build-args: |
+ "release=1"
+
+ - name: Docker meta
+ id: meta-arm64
+ uses: docker/metadata-action@v5
+ with:
+ images: quay.io/invidious/invidious
+ flavor: |
+ suffix=-arm64
+ tags: |
+ type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ labels: |
+ quay.expires-after=12w
+
+ - name: Build and push Docker ARM64 image for Push Event
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: docker/Dockerfile.arm64
+ platforms: linux/arm64/v8
+ labels: ${{ steps.meta-arm64.outputs.labels }}
+ push: true
+ tags: ${{ steps.meta-arm64.outputs.tags }}
+ build-args: |
+ "release=1"
diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml
new file mode 100644
index 00000000..25571ed6
--- /dev/null
+++ b/.github/workflows/build-stable-container.yml
@@ -0,0 +1,81 @@
+name: Build and release container
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to registry
+ uses: docker/login-action@v3
+ with:
+ registry: quay.io
+ username: ${{ secrets.QUAY_USERNAME }}
+ password: ${{ secrets.QUAY_PASSWORD }}
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: quay.io/invidious/invidious
+ flavor: |
+ latest=false
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+ labels: |
+ quay.expires-after=12w
+
+ - name: Build and push Docker AMD64 image for Push Event
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: docker/Dockerfile
+ platforms: linux/amd64
+ labels: ${{ steps.meta.outputs.labels }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ build-args: |
+ "release=1"
+
+ - name: Docker meta
+ id: meta-arm64
+ uses: docker/metadata-action@v5
+ with:
+ images: quay.io/invidious/invidious
+ flavor: |
+ latest=false
+ suffix=-arm64
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+ labels: |
+ quay.expires-after=12w
+
+ - name: Build and push Docker ARM64 image for Push Event
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: docker/Dockerfile.arm64
+ platforms: linux/arm64/v8
+ labels: ${{ steps.meta-arm64.outputs.labels }}
+ push: true
+ tags: ${{ steps.meta-arm64.outputs.tags }}
+ build-args: |
+ "release=1"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1ca0dc96..5f859613 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,19 +38,24 @@ jobs:
matrix:
stable: [true]
crystal:
- - 1.6.2
- - 1.7.3
- - 1.8.2
- - 1.9.2
+ - 1.12.1
+ - 1.13.2
+ - 1.14.0
+ - 1.15.0
include:
- crystal: nightly
stable: false
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
submodules: true
+ - name: Install required APT packages
+ run: |
+ sudo apt install -y libsqlite3-dev
+ shell: bash
+
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.0
with:
@@ -59,7 +64,9 @@ jobs:
- name: Cache Shards
uses: actions/cache@v3
with:
- path: ./lib
+ path: |
+ ./lib
+ ./bin
key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards
@@ -71,14 +78,6 @@ jobs:
- name: Run tests
run: crystal spec
- - name: Run lint
- run: |
- if ! crystal tool format --check; then
- crystal tool format
- git diff
- exit 1
- fi
-
- name: Build
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
@@ -87,13 +86,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Build Docker
- run: docker-compose build --build-arg release=0
+ run: docker compose build --build-arg release=0
- name: Run Docker
- run: docker-compose up -d
+ run: docker compose up -d
- name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done
@@ -103,18 +102,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Build Docker ARM64 image
- uses: docker/build-push-action@v3
+ uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.arm64
@@ -124,4 +123,44 @@ jobs:
- name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done
+ lint:
+
+ runs-on: ubuntu-latest
+
+ continue-on-error: true
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Install Crystal
+ id: lint_step_install_crystal
+ uses: crystal-lang/install-crystal@v1.8.0
+ with:
+ crystal: latest
+
+ - name: Cache Shards
+ uses: actions/cache@v3
+ with:
+ path: |
+ ./lib
+ ./bin
+ key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }}
+
+ - name: Install Shards
+ run: |
+ if ! shards check; then
+ shards install
+ fi
+
+ - name: Check Crystal formatter compliance
+ run: |
+ if ! crystal tool format --check; then
+ crystal tool format
+ git diff
+ exit 1
+ fi
+ - name: Run Ameba linter
+ run: bin/ameba
diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml
deleted file mode 100644
index c2756fcc..00000000
--- a/.github/workflows/container-release.yml
+++ /dev/null
@@ -1,79 +0,0 @@
-name: Build and release container
-
-on:
- push:
- branches:
- - "master"
- paths-ignore:
- - "*.md"
- - LICENCE
- - TRANSLATION
- - invidious.service
- - .git*
- - .editorconfig
-
- - screenshots/*
- - .github/ISSUE_TEMPLATE/*
- - kubernetes/**
-
-jobs:
- release:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Install Crystal
- uses: crystal-lang/install-crystal@v1.8.0
- with:
- crystal: 1.9.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@v2
- with:
- platforms: arm64
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
-
- - name: Login to registry
- uses: docker/login-action@v2
- with:
- registry: quay.io
- username: ${{ secrets.QUAY_USERNAME }}
- password: ${{ secrets.QUAY_PASSWORD }}
-
- - name: Build and push Docker AMD64 image for Push Event
- if: github.ref == 'refs/heads/master'
- uses: docker/build-push-action@v3
- with:
- context: .
- file: docker/Dockerfile
- platforms: linux/amd64
- labels: quay.expires-after=12w
- push: true
- tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest
- build-args: |
- "release=1"
-
- - name: Build and push Docker ARM64 image for Push Event
- if: github.ref == 'refs/heads/master'
- uses: docker/build-push-action@v3
- with:
- context: .
- file: docker/Dockerfile.arm64
- platforms: linux/arm64/v8
- 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"
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index a7e218a2..498a2c1b 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -10,17 +10,14 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v5
+ - uses: actions/stale@v8
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- days-before-stale: 365
- days-before-pr-stale: 90
- days-before-close: 30
- exempt-pr-labels: blocked
+ days-before-stale: 730
+ days-before-pr-stale: -1
+ days-before-close: 60
stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.'
- stale-pr-message: 'This pull request has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely abandoned or outdated. If you think this pull request is still relevant and applicable, you just have to post a comment and it will be unmarked.'
stale-issue-label: "stale"
- stale-pr-label: "stale"
ascending: true
- # Never mark feature requests/enhancements as stale
- exempt-issue-labels: "feature-request,enhancement,exempt-stale"
+ # Exempt the following types of issues from being staled
+ exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8aa416ec..5af38003 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,844 +1,347 @@
-# Note: This is no longer updated and links to omarroths repo, which doesn't exist anymore.
-
-# 0.20.0 (2019-011-06)
-
-# Version 0.20.0: Custom Playlists
-
-It's been quite a while since the last release! There've been [198 commits](https://github.com/omarroth/invidious/compare/0.19.0..0.20.0) from 27 contributors.
-
-A couple smaller features have since been added. Channel pages and playlists in particular have received a bit of a face-lift, with both now displaying their descriptions as expected, and playlists providing video count and published information. Channels will also now provide video descriptions in their RSS feed.
-
-Turkish (tr), Chinese (zh-TW, in addition to zh-CN), and Japanese (jp) are all now supported languages. Thank you as always to the hard work done by translators that makes this possible.
-
-The feed menu and default home page are both now configurable for registered and unregistered users, and is quite a bit of an improvement for users looking to reduce distractions for their daily use.
-
-## For Administrators
-
-`feed_menu` and `default_home` are now configurable by the user, and have therefore been moved into `default_user_preferences`:
-
-```yaml
-feed_menu: ["Popular", "Top"]
-default_home: Top
-
-# becomes:
-
-default_user_preferences:
- feed_menu: ["Popular", "Top"]
- default_home: Top
-```
-
-Several new options have also been added, including the ability to set a support email for the instance using `admin_email: EMAIL`, and forcing the use of a specific connection in the case of rate-limiting using `force_resolve` (see below).
-
-## For Developers
-
-Authenticated endpoints are now [properly documented](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints), as well how to generate and use API tokens. My hope is that this makes some of the more [interesting](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authnotifications) endpoints more accessible for developers to use in their own applications.
-
-API endpoints for interacting with custom playlists have also been added with documentation available [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists).
-
-## Custom playlists
-
-This is probably the feature that has been the longest in the pipe and that I'm quite pleased is now implemented. It is now possible to create custom playlists, which can be played and edited through Invidious. API endpoints have also been added (documentation [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists)).
-
-Overall I'm quite pleased with how smoothly it has been rolled out and with the experience so far, and I'm exctited for how it can be extended and improved in future.
-
-## [instances.invidio.us](https://instances.invidio.us)
-
-It is now possible to view a list of public instances (as provided in the [wiki](https://github.com/omarroth/invidious/wiki/Invidious-Instances)) through an API or a pretty new interface [here](https://instances.invidio.us). It combines uptime information, statistics from each instance and basic information already provided in the wiki. I expect it should be much more user-friendly than compiling the information yourself, and is already used by [Invidition](https://codeberg.org/Booteille/Invidition) to provide a list of instances for users to choose from.
-
-The site itself is licensed under the AGPLv3 and the source is available [here](https://github.com/omarroth/instances.invidio.us).
-
-## Video unavailable [#811](https://github.com/omarroth/invidious/issues/811)
-
-Many users have likely noticed this error message if using Invidious directly or through another service, such as FreeTube. This issue is caused by rate-limiting by Google, and is not a new issuee for projects like Invidious (notably [youtube-dl](https://github.com/ytdl-org/youtube-dl#http-error-429-too-many-requests-or-402-payment-required)) and appears to be affecting smaller, private instances as well.
-
-There is not a permanent fix for administrators currently, however there is some information available [here](https://github.com/omarroth/invidious/issues/811#issuecomment-540017772) that may provide a temporary solution. Unfortanately, in most cases the best option is to wait for the instance to be unbanned or to move the instance to a different IP. A more informative error message is also now provided, which should help an administrator more quickly diagnose the problem.
-
-For those interested, I would recommend following [#811](https://github.com/omarroth/invidious/issues/811) for any future progress on the issue.
-
-## BAT verified publisher
-
-I'm quite late to this announcement, however I'm pleased to mention that Invidious is now a BAT verified publisher! I would recommend looking [here](https://basicattentiontoken.org/about/) or [here](https://www.reddit.com/r/BATProject/comments/7cr7yc/new_to_bat_read_this_introduction_to_basic/) for learning more about what it is and how it works. Overall I think it makes an interesting substitute for services like Liberapay, and a (hopefully) much less-intrusive alternative to direct advertising.
-
-BAT is combined under other cryptocurrencies below. Currently there's a fairly significant delay in payout, which is the reason for the large fluctuation in crypto donations between September and October (and also the reason for the late announcement).
-
-## Release schedule
-
-Currently I'm quite pleased with the current state of the project. There's plenty of things I'd still like to add, however at this point I expect the rate of most new additions will slow down a bit, with more focus on stabililty and any long-standing bugs.
-
-Because of this, I'm planning on releasing a new version quarterly, with any necessary hotfixes being pushed as a new patch release as necessary. As always it will be possible to run Invidious directly from [master](https://github.com/omarroth/invidious/wiki/Updating) if you'd still like to have the lastest version.
-
-I'll plan on providing finances each release, with a similar monthly breakdown as below.
-
-## Finances for September 2019
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$64.37
-- [Liberapay](https://liberapay.com/omarroth) : \$76.04
-- Crypto : ~\$99.89 (converted from BAT, BCH, BTC)
-- Total : \$240.30
-
-### Expenses
-
-- invidious-lb1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$135.00
-
-## Finances for October 2019
-
-- [Liberapay](https://liberapay.com/omarroth) : \$134.40
-- Crypto : ~\$8.29 (converted from BAT, BCH, BTC)
-- Total : \$142.69
-
-### Expenses
-
-- invidious-lb1 (nyc1) : \$5.00 (load balancer)
-- invidious-lb2 (nyc1) : \$5.00 (load balancer)
-- invidious-lb3 (nyc1) : \$5.00 (load balancer)
-- invidious-lb4 (nyc1) : \$5.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node17 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node18 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$155.00
-
-# 0.19.0 (2019-07-13)
-
-# Version 0.19.0: Communities
-
-Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
-
-This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
-
-I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
-
-This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
-
-Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
-
-## Communities
-
-Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
-
-It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
-
-## For Developers
-
-For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
-
-An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
-
-An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
-
-## For Administrators
-
-It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$38.39
-- [Liberapay](https://liberapay.com/omarroth) : \$84.85
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$123.24
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$105.00
-
-The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
-
-As always I'm grateful for everyone's support and feedback. I'll see you all next month.
-
-# 0.18.0 (2019-06-06)
-
-# Version 0.18.0: Native Notifications and Optimizations
-
-Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
-
-I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
-
-Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
-
-Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
-
-This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
-
-## For Developers
-
-`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
-
-An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
-
-A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
-
-## For Administrators
-
-There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
-
-As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
-
-## Native Notifications
-
-[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
-
-It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
-
-You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
-
-Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
-
-Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.73
-- [Liberapay](https://liberapay.com/omarroth) : \$100.57
-- Crypto : ~\$11.12 (converted from BCH, BTC)
-- Total : \$161.42
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$85.00
-
-See you all next month!
-
-# 0.17.0 (2019-05-06)
-
-# Version 0.17.0: Player and Authentication API
-
-Hello everyone! This past month there have been [130 commits](https://github.com/omarroth/invidious/compare/0.16.0..0.17.0) from 11 contributors. Large focus has been on improving the player as well as adding API access for other projects to make use of Invidious.
-
-There have also been significant changes in preparation of native notifications (see [#195](https://github.com/omarroth/invidious/issues/195), [#469](https://github.com/omarroth/invidious/issues/469), [#473](https://github.com/omarroth/invidious/issues/473), and [#502](https://github.com/omarroth/invidious/issues/502)), and playlists. I expect to see both of these to be added in the next release.
-
-I'm quite happy to mention that new translations have been added for Esperanto (`eo`) and Ukranian (`uk`). Support for pluralization has also been added, so it should now be possible to make a more native experience for speakers in other languages. The system currently in place is a bit cumbersome, so for any help using this feature please get in touch!
-
-## For Administrators
-
-A `check_tables` option has been added to automatically migrate without the use of custom scripts. This method will likely prove to be much more robust, and is currently enabled for the official instance. To prevent any unintended changes to the DB, `check_tables` is disabled by default and will print commands before executing. Having this makes features that require schema changes much easier to implement, and also makes it easier to upgrade from older instances.
-
-As part of [#303](https://github.com/omarroth/invidious/issues/303), a `cache_annotations` option has been added to speed up access from `/api/v1/annotations/:id`. This vastly improves the experience for videos with annotations. Currently, only videos that contain legacy annotations will be cached, which should help keep down the size of the cache. `cache_annotations` is disabled by default.
-
-## For Developers
-
-An authorization API has been added which allows other applications to read and modify user subscriptions and preferences (see [#473](https://github.com/omarroth/invidious/issues/473)). Support for accessing user feeds and notifications is also planned. I believe this feature is a large step forward in supporting syncing subscriptions and preferences with other services, and I'm excited to see what other developers do with this functionality.
-
-Support for server-to-client push notifications is currently underway. This allows Invidious users, as well as applications using the Invidious API, to receive notifications about uploads in near real-time (see #469). An `/api/v1/auth/notifications` endpoint is currently available. I'm very excited for this to be integrated into the site, and to see how other developers use it in their own projects.
-
-An `/api/v1/storyboards/:id` endpoint has been added for accessing storyboard URLs, which allows developers to add video previews to their players (see below).
-
-## Player
-
-Support for annotations has been merged into master with [#303](https://github.com/omarroth/invidious/issues/303), thanks @glmdgrielson! Annotations can be enabled by default or only for subscribed channels, and can also be toggled per video. I'm extremely proud of the progress made here, and I'm so thankful to everyone that has made this possible. I expect this to be the last update with regards to supporting annotations, but I do plan on continuing to improve the experience as much as possible.
-
-The Invidious player now supports video previews and a corresponding API endpoint `/api/v1/storyboards/:id` has been added for developers looking to add similar functionality to their own players. Not much else to say here. Overall it's a very nice quality of life improvement and an attractive addition to the site.
-
-It is now possible to select specific sources for videos provided using DASH (see [#34](https://github.com/omarroth/invidious/issues/34)). I would consider support largely feature complete, although there are still several issues to be fixed before I would consider it ready for larger rollout. You can watch videos in 1080p by setting `Default quality` to `dash` in your preferences, or by adding `&quality=dash` to the end of video URLs.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.73
-- [Liberapay](https://liberapay.com/omarroth) : \$63.03
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$112.76
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$80.00
-
-That's all for now. Thanks!
-
-# 0.16.0 (2019-04-06)
-
-# Version 0.16.0: API Improvements and Annotations
-
-Hello again! This past month has seen [116 commits](https://github.com/omarroth/invidious/compare/0.15.0..0.16.0) from 13 contributors and a couple important changes I'd like to announce.
-
-A privacy policy is now available [here](https://invidio.us/privacy). I've done my best to explain things as clearly as possible without oversimplifying, and would very much recommend reading it if you're concerned about your privacy and want to learn more about how Invidious uses your data. Please let me know if there is anything that needs clarification.
-
-I'm also very happy to announce that a Spanish translation has been added to the site. You can use it with `?hl=es` or by setting `es` as your default locale. As always I'm extremely grateful to translators for making the site accessible to more people.
-
-## For Administrators
-
-Invidious now supports server-to-server [push notifications](https://developers.google.com/youtube/v3/guides/push_notifications). This uses [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html) to automatically handle new videos sent to an instance, which is less resource intensive and generally faster. Note that it will not pull all videos from a subscribed channel, so recommended usage is in addition to `channel_threads`. Using PubSub requires a valid `domain` that updates can be sent to, and a random string that can be used to sign updates sent to the instance. You can enable it by adding `use_pubsub_feeds: true` to your `config.yml`. See [Configuration](https://github.com/omarroth/invidious/wiki/Configuration) for more info.
-
-Unfortunately there are a couple necessary changes to the DB to support `liveNow` and `premiereTimestamp` in subscription feeds. Migration scripts have been provided that should be used automatically if following the instructions [here](https://github.com/omarroth/invidious/wiki/Updating).
-
-You can now configure default user preferences for your instance. This allows you to set default locale, player preferences, and more. See [#415](https://github.com/omarroth/invidious/issues/415) for more details and example usage.
-
-## For Developers
-
-The [fields](https://developers.google.com/youtube/v3/getting-started#fields) API has been added with [#429](https://github.com/omarroth/invidious/pull/429) and is now supported on all JSON endpoints, thanks [**@afrmtbl**](https://github.com/afrmtbl)! Synax is straight-forward and can be used to reduce data transfer and create a simpler response for debugging. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1&fields=title,recommendedVideos/title). I've been quite happy using it and hope it is similarly useful for others.
-
-An `/api/v1/annotations/:id` endpoint has been added for pulling legacy annotation data from [this](https://archive.org/details/youtubeannotations) archive, see below for more details. You can also access annotation data available on YouTube using `?source=youtube`, although this will only return card data as legacy annotations were deleted on January 15th.
-
-A couple minor changes to existing endpoints:
-
-- A `premiereTimestamp` field has been added to `/api/v1/videos/:id`
-- A `sort_by` param has been added to `/api/v1/comments/:id`, supports `new`, `top`.
-
-More info is available in the [documentation](https://github.com/omarroth/invidious/wiki/API).
-
-## Annotations
-
-I'm pleased to announce that annotation data is finally available from the roughly 1.4 billion videos archived as part of [this](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/) project. They are accessible from the Internet Archive [here](https://archive.org/details/youtubeannotations) or as a 355GB torrent, see [here](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. A corresponding `/api/v1/annotations/:id` endpoint has been added to Invidious which uses the collection from IA to provide legacy annotations.
-
-Support for them in the player is possible thanks to [this](https://github.com/afrmtbl/videojs-youtube-annotations) plugin developed by [**@afrmtbl**](https://github.com/afrmtbl). A PR for adding support to the site is available as [#303](https://github.com/omarroth/invidious/pull/303). There's also an [extension](https://github.com/afrmtbl/AnnotationsRestored) for overlaying them on top of the YouTube player (again thanks to [**@afrmtbl**](https://github.com/afrmtbl)), and an [extension](https://tech234a.bitbucket.io/AnnotationsReloaded?src=invidious) for hooking into code still present in the YouTube player itself, developed by [**@tech234a**](https://github.com/tech234a).
-
-I would recommend reading the [official announcement](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. I would like to again thank everyone that helped contribute to this project.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$42.42
-- [Liberapay](https://liberapay.com/omarroth) : \$70.11
-- Crypto : ~\$1.76 (converted from BCH, BTC, BSV)
-- Total : \$114.29
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$80.00
-
-This past month the site saw a couple abnormal peaks in traffic, so an additional webserver has been added to match the increased load. The goal on Patreon has been updated to match the above expenses.
-
-Thanks everyone!
-
-# 0.15.0 (2019-03-06)
-
-## Version 0.15.0: Preferences and Channel Playlists
-
-The project has seen quite a bit of activity this past month. Large focus has been on fixing bugs, but there's still quite a few new features I'm happy to announce. There have been [133 commits](https://github.com/omarroth/invidious/compare/0.14.0...0.15.0) from 15 contributors this past month.
-
-As a couple miscellaneous changes, a couple [nice screenshots](https://github.com/omarroth/invidious#screenshots) have been added to the README, so folks can see more of what the site has to offer without creating an account.
-
-The footer has also been cleaned up quite a bit, and now displays the current version, so it's easier to know what features are available from the current instance.
-
-## For Administrators
-
-This past month there has been a minor release - `0.14.1` - which fixes a breaking change made by YouTube for their polymer redesign.
-
-There have been several new features that unfortunately require a database migration. There are migration scripts provided in `config/migrate-scripts`, and the [wiki](https://github.com/omarroth/invidious/wiki/Updating) has instructions for automatically applying them. I'll do my best to keep those changes to a minimum, and expect to see a corresponding script to automatically apply any new changes.
-
-Administrator preferences have been added with [#312](https://github.com/omarroth/invidious/issues/312), which allows administrators to customize their instance. Administrators can change the order of feed menus, change the default homepage, disable open registration, and several other options. There's a short 'how-to' [here](https://github.com/omarroth/invidious/issues/312#issuecomment-468831842), and the new options are documented [here](https://github.com/omarroth/invidious/wiki/Configuration).
-
-An `/api/v1/stats` endpoint has been added with [#356](https://github.com/omarroth/invidious/issues/356), which reports the instance version and number of active users. Statistics are disabled by default, and can be enabled in administator preferences. Statistics for the official instance are available [here](https://invidio.us/api/v1/stats?pretty=1).
-
-## For Developers
-
-`/api/v1/channels/:ucid` now provides an `autoGenerated` tag, which returns true for topic channels, and larger genre channels generated by YouTube. These channels don't have any videos of their own, so `latestVideos` will be empty. It is recommended instead to display a list of playlists generated by YouTube.
-
-You can now pull a list of playlists from a channel with `/api/v1/channels/playlists/:ucid`. Supported options are documented in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelsplaylistsucid-apiv1channelsucidplaylists). Pagination is handled with a `continuation` token, which is generated on each call. Of note is that auto-generated channels currently have one page of results, and subsequent calls will be empty.
-
-For quickly pulling the latest 30 videos from a channel, there is now `/api/v1/channels/latest/:ucid`. It is much faster than a call to `/api/v1/channels/:ucid`. It will not convert an author name to a valid ucid automatically, and will not return any extra data about a channel.
-
-## Preferences
-
-In addition to administrator preferences mentioned above, you can now change your preferences without an account (see [#42](https://github.com/omarroth/invidious/pull/42)). I think this is quite an improvement to the usability of the site, and is much friendlier to privacy-conscious folks that don't want to make an account. Preferences will be automatically imported to a newly created account.
-
-Several issues with sorting subscriptions have been fixed, and `/manage_subscriptions` has been sped up significantly. The subscription feed has also seen a bump in performance. Delayed notifications have unfortunately started becoming a problem now that there are more users on the site. Some new changes are currently being tested which should mostly resolve the issue, so expect to see more in the next release.
-
-## Channel Playlists
-
-You can now view available playlists from a channel, and [auto-generated channels](https://invidio.us/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) are no longer empty. You can sort as you would on YouTube, and all the same functionality should be available. I'm quite pleased to finally have it implemented, since it's currently the only data available from the above mentioned auto-generated channels, and makes it much easier to consume music on the site.
-
-There's also more discussion on improving Invidious for streaming music in [#304](https://github.com/omarroth/invidious/issues/304), and adding support for music.youtube.com. I would appreciate any thoughts on how to improve that experience, since it's a very large and useful part of YouTube.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$42.42
-- [Liberapay](https://liberapay.com/omarroth) : \$30.97
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$73.39
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-It's been very humbling to see how fast the project has grown, and I look forward to making the site even better. Thank you everyone.
-
-# 0.14.0 (2019-02-06)
-
-## Version 0.14.0: Community
-
-This last month several contributors have made improvements specifically for the people using this project. New pages have been added to the wiki, and there is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) and IRC channel so it's easier and faster for people to ask questions or chat. There have been [101 commits](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) since the last major release from 8 contributors.
-
-It has come to my attention in the past month how many people are self-hosting, and I would like to make it easier for them to do so.
-
-With that in mind, expect future releases to have a section for For Administrators (if any relevant changes) and For Developers (if any relevant changes).
-
-## For Administrators
-
-This month the most notable change for administrators is releases. As always, there will be a major release each month. However, a new minor release will be made whenever there are any critical bugs that need to be fixed.
-
-This past month is the first time there has been a minor release - `0.13.1` - which fixes a breaking change made by YouTube. Administrators using versioning for their instances will be able to rely on the latest version, and should have a system in place to upgrade their instance as soon as a new release is available.
-
-Several new pages have been added to the [wiki](https://github.com/omarroth/invidious/wiki#for-administrators) (as mentioned below) that will help administrators better setup their own instances. Configuration, maintenance, and instructions for updating are of note, as well as several common issues that are encountered when first setting up.
-
-## For Developers
-
-There's now a `pretty=1` parameter for most endpoints so you can view data easily from the browser, which is convenient for debugging and casual use. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1).
-
-Unfortunately the `/api/v1/insights/:id` endpoint is no longer functional, as YouTube removed all publicly available analytics around a month ago. The YouTube endpoint now returns a 404, so it's unlikely it will be functional again.
-
-## Wiki
-
-There have been a sizable number of changes to the Wiki, including a [list of public Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances), the [list of extensions](https://github.com/omarroth/invidious/wiki/Extensions), and documentation for administrators (as mentioned above) and developers.
-
-The wiki is editable by anyone so feel free to add anything you think is useful.
-
-## Matrix & IRC
-
-Thee is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) for Invidious, so please feel free to hop on if you have any questions or want to chat. There is also a registered IRC channel: #invidious on Freenode which is bridged to Matrix.
-
-## Features
-
-Several new features have been added, including a download button, creator hearts and comment colors, and a French translation.
-
-There have been fixes for Google logins, missing text in locales, invalid links to genre channels, and better error handling in the player, among others.
-
-Several fixes and features are omitted for space, so I'd recommend taking a look at the [compare tab](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) for more information.
-
-## Annotations Update
-
-Annotations were removed January 15th, 2019 around15:00 UTC. Before they were deleted we were able to archive annotations from around 1.4 billion videos. I'd very much recommend taking a look [here](https://www.reddit.com/r/DataHoarder/comments/al7exa/youtube_annotation_archive_update_and_preview/) for more information and a list of acknowledgements. I'm extremely thankful to everyone who was able to contribute and I'm glad we were able to save such a large part of internet history.
-
-There's been large strides in supporting them in the player as well, which you can follow in [#303](https://github.com/omarroth/invidious/pull/303). You can preview the functionality at https://dev.invidio.us . Before they are added to the main site expect to see an option to disable them, both site-wide and per video.
-
-Organizing this project has unfortunately taken up quite a bit of my time, and I've been very grateful for everyone's patience.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.42
-- [Liberapay](https://liberapay.com/omarroth) : \$27.89
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$77.31
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-As always I'm grateful for everyone's contributions and support. I'll see you all in March.
-
-# 0.13.1 (2019-01-19)
-
-##
-
-# 0.13.0 (2019-01-06)
-
-## Version 0.13.0: Translations, Annotations, and Tor
-
-I hope everyone had a happy New Year! There's been a couple new additions since last release, with [44 commits](https://github.com/omarroth/invidious/compare/0.12.0...0.13.0) from 9 contributors. It's been quite a year for the project, and I hope to continue improving the project into 2019! Starting off the new year:
-
-## Translations
-
-I'm happy to announce support for translations has been added with [`a160c64`](https://github.com/omarroth/invidious/a160c64). Currently, there is support for:
-
-- Arabic (`ar`)
-- Dutch (`nl`)
-- English (`en-US`)
-- German (`de`)
-- Norwegian Bokmål (`nb_NO`)
-- Polish (`pl`)
-- Russian (`ru`)
-
-Which you can change in your preferences under `Language`. You can also add `&hl=LANGUAGE` to the end of any request to translate it to your preferred language, for example https://invidio.us/?hl=ru. I'd like to say thank you again to everyone who has helped translate the site! I've mentioned this before, but I'm delighted that so many people find the project useful.
-
-## Annotations
-
-Recently, [YouTube announced that all annotations will be deleted on January 15th, 2019](https://support.google.com/youtube/answer/7342737). I believe that annotations have a very important place in YouTube's history, and [announced a project to archive them](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/).
-
-I expect annotations to be supported in the Invidious player once archiving is complete (see [#110](https://github.com/omarroth/invidious/issues/110) for details), and would also like to host them for other developers to use in their projects.
-
-The code is available [here](https://github.com/omarroth/archive), and contains instructions for running a worker if you would like to contribute. There's much more information available in the announcement as well for anyone who is interested.
-
-## Tor
-
-I unfortunately missed the chance to mention this in the previous release, but I'm now happy to announce that you can now view Invidious through Tor at the following links:
-
-kgg2m7yk5aybusll.onion
-axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
-
-Invidious is well suited to use through Tor, as it does not require any JS and is fairly lightweight. I'd recommend looking [here](https://diasp.org/posts/10965196) and [here](https://www.reddit.com/r/TOR/comments/a3c1ak/you_can_now_watch_youtube_videos_anonymously_with/) for more details on how to use the onion links, and would like to say thank you to [/u/whonix-os](https://www.reddit.com/user/whonix-os) for suggesting it and providing support setting setting them up.
-
-## Popular and Trending
-
-You can now easily view videos trending on YouTube with [`a16f967`](https://github.com/omarroth/invidious/a16f967). It also provides support for viewing YouTube's various categories categories, such as `News`, `Gaming`, and `Music`. You can also change the `region` parameter to view trending in different countries, which should be made easier to use in the coming weeks.
-
-A link to `/feed/popular` has also been added, which provides a list of videos sorted using the algorithm described [here](https://github.com/omarroth/invidious/issues/217#issuecomment-436503761). I think it better reflects what users watch on the site, but I'd like to hear peoples' thoughts on this and on how it could be improved.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth): \$64.63
-- [Liberapay](https://liberapay.com/omarroth) : \$30.05
-- Crypto : ~\$28.74 (converted from BCH, BTC)
-- Total : \$123.42
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-### What will happen with what's left over?
-
-I believe this is the first month that all expenses have been fully paid for by donations. Thank you! I expect to allocate the current amount for hardware to improve performance and for hosting annotation data, as mentioned above.
-
-Anything that is left over is kept to continue hosting the project for as long as possible. Thank you again everyone!
-
-I think that's everything for 2018. There's lots still planned, and I'm very excited for the future of this project!
-
-# 0.12.0 (2018-12-06)
-
-## Version 0.12.0: Accessibility, Privacy, Transparency
-
-Hello again, it's been a while! A lot has happened since the last release. Invidious has seen [134 commits](https://github.com/omarroth/invidious/compare/0.11.0...0.12.0) from 3 contributors, and I'm quite happy with the progress that has been made. I enjoyed this past month, and I believe having a monthly release schedule allows me to focus on more long-term improvements, and I hope people enjoy these more substantial updates as well.
-
-## Accessability and Privacy
-
-There have been quite a few improvements for user privacy, and improvements that improve accessibility for both people and software.
-
-You can now view comments without JS with [`19516ea`](https://github.com/omarroth/invidious/19516ea). Currently, this functionality is limited to the first 20 comments, but expect this functionality to be improved to come as close to the JS version as possible. Folks can track progress in [#204](https://github.com/omarroth/invidious/issues/204).
-
-Invidious is now compatible with [LibreJS](https://www.gnu.org/software/librejs/), and provides license information [here](https://invidio.us/licenses) with [`7f868ec`](https://github.com/omarroth/invidious/7f868ec). As expected, all libraries are compatible under the AGPLv3, and I'm happy to mention that no other changes were required to make Invidious compatible with LibreJS.
-
-A DNT policy has also been added with [`9194f47`](https://github.com/omarroth/invidious/9194f47) for compatibility with [Privacy Badger](https://www.eff.org/privacybadger). I'm pleased to mention that here too no other changes had to be made in order for Invidious to be compatible with this extension. I expect a privacy policy to be added soon as well, so users can better understand how Invidious uses their data.
-
-For users that are visually impaired, there is now a text CAPTCHA available so it's easier to register and login. Because of the simple front-end of the project, I expect screen readers and other software to be able to easily understand the site's interface. In combination with the ability to listen-only, I believe Invidious is much more accessible than YouTube. Folks can read [#244](https://github.com/omarroth/invidious/issues/244) for more details, and I would very much appreciate any feedback on how this can be improved.
-
-## User Preferences
-
-There have been a lot of improvements to preferences. Options for enabling audio-only by default and continuous playback (autoplay) have been added with [`e39dec9`](https://github.com/omarroth/invidious/e39dec9), with [`4b76b93`](https://github.com/omarroth/invidious/4b76b93), respectively. Users can also now mark videos as watched from their subscription feed and view watch history by going to https://invidio.us/feed/history. I expect to add more information to history so that it's easier to use. Folks can track progress with [#182](https://github.com/omarroth/invidious/issues/182). As with all data Invidious keeps, watch history can be exported [here](https://invidio.us/data_control).
-
-Users can now delete their account with [`b9c29bf`](https://github.com/omarroth/invidious/b9c29bf). This will remove _all_ user data from Invidious, including session IDs, watch history, and subscriptions. As mentioned above, it's easy to export that data and import it to a local instance, or export subscriptions for use with other applications such as [FreeTube](https://github.com/FreeTubeApp/FreeTube) or [NewPipe](https://github.com/TeamNewPipe/NewPipe).
-
-## Translation and Internationalis(z)ation
-
-Invidious has been approved for hosting by Weblate, available [here](https://hosted.weblate.org/projects/invidious/translations/). At the time of writing, translations for Arabic, Dutch, German, Polish, and Russian are currently underway. I would like to say a very big thank you to everyone working on them, and I hope to fully support them within around 2 weeks. Folks can track progress with [#251](https://github.com/omarroth/invidious/issues/251).
-
-## Transperency and Finances
-
-For the sake of transparency, I plan on publishing each month's finances. This is currently already done on Liberapay and Patreon, but there is not a total amount currently provided anywhere, and I would also like to include expenses to provide a better explanation of how patrons' money is being spent.
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth): \$43.60 (Patreon takes roughly 9%)
-- [Liberapay](https://liberapay.com/omarroth) : \$22.10
-- Crypto : ~\$1.25 (converted from BCH, BTC)
-- Total : \$66.95
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-I'd be happy to provide any explanation where needed. I would also like to thank everyone who donates, it really helps and I can't say how happy I am to see that so many people find it valuable.
-
-That's all for this month. I wish everyone the best for the holidays, and I'll see you all again in January!
-
-# 0.11.0 (2018-10-23)
-
-## Week 11: FreeTube and Styling
-
-This past Friday I'm been very excited to see that FreeTube version [0.4.0](https://github.com/FreeTubeApp/FreeTube/tree/0.4.0) has been released! I'd recommend taking a look at the official patch notes, but to spoil a little bit here: FreeTube now uses the Invidious API for _all_ requests previously sent to YouTube, and has also seen support for playlists, keyboard shortcuts, and more default settings (speed, autoplay, and subtitles). I'm happy to see that FreeTube has reached 500 stars on Github, and I think it's very much deserved. I'd recommend keeping an eye on the newly-launched [FreeTube blog](https://freetube.writeas.com/) for updates on the project.
-
-Quite a few styling changes have been added this past week, including channel subscriber count to the subscribe and unsubscribe buttons. The changes sound small, but they've been a very big improvement and I'm quite satisfied with how they look. Also to note is that partial support for duration in thumbnails have been added with [#202](https://github.com/omarroth/invidious/issues/202). Overall, I think the site is becoming much more pleasing visually, and I hope to continue to improve it.
-
-I've been very pleased to see Invidious in its current state, and I believe it's many times more mature compared to even a month ago. Changes have also started slowing down a bit as it's become more mature, and therefore I'd like to transition to a monthly update schedule in order to provide more comprehensive updates for everyone. I want to thank you all for helping me reach this point. I can't say how happy I am for Invidious to be where it is now.
-
-Enjoy the rest of your week everyone, I'll see you in November!
-
-# 0.10.0 (2018-10-16)
-
-## Week 10: Subscriptions
-
-This week I'm happy to announce that subscriptions have been drastically sped up with
-35e63fa. As I mentioned last week, this essentially "caches" a user's feed, meaning that operations that previously took 20 seconds or timed out, now can load in under a second. I'd take a look at [#173](https://github.com/omarroth/invidious/issues/173) for a sample benchmark. Previously features that made Invidious's feed so useful, such as filtering by unseen and by author would take too long to load, and so instead would timeout. I'm very happy that this has been fixed, and folks can get back to using these features.
-
-Among some smaller features that have been added this week include [#118](https://github.com/omarroth/invidious/issues/118), which adds, in my opinion, some very attractive subscribe and unsubscribe buttons. I think it's also a bit of a functional improvement as well, since it doesn't require a user to reload the page in order to subscribe or unsubscribe to a channel, and also gives the opportunity to put the channel's sub count on display.
-
-An option to swap between Reddit and YouTube comments without a page reload has been added with
-5eefab6, bringing it somewhat closer in functionality to the popular [AlienTube](https://github.com/xlexi/alientube) extension, on which it is based (although the extension unfortunately appears now to be fragmented).
-
-As always, there are a couple smaller improvements this week, including some minor fixes for geo-bypass with
-e46e618 and [`245d0b5`](https://github.com/omarroth/invidious/245d0b5), playlist preferences with [`81b4477`](https://github.com/omarroth/invidious/81b4477), and YouTube comments with [`02335f3`](https://github.com/omarroth/invidious/02335f3).
-
-This coming week I'd also recommend keeping an eye on the excellent [FreeTube](https://github.com/FreeTubeApp/FreeTube), which is looking forward to a new release. I've been very lucky to work with [**@PrestonN**](https://github.com/PrestonN) for the past few weeks to improve the Invidious API, and I'm quite looking forward to the new release.
-
-That's all for this week folks, thank you all again for your continued interest and support.
-
-# 0.9.0 (2018-10-08)
-
-## Week 9: Playlists
-
-Not as much to announce this week, but I'm still quite happy to announce a couple things, namely:
-
-Playback support for playlists has finally been added with [`88430a6`](https://github.com/omarroth/invidious/88430a6). You can now view playlists with the `&list=` query param, as you would on YouTube. You can also view mixes with the mentioned `&list=`, although they require some extra handling that I would like to add in the coming week, as well as adding playlist looping and shuffle. I think playback support has been a roadblock for more exciting features such as [#114](https://github.com/omarroth/invidious/issues/114), and I look forward to improving the experience.
-
-Comments have had a bit of a cosmetic upgrade with [#132](https://github.com/omarroth/invidious/issues/132), which I think helps better distinguish between Reddit and YouTube comments, as it makes them appear similarly to their respective sites. You can also now switch between YouTube and Reddit comments with a push of a button, which I think is quite an improvement, especially for newer or less popular videos with fewer comments.
-
-I've had a small breakthrough in speeding up users' subscription feeds with PostgreSQL's [materialized views](https://www.postgresql.org/docs/current/static/rules-materializedviews.html). Without going into too much detail, materialized views essentially cache the result of a query, making it possible to run resource-intensive queries once, rather than every time a user visits their feed. In the coming week I hope to push this out to users, and hopefully close [#173](https://github.com/omarroth/invidious/issues/173).
-
-I haven't had as much time to work on the project this week, but I'm quite happy to have added some new features. Have a great week everyone.
-
-# 0.8.0 (2018-10-02)
-
-## Week 8: Mixes
-
-Hello again!
-
-Mixes have been added with [`20130db`](https://github.com/omarroth/invidious/20130db), which makes it easy to create a playlist of related content. See [#188](https://github.com/omarroth/invidious/issues/188) for more info on how they work. Currently, they return the first 50 videos rather than a continuous feed to avoid tracking by Google/YouTube, which I think is a good trade-off between usability and privacy, and I hope other folks agree. You can create mixes by adding `RD` to the beginning of a video ID, an example is provided [here](https://www.invidio.us/mix?list=RDYE7VzlLtp-4) based on Big Buck Bunny. I've been quite happy with the results returned for the mixes I've tried, and it is not limited to music, which I think is a big plus. To emulate a continuous feed provided many are used to, using the last video of each mix as a new 'seed' has worked well for me. In the coming week I'd like to to add playback support in the player to listen to these easily.
-
-A very big thanks to [**@flourgaz**](https://github.com/flourgaz) for Docker support with [#186](https://github.com/omarroth/invidious/pull/186). This is an enormous improvement in portability for the project, and opens the door for Heroku support (see [#162](https://github.com/omarroth/invidious/issues/162)), and seamless support on Windows. For most users, it should be as easy as running `docker-compose up`.
-
-I've spent quite a bit of time this past week improving support for geo-bypass (see [#92](https://github.com/omarroth/invidious/issues/92)), and am happy to note that Invidious has been able to proxy ~50% of the geo-restricted videos I've tried. In addition, you can now watch geo-restricted videos if you have `dash` enabled as your `preferred quality`, for more details see [#34](https://github.com/omarroth/invidious/issues/34) and [#185](https://github.com/omarroth/invidious/issues/185), or last week's update. For folks interested in replicating these results for themselves, I'd take a look [here](https://gist.github.com/omarroth/3ce0f276c43e0c4b13e7d9cd35524688) for the script used, and [here](https://gist.github.com/omarroth/beffc4a76a7b82a422e1b36a571878ef) for a list of videos restricted in the US.
-
-1080p has seen a fairly smooth roll-out, although there have been a couple issues reported, mainly [#193](https://github.com/omarroth/invidious/issues/193), which is likely an issue in the player. I've also encountered a couple other issues myself that I would like to investigate. Although none are major, I'd like to keep 1080p opt-in for registered users another week to better address these issues.
-
-Have an excellent week everyone.
-
-# 0.7.0 (2018-09-25)
-
-## Week 7: 1080p and Search Types
-
-Hello again everyone! I've got quite a couple announcements this week:
-
-Experimental 1080p support has been added with [`b3ca392`](https://github.com/omarroth/invidious/b3ca392), and can be enabled by going to preferences and changing `preferred video quality` to `dash`. You can find more details [here](https://github.com/omarroth/invidious/issues/34#issuecomment-424171888). Currently quality and speed controls have not yet been integrated into the player, but I'd still appreciate feedback, mainly on any issues with buffering or DASH playback. I hope to integrate 1080p support into the player and push support site-wide in the coming weeks.
-
-You can now filter content types in search with the `type:TYPE` filter. Supported content types are `playlist`, `channel`, and `video`. More info is available [here](https://github.com/omarroth/invidious/issues/126#issuecomment-423823148). I think this is quite an improvement in usability and I hope others find the same.
-
-A [CHANGELOG](https://github.com/omarroth/invidious/blob/master/CHANGELOG.md) has been added to the repository, so folks will now receive a copy of all these updates when cloning. I think this is an improvement in hosting the project, as it is no longer tied to the `/releases` tab on Github or the posts on Patreon.
-
-Recently, users have been reporting 504s when attempting to access their subscriptions, which is tracked in [#173](https://github.com/omarroth/invidious/issues/173). This is most likely caused by an uptick in usage, which I am absolutely grateful for, but unfortunately has resulted in an increase in costs for hosting the site, which is why I will be bumping my goal on Patreon from $60 to $80. I would appreciate any feedback on how subscriptions could be improved.
-
-Other minor improvements include:
-
-- Additional regions added to bypass geo-block with [`9a78523`](https://github.com/omarroth/invidious/9a78523)
-- Fix for playlists containing less than 100 videos (previously shown as empty) with [`35ac887`](https://github.com/omarroth/invidious/35ac887)
-- Fix for `published` date for Reddit comments (previously showing negative seconds) with [`6e09202`](https://github.com/omarroth/invidious/6e09202)
-
-Thank you everyone for your support!
-
-# 0.6.0 (2018-09-18)
-
-## Week 6: Filters and Thumbnails
-
-Hello again! This week I'm happy to mention a couple new features to search as well as some miscellaneous usability improvements.
-
-You can now constrain your search query to a specific channel with the `channel:CHANNEL` filter (see [#165](https://github.com/omarroth/invidious/issues/165) for more details). Unfortunately, other search filters combined with channel search are not yet supported. I hope to add support for them in the coming weeks.
-
-You can also now search only your subscriptions by adding `subscriptions:true` to your query (see [#30](https://github.com/omarroth/invidious/issues/30) for more details). It's not quite ready for widespread use but I would appreciate feedback as the site updates to fully support it. Other search filters are not yet supported with `subscriptions:true`, but I hope to add more functionality to this as well.
-
-With [#153](https://github.com/omarroth/invidious/issues/153) and [#168](https://github.com/omarroth/invidious/issues/168) all images on the site are now proxied through Invidious. In addition to offering the user more protection from Google's eyes, it also allows the site to automatically pick out the highest resolution thumbnail for videos. I think this is quite a large aesthetic improvement and I hope others will find the same.
-
-As a smaller improvement to the site, you can also now view RSS feeds for playlists with [#113](https://github.com/omarroth/invidious/issues/113).
-
-These updates are also now listed under Github's [releases](https://github.com/omarroth/invidious/releases). I'm also planning on adding them as a `CHANGELOG.md` in the repository itself so people can receive a copy with the project's source.
-
-That's all for this week. Thank you everyone for your support!
-
-# 0.5.0 (2018-09-11)
-
-## Week 5: Privacy and Security
-
-I hope everyone had a good weekend! This past week I've been fixing some issues that have been brought to my attention to help better protect users and help them keep their anonymity.
-
-An issue with open referers has been fixed with [`29a2186`](https://github.com/omarroth/invidious/29a2186), which prevents potential redirects to external sites on actions such as login or modifying preferences.
-
-Additionally, X-XSS-Protection, X-Content-Type-Options, and X-Frame-Options headers have been added with [`96234e5`](https://github.com/omarroth/invidious/96234e5), which should keep users safer while using the site.
-
-A potential XSS vector has also been fixed in YouTube comments with [`8c45694`](https://github.com/omarroth/invidious/8c45694).
-
-All the above vulnerabilities were brought to my attention by someone who wishes to remain anonymous, but I would like to say again here how thankful I am. If anyone else would like to get in touch please feel free to email me at omarroth@hotmail.com or omarroth@protonmail.com.
-
-This week a couple changes have been made to better protect user's privacy as well.
-All CSS and JS assets are now served locally with [`3ec684a`](https://github.com/omarroth/invidious/3ec684a), which means users no longer need to whitelist unpkg.com. Although I personally have encountered few issues, I understand that many folks would like to keep their browsing activity contained to as few parties as possible. In the coming week I also hope to proxy YouTube images, so that no user data is sent to Google.
-
-YouTube links in comments now should redirect properly to the Invidious alternate with [`1c8bd67`](https://github.com/omarroth/invidious/1c8bd67) and [`cf63c82`](https://github.com/omarroth/invidious/cf63c82), so users can more easily evade Google tracking.
-
-I'm also happy to mention a couple quality of life features this week:
-
-Invidious now shows a video's "license" if provided, see [#159](https://github.com/omarroth/invidious/issues/159) for more details. You can also search for videos licensed under the creative commons with "QUERY features:creative_commons".
-
-Videos with only one source will always display the cog for changing quality, so that users can see what quality is currently playing. See [#158](https://github.com/omarroth/invidious/issues/158) for more details.
-
-Folks have also probably noticed that the gutters on either side of the screen have been shrunk down quite significantly, so that more of the screen is filled with content. Hopefully this can be improved even more in the coming weeks.
-
-"Music", "Sports", and "Popular on YouTube" channels now properly display their videos. You can subscribe to these channels just as you would normally.
-
-This coming week I'm planning on spending time with my family, so I unfortunately may not be as responsive. I do still hope to add some smaller features for next week however, and I hope to continue development soon.
-Thank you everyone again for your support.
-
-# 0.4.0 (2018-09-06)
-
-## Week 4: Genre Channels
-
-Hello! I hope everyone enjoyed their weekend. Without further ado:
-Just today genre channels have been added with [#119](https://github.com/omarroth/invidious/issues/119). More information on genre channels is available [here](https://support.google.com/youtube/answer/2579942). You can subscribe to them as normally, and view them as RSS. I think they offer an interesting alternative way to find new content and I hope people find them useful.
-
-This past week folks have started reporting 504s on their subscription page (see [#144](https://github.com/omarroth/invidious/issues/144) for more details). Upgrading the database server appeared to fix the issue, as well as providing a smoother experience across the site. Unfortunately, that means I will be increasing the goal from $50 to $60 in order to meet the increased hosting costs.
-
-With [#134](https://github.com/omarroth/invidious/issues/134), comments are now formatted correctly, providing support for bold, italics, and links in comments. I think this improvement makes them much easier to read, and I hope others find the same. Also to note is that links in both comments and the video description now no longer contain any of Google's tracking with [#115](https://github.com/omarroth/invidious/issues/115).
-
-One of the major use cases for Invidious is as a stripped-down version of YouTube. In line with that, I'm happy to announce that you can now hide related videos if you're logged in, for users that prefer an even more lightweight experience.
-
-Finally, I'm pleased to announce that Invidious has hit 100 stars on GitHub. I am very happy that Invidious has proven to be useful to so many people, and I can't say how grateful I am to everyone for their continued support.
-
-Enjoy the rest of your week everyone!
-
-# 0.3.0 (2018-09-06)
-
-## Week 3: Quality of Life
-
-Hello everyone! This week I've been working on some smaller features that will hopefully make the site more functional.
-Search filters have been added with [#126](https://github.com/omarroth/invidious/issues/126). You can now specify 'sort', 'date', 'duration', and 'features' within your query using the 'operator:value' syntax. I'd recommend taking a look [here](https://github.com/omarroth/invidious/blob/master/src/invidious/search.cr#L33-L114) for a list of supported options and at [#126](https://github.com/omarroth/invidious/issues/126) for some examples. This also opens the door for features such as [#30](https://github.com/omarroth/invidious/issues/30) which can be implemented as filters. I think advanced search is a major point in which Invidious can improve on YouTube and hope to add more features soon!
-
-This week a more advanced system for viewing fallback comments has been added (see [#84](https://github.com/omarroth/invidious/issues/84) for more details). You can now specify a comment fallback in your preferences, which Invidious will use. If, for example, no Reddit comments are available for a given video, it can choose to fallback on YouTube comments. This also makes it possible to turn comments off completely for users that prefer a more streamlined experience.
-
-With [#98](https://github.com/omarroth/invidious/issues/98), it is now possible for users to specify preferences without creating an account. You can now change speed, volume, subtitles, autoplay, loop, and quality using query parameters. See the issue above for more details and several examples.
-
-I'd also like to announce that I've set up an account on [Liberapay](https://liberapay.com/omarroth), for patrons that prefer a privacy-friendly alternative to Patreon. Liberapay also does not take any percentage of donations, so I'd recommend donating some to the Liberapay for their hard work. Go check it out!
-
-[Two weeks ago](https://github.com/omarroth/invidious/releases/tag/0.1.0) I mentioned adding 1080p support into the player. Currently, the only thing blocking is [#207](https://github.com/videojs/http-streaming/pull/207) in the excellent [http-streaming](https://github.com/videojs/http-streaming) library. I hope to work with the videojs team to merge it soon and finally implement 1080p support!
-
-That's all for this week, thank you again everyone for your support!
-
-# 0.2.0 (2018-09-06)
-
-## Week 2: Toward Playlists
-
-Sorry for the late update! Not as much to announce this week, but still a couple things of note:
-I'm happy to announce that a playlists page and API endpoint has been added so you can now view playlists. Currently, you cannot watch playlists through the player, but I hope to add that in the coming week as well as adding functionality to add and modify playlists. There is a good conversation on [#114](https://github.com/omarroth/invidious/issues/114) about giving playlists even more functionality, which I think is interesting and would appreciate feedback on.
-
-As an update to the Invidious API announcement last week, I've been working with [**@PrestonN**](https://github.com/PrestonN), the developer of [FreeTube](https://github.com/FreeTubeApp/FreeTube), to help migrate his project to the Invidious API. Because of it's increasing popularity, he has had trouble keeping under the quota set by YouTube's API. I hope to improve the API to meet his and others needs and I'd recommend folks to keep an eye on his excellent project! There is a good discussion with his thoughts [here](https://github.com/FreeTubeApp/FreeTube/issues/100).
-
-A couple of miscellaneous features and bugfixes:
-
-- You can now login to Invidious simultaneously from multiple devices - [#109](https://github.com/omarroth/invidious/issues/109)
-
-- Added a note for scheduled livestreams - [#124](https://github.com/omarroth/invidious/issues/124)
-
-- Changed YouTube comment header to "View x comments" - [#120](https://github.com/omarroth/invidious/issues/120)
-
-Enjoy your week everyone!
-
-# 0.1.0 (2018-09-06)
-
-## Week 1: Invidious API and Geo-Bypass
-
-Hello everyone! This past week there have been quite a few things worthy of mention:
-
-I'm happy to announce the [Invidious Developer API](https://github.com/omarroth/invidious/wiki/API). The Invidious API does not use any of the official YouTube APIs, and instead crawls the site to provide a JSON interface for other developers to use. It's still under development but is already powering [CloudTube](https://github.com/cloudrac3r/cadencegq). The API currently does not have a quota (compared to YouTube) which I hope to continue thanks to continued support from my Patrons. Hopefully other developers find it useful, and I hope to continue to improve it so it can better serve the community.
-
-Just today partial support for bypassing geo-restrictions has been added with [fada57a](https://github.com/omarroth/invidious/commit/fada57a307d66d696d9286fc943c579a3fd22de6). If a video is unblocked in one of: United States, Canada, Germany, France, Japan, Russia, or United Kingdom, then Invidious will be able to serve video info. Currently you will not yet be able to access the video files themselves, but in the coming week I hope to proxy videos so that users can enjoy content across borders.
-
-Support for generating DASH manifests has been fixed, in the coming week I hope to integrate this functionality into the watch page, so users can view videos in 1080p and above.
-
-Thank you everyone for your continued interest and support!
+# CHANGELOG
+
+## vX.Y.0 (future)
+
+
+## v2.20241110.0
+
+### Wrap-up
+
+This release is most importantly here to fix to the annoying "Youtube API returned error 400"
+error that prevented all channel pages from loading.
+
+If you're updating from the previous release, it provides no improvements on the ability to play
+videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
+by a previous attempt at restoring video playback on large instances.
+
+In the preferences, a new option allows for control of video preload. When enabled, this option
+tells the browser to load the video as soon as the page is loaded (this used to be the default).
+When disabled, the video starts loading only when the "play" button is pressed.
+
+New interface languages available: Bulgarian, Welsh and Lombard
+
+New dependency required: `tzdata`.
+
+An HTTP proxy can be configured directly in Invidious, if needed. \
+**NOTE:** In that case, it is recommended to comment out `force_resolve`.
+
+
+### New features & important changes
+
+#### For users
+
+* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
+* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
+* Preferences: Addition of the new "preload" option
+* New interface languages available: Bulgarian, Welsh and Lombard
+* Added "Filipino (auto-generated)" to the list of caption languages available
+* Lots of new translations from Weblate
+
+#### For instance owners
+
+* Allow the configuration of an HTTP proxy to talk to Youtube
+* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
+* The instance list is downloaded in the background to improve redirection speed
+* New `colorize_logs` option makes each log level a different color
+
+#### For developpers
+
+* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
+ `newest`, `oldest` and `popular`
+* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
+* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
+ `is3d` and `hasCaptions`
+
+### Bugs fixed
+
+#### User-side
+
+* Channels: The second page of shorts now loads as expected
+* Channels: Fixed intermittent empty "playlists" tab
+* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
+* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
+* Switching to another instance is much faster
+* Fixed an "invalid byte sequence" error when subscribing to a playlist
+* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
+
+#### For instance owners
+
+* Fix `force_resolve` being ignored in some cases
+
+#### API
+
+* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
+
+
+### Full list of pull requests merged since the last release (newest first)
+
+* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha)
+* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox)
+* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox)
+* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox)
+* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox)
+* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue)
+* Stale bot updates ([#5060], thanks @syeopite)
+* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox)
+* Channels: Fix for live videos ([#5027], thanks @iBicha)
+* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox)
+* Shards: Update database dependencies ([#5034], by @SamantazFox)
+* Logger: Add color support for different log levels ([#4931], thanks @Fijxu)
+* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite)
+* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite)
+* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox)
+* Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox)
+* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu)
+* Fix player menus hiding onHover ready ([#4750], thanks @giacomocerquone)
+* Use connection pools when requesting images from YouTube ([#4326], thanks @syeopite)
+* Add support for using Invidious through a HTTP Proxy ([#4270], thanks @syeopite)
+* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox)
+* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
+* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
+* Parse more metadata badges for SearchVideos ([#4863], thanks @ChunkyProgrammer)
+* Translations update from Hosted Weblate ([#4862], thanks to our many translators)
+* Videos: Convert URL before putting result into cache ([#4850], by @SamantazFox)
+* HTML: Add error message to "search issues on GitHub" link ([#4652], thanks @tracedgod)
+* Preferences: Add option to control preloading of video data ([#4122], thanks @Nerdmind)
+* Performance: Improve speed of automatic instance redirection ([#4193], thanks @syeopite)
+* Remove myself from CODEOWNERS on the config file ([#4942], by @TheFrenchGhosty)
+* Update latest version WEB_CREATOR + fix comment web embed ([#4930], thanks @unixfox)
+* use WEB_CREATOR when po_token with WEB_EMBED as a fallback ([#4928], thanks @unixfox)
+* Revert "use web screen embed for fixing potoken functionality"
+* use web screen embed for fixing potoken functionality ([#4923], thanks @unixfox)
+
+[#4122]: https://github.com/iv-org/invidious/pull/4122
+[#4193]: https://github.com/iv-org/invidious/pull/4193
+[#4270]: https://github.com/iv-org/invidious/pull/4270
+[#4326]: https://github.com/iv-org/invidious/pull/4326
+[#4652]: https://github.com/iv-org/invidious/pull/4652
+[#4709]: https://github.com/iv-org/invidious/pull/4709
+[#4750]: https://github.com/iv-org/invidious/pull/4750
+[#4754]: https://github.com/iv-org/invidious/pull/4754
+[#4850]: https://github.com/iv-org/invidious/pull/4850
+[#4862]: https://github.com/iv-org/invidious/pull/4862
+[#4863]: https://github.com/iv-org/invidious/pull/4863
+[#4887]: https://github.com/iv-org/invidious/pull/4887
+[#4888]: https://github.com/iv-org/invidious/pull/4888
+[#4894]: https://github.com/iv-org/invidious/pull/4894
+[#4923]: https://github.com/iv-org/invidious/pull/4923
+[#4928]: https://github.com/iv-org/invidious/pull/4928
+[#4930]: https://github.com/iv-org/invidious/pull/4930
+[#4931]: https://github.com/iv-org/invidious/pull/4931
+[#4934]: https://github.com/iv-org/invidious/pull/4934
+[#4942]: https://github.com/iv-org/invidious/pull/4942
+[#4984]: https://github.com/iv-org/invidious/pull/4984
+[#4991]: https://github.com/iv-org/invidious/pull/4991
+[#4993]: https://github.com/iv-org/invidious/pull/4993
+[#4995]: https://github.com/iv-org/invidious/pull/4995
+[#5027]: https://github.com/iv-org/invidious/pull/5027
+[#5034]: https://github.com/iv-org/invidious/pull/5034
+[#5045]: https://github.com/iv-org/invidious/pull/5045
+[#5046]: https://github.com/iv-org/invidious/pull/5046
+[#5059]: https://github.com/iv-org/invidious/pull/5059
+[#5060]: https://github.com/iv-org/invidious/pull/5060
+[#5063]: https://github.com/iv-org/invidious/pull/5063
+[#5070]: https://github.com/iv-org/invidious/pull/5070
+[#5071]: https://github.com/iv-org/invidious/pull/5071
+
+
+## v2.20240825.2 (2024-08-26)
+
+This releases fixes the container tags pushed on quay.io.
+Previously, the ARM64 build was released under the `latest` tag, instead of `latest-arm64`.
+
+### Full list of pull requests merged since the last release (newest first)
+
+CI: Fix docker container tags ([#4883], by @SamantazFox)
+
+[#4877]: https://github.com/iv-org/invidious/pull/4877
+
+
+## v2.20240825.1 (2024-08-25)
+
+Add patch component to be [semver] compliant and make github actions happy.
+
+[semver]: https://semver.org/
+
+### Full list of pull requests merged since the last release (newest first)
+
+Allow manual trigger of release-container build ([#4877], thanks @syeopite)
+
+[#4877]: https://github.com/iv-org/invidious/pull/4877
+
+
+## v2.20240825.0 (2024-08-25)
+
+### New features & important changes
+
+#### For users
+
+* The search bar now has a button that you can click!
+* Youtube URLs can be pasted directly in the search bar. Prepend search query with a
+ backslash (`\`) to disable that feature (useful if you need to search for a video whose
+ title contains some youtube URL).
+* On the channel page the "streams" tab can be sorted by either: "newest", "oldest" or "popular"
+* Lots of translations have been updated (thanks to our contributors on Weblate!)
+* Videos embedded in local HTML files (e.g: a webpage saved from a blog) can now be played
+
+#### For instance owners
+
+* Invidious now has the ability to provide a `po_token` and `visitordata` to Youtube in order to
+ circumvent current Youtube restrictions.
+* Invidious can use an (optional) external signature server like [inv_sig_helper]. Please note that
+ some videos can't be played without that signature server.
+* The Helm charts were moved to a separate repo: https://github.com/iv-org/invidious-helm-chart
+* We have changed how containers are released: the `latest` tag now tracks tagged releases, whereas
+ the `master` tag tracks the most recent commits of the `master` branch ("nightly" builds).
+
+[inv_sig_helper]: https://github.com/iv-org/inv_sig_helper
+
+#### For developpers
+
+* The versions of Crystal that we test in CI/CD are now: `1.9.2`, `1.10.1`, `1.11.2`, `1.12.1`.
+ Please note that due to a bug in the `libxml` bindings (See [#4256]), versions prior to `1.10.0`
+ are not recommended to use.
+* Thanks to @syeopite, the code is now [ameba] compliant.
+* Ameba is part of our CI/CD pipeline, and its rules will be enforced in future PRs.
+* The transcript code has been rewritten to permit transcripts as a feature rather than being
+ only a workaround for captions. Trancripts feature is coming soon!
+* Various fixes regarding the logic interacting with Youtube
+* The `sort_by` parameter can be used on the `/api/v1/channels/{id}/streams` endpoint. Accepted
+ values are: "newest", "oldest" and "popular"
+
+[ameba]: https://github.com/crystal-ameba/ameba
+[#4256]: https://github.com/iv-org/invidious/issues/4256
+
+
+### Bugs fixed
+
+#### User-side
+
+* Channels: fixed broken "subscribers" and "views" counters
+* Watch page: playback position is reset at the end of a video, so that the next time this video
+ is watched, it will start from the beginning rather than 15 seconds before the end
+* Watch page: the items in the "add to playlist" drop down are now sorted alphabetically
+* Videos: the "genre" URL is now always pointing to a valid webpage
+* Playlists: Fixed `Could not parse N episodes` error on podcast playlists
+* All external links should now have the [`rel`] attibute set to `noreferrer noopener` for
+ increased privacy.
+* Preferences: Fixed the admin-only "modified source code" input being ignored
+* Watch/channel pages: use the full image URL in `og:image` and `twitter:image` meta tags
+
+[`rel`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel
+
+#### API
+
+* fixed the `local` parameter not applying to `formatStreams` on `/api/v1/videos/{id}`
+* fixed an `Index out of bounds` error hapenning when a playlist had no videos
+* fixed duplicated query parameters in proxied video URLs
+* Return actual video height/width/fps rather than hard coded values
+* Fixed the `/api/v1/popular` endpoint not returning a proper error code/message when the
+ popular page/endpoint are disabled.
+
+
+### Full list of pull requests merged since the last release (newest first)
+
+* HTML: Sort playlists alphabetically in watch page drop down ([#4853], by @SamantazFox)
+* Videos: Fix XSS vulnerability in description/comments ([#4852], thanks _anonymous_)
+* YtAPI: Bump client versions ([#4849], by @SamantazFox)
+* SigHelper: Fix inverted time comparison in 'check_update' ([#4845], by @SamantazFox)
+* Storyboards: Various fixes and code cleaning ([#4153], by SamantazFox)
+* Fix lint errors introduced in #4146 and #4295 ([#4876], thanks @syeopite)
+* Search: Add support for Youtube URLs ([#4146], by @SamantazFox)
+* Channel: Render age restricted channels ([#4295], thanks @ChunkyProgrammer)
+* Ameba: Miscellaneous fixes ([#4807], thanks @syeopite)
+* API: Proxy formatStreams URLs too ([#4859], thanks @colinleroy)
+* UI: Add search button to search bar ([#4706], thanks @thansk)
+* Add ability to set po_token and visitordata ID ([#4789], thanks @unixfox)
+* Add support for an external signature server ([#4772], by @SamantazFox)
+* Ameba: Fix Naming/VariableNames ([#4790], thanks @syeopite)
+* Translations update from Hosted Weblate ([#4659])
+* Ameba: Fix Lint/UselessAssign ([#4795], thanks @syeopite)
+* HTML: Add rel="noreferrer noopener" to external links ([#4667], thanks @ulmemxpoc)
+* Remove unused methods in Invidious::LogHandler ([#4812], thanks @syeopite)
+* Ameba: Fix Lint/NotNilAfterNoBang ([#4796], thanks @syeopite)
+* Ameba: Fix unused argument Lint warnings ([#4805], thanks @syeopite)
+* Ameba: i18next.cr fixes ([#4806], thanks @syeopite)
+* Ameba: Disable rules ([#4792], thanks @syeopite)
+* Channel: parse subscriber count and channel banner ([#4785], thanks @ChunkyProgrammer)
+* Player: Fix playback position of already watched videos ([#4731], thanks @Fijxu)
+* Videos: Fix genre url being unusable ([#4717], thanks @meatball133)
+* API: Fix out of bound error on empty playlists ([#4696], thanks @Fijxu)
+* Handle playlists cataloged as Podcast ([#4695], thanks @Fijxu)
+* API: Fix duplicated query parameters in proxied video URLs ([#4587], thanks @absidue)
+* API: Return actual stream height, width and fps ([#4586], thanks @absidue)
+* Preferences: Fix handling of modified source code URL ([#4437], thanks @nooptek)
+* API: Fix URL for vtt subtitles ([#4221], thanks @karelrooted)
+* Channels: Add sort options to streams ([#4224], thanks @src-tinkerer)
+* API: Fix error code for disabled popular endpoint ([#4296], thanks @iBicha)
+* Allow embedding videos in local HTML files ([#4450], thanks @tomasz1986)
+* CI: Bump Crystal version matrix ([#4654], by @SamantazFox)
+* YtAPI: Remove API keys like official clients ([#4655], by @SamantazFox)
+* HTML: Use full URL in the og:image property ([#4675], thanks @Fijxu)
+* Rewrite transcript logic to be more generic ([#4747], thanks @syeopite)
+* CI: Run Ameba ([#4753], thanks @syeopite)
+* CI: Add release based containers ([#4763], thanks @syeopite)
+* move helm chart to a dedicated github repository ([#4711], thanks @unixfox)
+
+[#4146]: https://github.com/iv-org/invidious/pull/4146
+[#4153]: https://github.com/iv-org/invidious/pull/4153
+[#4221]: https://github.com/iv-org/invidious/pull/4221
+[#4224]: https://github.com/iv-org/invidious/pull/4224
+[#4295]: https://github.com/iv-org/invidious/pull/4295
+[#4296]: https://github.com/iv-org/invidious/pull/4296
+[#4437]: https://github.com/iv-org/invidious/pull/4437
+[#4450]: https://github.com/iv-org/invidious/pull/4450
+[#4586]: https://github.com/iv-org/invidious/pull/4586
+[#4587]: https://github.com/iv-org/invidious/pull/4587
+[#4654]: https://github.com/iv-org/invidious/pull/4654
+[#4655]: https://github.com/iv-org/invidious/pull/4655
+[#4659]: https://github.com/iv-org/invidious/pull/4659
+[#4667]: https://github.com/iv-org/invidious/pull/4667
+[#4675]: https://github.com/iv-org/invidious/pull/4675
+[#4695]: https://github.com/iv-org/invidious/pull/4695
+[#4696]: https://github.com/iv-org/invidious/pull/4696
+[#4706]: https://github.com/iv-org/invidious/pull/4706
+[#4711]: https://github.com/iv-org/invidious/pull/4711
+[#4717]: https://github.com/iv-org/invidious/pull/4717
+[#4731]: https://github.com/iv-org/invidious/pull/4731
+[#4747]: https://github.com/iv-org/invidious/pull/4747
+[#4753]: https://github.com/iv-org/invidious/pull/4753
+[#4763]: https://github.com/iv-org/invidious/pull/4763
+[#4772]: https://github.com/iv-org/invidious/pull/4772
+[#4785]: https://github.com/iv-org/invidious/pull/4785
+[#4789]: https://github.com/iv-org/invidious/pull/4789
+[#4790]: https://github.com/iv-org/invidious/pull/4790
+[#4792]: https://github.com/iv-org/invidious/pull/4792
+[#4795]: https://github.com/iv-org/invidious/pull/4795
+[#4796]: https://github.com/iv-org/invidious/pull/4796
+[#4805]: https://github.com/iv-org/invidious/pull/4805
+[#4806]: https://github.com/iv-org/invidious/pull/4806
+[#4807]: https://github.com/iv-org/invidious/pull/4807
+[#4812]: https://github.com/iv-org/invidious/pull/4812
+[#4845]: https://github.com/iv-org/invidious/pull/4845
+[#4849]: https://github.com/iv-org/invidious/pull/4849
+[#4852]: https://github.com/iv-org/invidious/pull/4852
+[#4853]: https://github.com/iv-org/invidious/pull/4853
+[#4859]: https://github.com/iv-org/invidious/pull/4859
+[#4876]: https://github.com/iv-org/invidious/pull/4876
+
+
+## v2.20240427 (2024-04-27)
+
+Major bug fixes:
+ * Videos: Use android test suite client (#4650, thanks @SamantazFox)
+ * Trending: Un-nest category if this is the only one (#4600, thanks @ChunkyProgrammer)
+ * Comments: Add support for new format (#4576, thanks @ChunkyProgrammer)
+
+Minor bug fixes:
+ * API: Add bitrate to formatStreams too (#4590, thanks @absidue)
+ * API: Add 'authorVerified' field on recommended videos (#4562, thanks @ChunkyProgrammer)
+ * Videos: Add support for new likes format (#4462, thanks @ChunkyProgrammer)
+ * Proxy: Handle non-200 HTTP codes on DASH manifests (#4429, thanks @absidue)
+
+Other improvements:
+ * Remove legacy proxy code (#4570, thanks @syeopite)
+ * API: convey info "is post live" from Youtube response (#4569, thanks @ChunkyProgrammer)
+ * API: Parse channel's tags (#4294, thanks @ChunkyProgrammer)
+ * Translations update from Hosted Weblate (#4164, thanks to our many translators)
diff --git a/CHANGELOG_legacy.md b/CHANGELOG_legacy.md
new file mode 100644
index 00000000..8aa416ec
--- /dev/null
+++ b/CHANGELOG_legacy.md
@@ -0,0 +1,844 @@
+# Note: This is no longer updated and links to omarroths repo, which doesn't exist anymore.
+
+# 0.20.0 (2019-011-06)
+
+# Version 0.20.0: Custom Playlists
+
+It's been quite a while since the last release! There've been [198 commits](https://github.com/omarroth/invidious/compare/0.19.0..0.20.0) from 27 contributors.
+
+A couple smaller features have since been added. Channel pages and playlists in particular have received a bit of a face-lift, with both now displaying their descriptions as expected, and playlists providing video count and published information. Channels will also now provide video descriptions in their RSS feed.
+
+Turkish (tr), Chinese (zh-TW, in addition to zh-CN), and Japanese (jp) are all now supported languages. Thank you as always to the hard work done by translators that makes this possible.
+
+The feed menu and default home page are both now configurable for registered and unregistered users, and is quite a bit of an improvement for users looking to reduce distractions for their daily use.
+
+## For Administrators
+
+`feed_menu` and `default_home` are now configurable by the user, and have therefore been moved into `default_user_preferences`:
+
+```yaml
+feed_menu: ["Popular", "Top"]
+default_home: Top
+
+# becomes:
+
+default_user_preferences:
+ feed_menu: ["Popular", "Top"]
+ default_home: Top
+```
+
+Several new options have also been added, including the ability to set a support email for the instance using `admin_email: EMAIL`, and forcing the use of a specific connection in the case of rate-limiting using `force_resolve` (see below).
+
+## For Developers
+
+Authenticated endpoints are now [properly documented](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints), as well how to generate and use API tokens. My hope is that this makes some of the more [interesting](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authnotifications) endpoints more accessible for developers to use in their own applications.
+
+API endpoints for interacting with custom playlists have also been added with documentation available [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists).
+
+## Custom playlists
+
+This is probably the feature that has been the longest in the pipe and that I'm quite pleased is now implemented. It is now possible to create custom playlists, which can be played and edited through Invidious. API endpoints have also been added (documentation [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists)).
+
+Overall I'm quite pleased with how smoothly it has been rolled out and with the experience so far, and I'm exctited for how it can be extended and improved in future.
+
+## [instances.invidio.us](https://instances.invidio.us)
+
+It is now possible to view a list of public instances (as provided in the [wiki](https://github.com/omarroth/invidious/wiki/Invidious-Instances)) through an API or a pretty new interface [here](https://instances.invidio.us). It combines uptime information, statistics from each instance and basic information already provided in the wiki. I expect it should be much more user-friendly than compiling the information yourself, and is already used by [Invidition](https://codeberg.org/Booteille/Invidition) to provide a list of instances for users to choose from.
+
+The site itself is licensed under the AGPLv3 and the source is available [here](https://github.com/omarroth/instances.invidio.us).
+
+## Video unavailable [#811](https://github.com/omarroth/invidious/issues/811)
+
+Many users have likely noticed this error message if using Invidious directly or through another service, such as FreeTube. This issue is caused by rate-limiting by Google, and is not a new issuee for projects like Invidious (notably [youtube-dl](https://github.com/ytdl-org/youtube-dl#http-error-429-too-many-requests-or-402-payment-required)) and appears to be affecting smaller, private instances as well.
+
+There is not a permanent fix for administrators currently, however there is some information available [here](https://github.com/omarroth/invidious/issues/811#issuecomment-540017772) that may provide a temporary solution. Unfortanately, in most cases the best option is to wait for the instance to be unbanned or to move the instance to a different IP. A more informative error message is also now provided, which should help an administrator more quickly diagnose the problem.
+
+For those interested, I would recommend following [#811](https://github.com/omarroth/invidious/issues/811) for any future progress on the issue.
+
+## BAT verified publisher
+
+I'm quite late to this announcement, however I'm pleased to mention that Invidious is now a BAT verified publisher! I would recommend looking [here](https://basicattentiontoken.org/about/) or [here](https://www.reddit.com/r/BATProject/comments/7cr7yc/new_to_bat_read_this_introduction_to_basic/) for learning more about what it is and how it works. Overall I think it makes an interesting substitute for services like Liberapay, and a (hopefully) much less-intrusive alternative to direct advertising.
+
+BAT is combined under other cryptocurrencies below. Currently there's a fairly significant delay in payout, which is the reason for the large fluctuation in crypto donations between September and October (and also the reason for the late announcement).
+
+## Release schedule
+
+Currently I'm quite pleased with the current state of the project. There's plenty of things I'd still like to add, however at this point I expect the rate of most new additions will slow down a bit, with more focus on stabililty and any long-standing bugs.
+
+Because of this, I'm planning on releasing a new version quarterly, with any necessary hotfixes being pushed as a new patch release as necessary. As always it will be possible to run Invidious directly from [master](https://github.com/omarroth/invidious/wiki/Updating) if you'd still like to have the lastest version.
+
+I'll plan on providing finances each release, with a similar monthly breakdown as below.
+
+## Finances for September 2019
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$64.37
+- [Liberapay](https://liberapay.com/omarroth) : \$76.04
+- Crypto : ~\$99.89 (converted from BAT, BCH, BTC)
+- Total : \$240.30
+
+### Expenses
+
+- invidious-lb1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$135.00
+
+## Finances for October 2019
+
+- [Liberapay](https://liberapay.com/omarroth) : \$134.40
+- Crypto : ~\$8.29 (converted from BAT, BCH, BTC)
+- Total : \$142.69
+
+### Expenses
+
+- invidious-lb1 (nyc1) : \$5.00 (load balancer)
+- invidious-lb2 (nyc1) : \$5.00 (load balancer)
+- invidious-lb3 (nyc1) : \$5.00 (load balancer)
+- invidious-lb4 (nyc1) : \$5.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node17 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node18 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$155.00
+
+# 0.19.0 (2019-07-13)
+
+# Version 0.19.0: Communities
+
+Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
+
+This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
+
+I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
+
+This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
+
+Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
+
+## Communities
+
+Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
+
+It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
+
+## For Developers
+
+For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
+
+An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
+
+An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
+
+## For Administrators
+
+It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$38.39
+- [Liberapay](https://liberapay.com/omarroth) : \$84.85
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$123.24
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$105.00
+
+The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
+
+As always I'm grateful for everyone's support and feedback. I'll see you all next month.
+
+# 0.18.0 (2019-06-06)
+
+# Version 0.18.0: Native Notifications and Optimizations
+
+Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
+
+I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
+
+Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
+
+Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
+
+This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
+
+## For Developers
+
+`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
+
+An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
+
+A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
+
+## For Administrators
+
+There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
+
+As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
+
+## Native Notifications
+
+[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
+
+It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
+
+You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
+
+Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
+
+Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.73
+- [Liberapay](https://liberapay.com/omarroth) : \$100.57
+- Crypto : ~\$11.12 (converted from BCH, BTC)
+- Total : \$161.42
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$85.00
+
+See you all next month!
+
+# 0.17.0 (2019-05-06)
+
+# Version 0.17.0: Player and Authentication API
+
+Hello everyone! This past month there have been [130 commits](https://github.com/omarroth/invidious/compare/0.16.0..0.17.0) from 11 contributors. Large focus has been on improving the player as well as adding API access for other projects to make use of Invidious.
+
+There have also been significant changes in preparation of native notifications (see [#195](https://github.com/omarroth/invidious/issues/195), [#469](https://github.com/omarroth/invidious/issues/469), [#473](https://github.com/omarroth/invidious/issues/473), and [#502](https://github.com/omarroth/invidious/issues/502)), and playlists. I expect to see both of these to be added in the next release.
+
+I'm quite happy to mention that new translations have been added for Esperanto (`eo`) and Ukranian (`uk`). Support for pluralization has also been added, so it should now be possible to make a more native experience for speakers in other languages. The system currently in place is a bit cumbersome, so for any help using this feature please get in touch!
+
+## For Administrators
+
+A `check_tables` option has been added to automatically migrate without the use of custom scripts. This method will likely prove to be much more robust, and is currently enabled for the official instance. To prevent any unintended changes to the DB, `check_tables` is disabled by default and will print commands before executing. Having this makes features that require schema changes much easier to implement, and also makes it easier to upgrade from older instances.
+
+As part of [#303](https://github.com/omarroth/invidious/issues/303), a `cache_annotations` option has been added to speed up access from `/api/v1/annotations/:id`. This vastly improves the experience for videos with annotations. Currently, only videos that contain legacy annotations will be cached, which should help keep down the size of the cache. `cache_annotations` is disabled by default.
+
+## For Developers
+
+An authorization API has been added which allows other applications to read and modify user subscriptions and preferences (see [#473](https://github.com/omarroth/invidious/issues/473)). Support for accessing user feeds and notifications is also planned. I believe this feature is a large step forward in supporting syncing subscriptions and preferences with other services, and I'm excited to see what other developers do with this functionality.
+
+Support for server-to-client push notifications is currently underway. This allows Invidious users, as well as applications using the Invidious API, to receive notifications about uploads in near real-time (see #469). An `/api/v1/auth/notifications` endpoint is currently available. I'm very excited for this to be integrated into the site, and to see how other developers use it in their own projects.
+
+An `/api/v1/storyboards/:id` endpoint has been added for accessing storyboard URLs, which allows developers to add video previews to their players (see below).
+
+## Player
+
+Support for annotations has been merged into master with [#303](https://github.com/omarroth/invidious/issues/303), thanks @glmdgrielson! Annotations can be enabled by default or only for subscribed channels, and can also be toggled per video. I'm extremely proud of the progress made here, and I'm so thankful to everyone that has made this possible. I expect this to be the last update with regards to supporting annotations, but I do plan on continuing to improve the experience as much as possible.
+
+The Invidious player now supports video previews and a corresponding API endpoint `/api/v1/storyboards/:id` has been added for developers looking to add similar functionality to their own players. Not much else to say here. Overall it's a very nice quality of life improvement and an attractive addition to the site.
+
+It is now possible to select specific sources for videos provided using DASH (see [#34](https://github.com/omarroth/invidious/issues/34)). I would consider support largely feature complete, although there are still several issues to be fixed before I would consider it ready for larger rollout. You can watch videos in 1080p by setting `Default quality` to `dash` in your preferences, or by adding `&quality=dash` to the end of video URLs.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.73
+- [Liberapay](https://liberapay.com/omarroth) : \$63.03
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$112.76
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$80.00
+
+That's all for now. Thanks!
+
+# 0.16.0 (2019-04-06)
+
+# Version 0.16.0: API Improvements and Annotations
+
+Hello again! This past month has seen [116 commits](https://github.com/omarroth/invidious/compare/0.15.0..0.16.0) from 13 contributors and a couple important changes I'd like to announce.
+
+A privacy policy is now available [here](https://invidio.us/privacy). I've done my best to explain things as clearly as possible without oversimplifying, and would very much recommend reading it if you're concerned about your privacy and want to learn more about how Invidious uses your data. Please let me know if there is anything that needs clarification.
+
+I'm also very happy to announce that a Spanish translation has been added to the site. You can use it with `?hl=es` or by setting `es` as your default locale. As always I'm extremely grateful to translators for making the site accessible to more people.
+
+## For Administrators
+
+Invidious now supports server-to-server [push notifications](https://developers.google.com/youtube/v3/guides/push_notifications). This uses [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html) to automatically handle new videos sent to an instance, which is less resource intensive and generally faster. Note that it will not pull all videos from a subscribed channel, so recommended usage is in addition to `channel_threads`. Using PubSub requires a valid `domain` that updates can be sent to, and a random string that can be used to sign updates sent to the instance. You can enable it by adding `use_pubsub_feeds: true` to your `config.yml`. See [Configuration](https://github.com/omarroth/invidious/wiki/Configuration) for more info.
+
+Unfortunately there are a couple necessary changes to the DB to support `liveNow` and `premiereTimestamp` in subscription feeds. Migration scripts have been provided that should be used automatically if following the instructions [here](https://github.com/omarroth/invidious/wiki/Updating).
+
+You can now configure default user preferences for your instance. This allows you to set default locale, player preferences, and more. See [#415](https://github.com/omarroth/invidious/issues/415) for more details and example usage.
+
+## For Developers
+
+The [fields](https://developers.google.com/youtube/v3/getting-started#fields) API has been added with [#429](https://github.com/omarroth/invidious/pull/429) and is now supported on all JSON endpoints, thanks [**@afrmtbl**](https://github.com/afrmtbl)! Synax is straight-forward and can be used to reduce data transfer and create a simpler response for debugging. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1&fields=title,recommendedVideos/title). I've been quite happy using it and hope it is similarly useful for others.
+
+An `/api/v1/annotations/:id` endpoint has been added for pulling legacy annotation data from [this](https://archive.org/details/youtubeannotations) archive, see below for more details. You can also access annotation data available on YouTube using `?source=youtube`, although this will only return card data as legacy annotations were deleted on January 15th.
+
+A couple minor changes to existing endpoints:
+
+- A `premiereTimestamp` field has been added to `/api/v1/videos/:id`
+- A `sort_by` param has been added to `/api/v1/comments/:id`, supports `new`, `top`.
+
+More info is available in the [documentation](https://github.com/omarroth/invidious/wiki/API).
+
+## Annotations
+
+I'm pleased to announce that annotation data is finally available from the roughly 1.4 billion videos archived as part of [this](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/) project. They are accessible from the Internet Archive [here](https://archive.org/details/youtubeannotations) or as a 355GB torrent, see [here](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. A corresponding `/api/v1/annotations/:id` endpoint has been added to Invidious which uses the collection from IA to provide legacy annotations.
+
+Support for them in the player is possible thanks to [this](https://github.com/afrmtbl/videojs-youtube-annotations) plugin developed by [**@afrmtbl**](https://github.com/afrmtbl). A PR for adding support to the site is available as [#303](https://github.com/omarroth/invidious/pull/303). There's also an [extension](https://github.com/afrmtbl/AnnotationsRestored) for overlaying them on top of the YouTube player (again thanks to [**@afrmtbl**](https://github.com/afrmtbl)), and an [extension](https://tech234a.bitbucket.io/AnnotationsReloaded?src=invidious) for hooking into code still present in the YouTube player itself, developed by [**@tech234a**](https://github.com/tech234a).
+
+I would recommend reading the [official announcement](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. I would like to again thank everyone that helped contribute to this project.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$42.42
+- [Liberapay](https://liberapay.com/omarroth) : \$70.11
+- Crypto : ~\$1.76 (converted from BCH, BTC, BSV)
+- Total : \$114.29
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$80.00
+
+This past month the site saw a couple abnormal peaks in traffic, so an additional webserver has been added to match the increased load. The goal on Patreon has been updated to match the above expenses.
+
+Thanks everyone!
+
+# 0.15.0 (2019-03-06)
+
+## Version 0.15.0: Preferences and Channel Playlists
+
+The project has seen quite a bit of activity this past month. Large focus has been on fixing bugs, but there's still quite a few new features I'm happy to announce. There have been [133 commits](https://github.com/omarroth/invidious/compare/0.14.0...0.15.0) from 15 contributors this past month.
+
+As a couple miscellaneous changes, a couple [nice screenshots](https://github.com/omarroth/invidious#screenshots) have been added to the README, so folks can see more of what the site has to offer without creating an account.
+
+The footer has also been cleaned up quite a bit, and now displays the current version, so it's easier to know what features are available from the current instance.
+
+## For Administrators
+
+This past month there has been a minor release - `0.14.1` - which fixes a breaking change made by YouTube for their polymer redesign.
+
+There have been several new features that unfortunately require a database migration. There are migration scripts provided in `config/migrate-scripts`, and the [wiki](https://github.com/omarroth/invidious/wiki/Updating) has instructions for automatically applying them. I'll do my best to keep those changes to a minimum, and expect to see a corresponding script to automatically apply any new changes.
+
+Administrator preferences have been added with [#312](https://github.com/omarroth/invidious/issues/312), which allows administrators to customize their instance. Administrators can change the order of feed menus, change the default homepage, disable open registration, and several other options. There's a short 'how-to' [here](https://github.com/omarroth/invidious/issues/312#issuecomment-468831842), and the new options are documented [here](https://github.com/omarroth/invidious/wiki/Configuration).
+
+An `/api/v1/stats` endpoint has been added with [#356](https://github.com/omarroth/invidious/issues/356), which reports the instance version and number of active users. Statistics are disabled by default, and can be enabled in administator preferences. Statistics for the official instance are available [here](https://invidio.us/api/v1/stats?pretty=1).
+
+## For Developers
+
+`/api/v1/channels/:ucid` now provides an `autoGenerated` tag, which returns true for topic channels, and larger genre channels generated by YouTube. These channels don't have any videos of their own, so `latestVideos` will be empty. It is recommended instead to display a list of playlists generated by YouTube.
+
+You can now pull a list of playlists from a channel with `/api/v1/channels/playlists/:ucid`. Supported options are documented in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelsplaylistsucid-apiv1channelsucidplaylists). Pagination is handled with a `continuation` token, which is generated on each call. Of note is that auto-generated channels currently have one page of results, and subsequent calls will be empty.
+
+For quickly pulling the latest 30 videos from a channel, there is now `/api/v1/channels/latest/:ucid`. It is much faster than a call to `/api/v1/channels/:ucid`. It will not convert an author name to a valid ucid automatically, and will not return any extra data about a channel.
+
+## Preferences
+
+In addition to administrator preferences mentioned above, you can now change your preferences without an account (see [#42](https://github.com/omarroth/invidious/pull/42)). I think this is quite an improvement to the usability of the site, and is much friendlier to privacy-conscious folks that don't want to make an account. Preferences will be automatically imported to a newly created account.
+
+Several issues with sorting subscriptions have been fixed, and `/manage_subscriptions` has been sped up significantly. The subscription feed has also seen a bump in performance. Delayed notifications have unfortunately started becoming a problem now that there are more users on the site. Some new changes are currently being tested which should mostly resolve the issue, so expect to see more in the next release.
+
+## Channel Playlists
+
+You can now view available playlists from a channel, and [auto-generated channels](https://invidio.us/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) are no longer empty. You can sort as you would on YouTube, and all the same functionality should be available. I'm quite pleased to finally have it implemented, since it's currently the only data available from the above mentioned auto-generated channels, and makes it much easier to consume music on the site.
+
+There's also more discussion on improving Invidious for streaming music in [#304](https://github.com/omarroth/invidious/issues/304), and adding support for music.youtube.com. I would appreciate any thoughts on how to improve that experience, since it's a very large and useful part of YouTube.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$42.42
+- [Liberapay](https://liberapay.com/omarroth) : \$30.97
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$73.39
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+It's been very humbling to see how fast the project has grown, and I look forward to making the site even better. Thank you everyone.
+
+# 0.14.0 (2019-02-06)
+
+## Version 0.14.0: Community
+
+This last month several contributors have made improvements specifically for the people using this project. New pages have been added to the wiki, and there is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) and IRC channel so it's easier and faster for people to ask questions or chat. There have been [101 commits](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) since the last major release from 8 contributors.
+
+It has come to my attention in the past month how many people are self-hosting, and I would like to make it easier for them to do so.
+
+With that in mind, expect future releases to have a section for For Administrators (if any relevant changes) and For Developers (if any relevant changes).
+
+## For Administrators
+
+This month the most notable change for administrators is releases. As always, there will be a major release each month. However, a new minor release will be made whenever there are any critical bugs that need to be fixed.
+
+This past month is the first time there has been a minor release - `0.13.1` - which fixes a breaking change made by YouTube. Administrators using versioning for their instances will be able to rely on the latest version, and should have a system in place to upgrade their instance as soon as a new release is available.
+
+Several new pages have been added to the [wiki](https://github.com/omarroth/invidious/wiki#for-administrators) (as mentioned below) that will help administrators better setup their own instances. Configuration, maintenance, and instructions for updating are of note, as well as several common issues that are encountered when first setting up.
+
+## For Developers
+
+There's now a `pretty=1` parameter for most endpoints so you can view data easily from the browser, which is convenient for debugging and casual use. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1).
+
+Unfortunately the `/api/v1/insights/:id` endpoint is no longer functional, as YouTube removed all publicly available analytics around a month ago. The YouTube endpoint now returns a 404, so it's unlikely it will be functional again.
+
+## Wiki
+
+There have been a sizable number of changes to the Wiki, including a [list of public Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances), the [list of extensions](https://github.com/omarroth/invidious/wiki/Extensions), and documentation for administrators (as mentioned above) and developers.
+
+The wiki is editable by anyone so feel free to add anything you think is useful.
+
+## Matrix & IRC
+
+Thee is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) for Invidious, so please feel free to hop on if you have any questions or want to chat. There is also a registered IRC channel: #invidious on Freenode which is bridged to Matrix.
+
+## Features
+
+Several new features have been added, including a download button, creator hearts and comment colors, and a French translation.
+
+There have been fixes for Google logins, missing text in locales, invalid links to genre channels, and better error handling in the player, among others.
+
+Several fixes and features are omitted for space, so I'd recommend taking a look at the [compare tab](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) for more information.
+
+## Annotations Update
+
+Annotations were removed January 15th, 2019 around15:00 UTC. Before they were deleted we were able to archive annotations from around 1.4 billion videos. I'd very much recommend taking a look [here](https://www.reddit.com/r/DataHoarder/comments/al7exa/youtube_annotation_archive_update_and_preview/) for more information and a list of acknowledgements. I'm extremely thankful to everyone who was able to contribute and I'm glad we were able to save such a large part of internet history.
+
+There's been large strides in supporting them in the player as well, which you can follow in [#303](https://github.com/omarroth/invidious/pull/303). You can preview the functionality at https://dev.invidio.us . Before they are added to the main site expect to see an option to disable them, both site-wide and per video.
+
+Organizing this project has unfortunately taken up quite a bit of my time, and I've been very grateful for everyone's patience.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.42
+- [Liberapay](https://liberapay.com/omarroth) : \$27.89
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$77.31
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+As always I'm grateful for everyone's contributions and support. I'll see you all in March.
+
+# 0.13.1 (2019-01-19)
+
+##
+
+# 0.13.0 (2019-01-06)
+
+## Version 0.13.0: Translations, Annotations, and Tor
+
+I hope everyone had a happy New Year! There's been a couple new additions since last release, with [44 commits](https://github.com/omarroth/invidious/compare/0.12.0...0.13.0) from 9 contributors. It's been quite a year for the project, and I hope to continue improving the project into 2019! Starting off the new year:
+
+## Translations
+
+I'm happy to announce support for translations has been added with [`a160c64`](https://github.com/omarroth/invidious/a160c64). Currently, there is support for:
+
+- Arabic (`ar`)
+- Dutch (`nl`)
+- English (`en-US`)
+- German (`de`)
+- Norwegian Bokmål (`nb_NO`)
+- Polish (`pl`)
+- Russian (`ru`)
+
+Which you can change in your preferences under `Language`. You can also add `&hl=LANGUAGE` to the end of any request to translate it to your preferred language, for example https://invidio.us/?hl=ru. I'd like to say thank you again to everyone who has helped translate the site! I've mentioned this before, but I'm delighted that so many people find the project useful.
+
+## Annotations
+
+Recently, [YouTube announced that all annotations will be deleted on January 15th, 2019](https://support.google.com/youtube/answer/7342737). I believe that annotations have a very important place in YouTube's history, and [announced a project to archive them](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/).
+
+I expect annotations to be supported in the Invidious player once archiving is complete (see [#110](https://github.com/omarroth/invidious/issues/110) for details), and would also like to host them for other developers to use in their projects.
+
+The code is available [here](https://github.com/omarroth/archive), and contains instructions for running a worker if you would like to contribute. There's much more information available in the announcement as well for anyone who is interested.
+
+## Tor
+
+I unfortunately missed the chance to mention this in the previous release, but I'm now happy to announce that you can now view Invidious through Tor at the following links:
+
+kgg2m7yk5aybusll.onion
+axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
+
+Invidious is well suited to use through Tor, as it does not require any JS and is fairly lightweight. I'd recommend looking [here](https://diasp.org/posts/10965196) and [here](https://www.reddit.com/r/TOR/comments/a3c1ak/you_can_now_watch_youtube_videos_anonymously_with/) for more details on how to use the onion links, and would like to say thank you to [/u/whonix-os](https://www.reddit.com/user/whonix-os) for suggesting it and providing support setting setting them up.
+
+## Popular and Trending
+
+You can now easily view videos trending on YouTube with [`a16f967`](https://github.com/omarroth/invidious/a16f967). It also provides support for viewing YouTube's various categories categories, such as `News`, `Gaming`, and `Music`. You can also change the `region` parameter to view trending in different countries, which should be made easier to use in the coming weeks.
+
+A link to `/feed/popular` has also been added, which provides a list of videos sorted using the algorithm described [here](https://github.com/omarroth/invidious/issues/217#issuecomment-436503761). I think it better reflects what users watch on the site, but I'd like to hear peoples' thoughts on this and on how it could be improved.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth): \$64.63
+- [Liberapay](https://liberapay.com/omarroth) : \$30.05
+- Crypto : ~\$28.74 (converted from BCH, BTC)
+- Total : \$123.42
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+### What will happen with what's left over?
+
+I believe this is the first month that all expenses have been fully paid for by donations. Thank you! I expect to allocate the current amount for hardware to improve performance and for hosting annotation data, as mentioned above.
+
+Anything that is left over is kept to continue hosting the project for as long as possible. Thank you again everyone!
+
+I think that's everything for 2018. There's lots still planned, and I'm very excited for the future of this project!
+
+# 0.12.0 (2018-12-06)
+
+## Version 0.12.0: Accessibility, Privacy, Transparency
+
+Hello again, it's been a while! A lot has happened since the last release. Invidious has seen [134 commits](https://github.com/omarroth/invidious/compare/0.11.0...0.12.0) from 3 contributors, and I'm quite happy with the progress that has been made. I enjoyed this past month, and I believe having a monthly release schedule allows me to focus on more long-term improvements, and I hope people enjoy these more substantial updates as well.
+
+## Accessability and Privacy
+
+There have been quite a few improvements for user privacy, and improvements that improve accessibility for both people and software.
+
+You can now view comments without JS with [`19516ea`](https://github.com/omarroth/invidious/19516ea). Currently, this functionality is limited to the first 20 comments, but expect this functionality to be improved to come as close to the JS version as possible. Folks can track progress in [#204](https://github.com/omarroth/invidious/issues/204).
+
+Invidious is now compatible with [LibreJS](https://www.gnu.org/software/librejs/), and provides license information [here](https://invidio.us/licenses) with [`7f868ec`](https://github.com/omarroth/invidious/7f868ec). As expected, all libraries are compatible under the AGPLv3, and I'm happy to mention that no other changes were required to make Invidious compatible with LibreJS.
+
+A DNT policy has also been added with [`9194f47`](https://github.com/omarroth/invidious/9194f47) for compatibility with [Privacy Badger](https://www.eff.org/privacybadger). I'm pleased to mention that here too no other changes had to be made in order for Invidious to be compatible with this extension. I expect a privacy policy to be added soon as well, so users can better understand how Invidious uses their data.
+
+For users that are visually impaired, there is now a text CAPTCHA available so it's easier to register and login. Because of the simple front-end of the project, I expect screen readers and other software to be able to easily understand the site's interface. In combination with the ability to listen-only, I believe Invidious is much more accessible than YouTube. Folks can read [#244](https://github.com/omarroth/invidious/issues/244) for more details, and I would very much appreciate any feedback on how this can be improved.
+
+## User Preferences
+
+There have been a lot of improvements to preferences. Options for enabling audio-only by default and continuous playback (autoplay) have been added with [`e39dec9`](https://github.com/omarroth/invidious/e39dec9), with [`4b76b93`](https://github.com/omarroth/invidious/4b76b93), respectively. Users can also now mark videos as watched from their subscription feed and view watch history by going to https://invidio.us/feed/history. I expect to add more information to history so that it's easier to use. Folks can track progress with [#182](https://github.com/omarroth/invidious/issues/182). As with all data Invidious keeps, watch history can be exported [here](https://invidio.us/data_control).
+
+Users can now delete their account with [`b9c29bf`](https://github.com/omarroth/invidious/b9c29bf). This will remove _all_ user data from Invidious, including session IDs, watch history, and subscriptions. As mentioned above, it's easy to export that data and import it to a local instance, or export subscriptions for use with other applications such as [FreeTube](https://github.com/FreeTubeApp/FreeTube) or [NewPipe](https://github.com/TeamNewPipe/NewPipe).
+
+## Translation and Internationalis(z)ation
+
+Invidious has been approved for hosting by Weblate, available [here](https://hosted.weblate.org/projects/invidious/translations/). At the time of writing, translations for Arabic, Dutch, German, Polish, and Russian are currently underway. I would like to say a very big thank you to everyone working on them, and I hope to fully support them within around 2 weeks. Folks can track progress with [#251](https://github.com/omarroth/invidious/issues/251).
+
+## Transperency and Finances
+
+For the sake of transparency, I plan on publishing each month's finances. This is currently already done on Liberapay and Patreon, but there is not a total amount currently provided anywhere, and I would also like to include expenses to provide a better explanation of how patrons' money is being spent.
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth): \$43.60 (Patreon takes roughly 9%)
+- [Liberapay](https://liberapay.com/omarroth) : \$22.10
+- Crypto : ~\$1.25 (converted from BCH, BTC)
+- Total : \$66.95
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+I'd be happy to provide any explanation where needed. I would also like to thank everyone who donates, it really helps and I can't say how happy I am to see that so many people find it valuable.
+
+That's all for this month. I wish everyone the best for the holidays, and I'll see you all again in January!
+
+# 0.11.0 (2018-10-23)
+
+## Week 11: FreeTube and Styling
+
+This past Friday I'm been very excited to see that FreeTube version [0.4.0](https://github.com/FreeTubeApp/FreeTube/tree/0.4.0) has been released! I'd recommend taking a look at the official patch notes, but to spoil a little bit here: FreeTube now uses the Invidious API for _all_ requests previously sent to YouTube, and has also seen support for playlists, keyboard shortcuts, and more default settings (speed, autoplay, and subtitles). I'm happy to see that FreeTube has reached 500 stars on Github, and I think it's very much deserved. I'd recommend keeping an eye on the newly-launched [FreeTube blog](https://freetube.writeas.com/) for updates on the project.
+
+Quite a few styling changes have been added this past week, including channel subscriber count to the subscribe and unsubscribe buttons. The changes sound small, but they've been a very big improvement and I'm quite satisfied with how they look. Also to note is that partial support for duration in thumbnails have been added with [#202](https://github.com/omarroth/invidious/issues/202). Overall, I think the site is becoming much more pleasing visually, and I hope to continue to improve it.
+
+I've been very pleased to see Invidious in its current state, and I believe it's many times more mature compared to even a month ago. Changes have also started slowing down a bit as it's become more mature, and therefore I'd like to transition to a monthly update schedule in order to provide more comprehensive updates for everyone. I want to thank you all for helping me reach this point. I can't say how happy I am for Invidious to be where it is now.
+
+Enjoy the rest of your week everyone, I'll see you in November!
+
+# 0.10.0 (2018-10-16)
+
+## Week 10: Subscriptions
+
+This week I'm happy to announce that subscriptions have been drastically sped up with
+35e63fa. As I mentioned last week, this essentially "caches" a user's feed, meaning that operations that previously took 20 seconds or timed out, now can load in under a second. I'd take a look at [#173](https://github.com/omarroth/invidious/issues/173) for a sample benchmark. Previously features that made Invidious's feed so useful, such as filtering by unseen and by author would take too long to load, and so instead would timeout. I'm very happy that this has been fixed, and folks can get back to using these features.
+
+Among some smaller features that have been added this week include [#118](https://github.com/omarroth/invidious/issues/118), which adds, in my opinion, some very attractive subscribe and unsubscribe buttons. I think it's also a bit of a functional improvement as well, since it doesn't require a user to reload the page in order to subscribe or unsubscribe to a channel, and also gives the opportunity to put the channel's sub count on display.
+
+An option to swap between Reddit and YouTube comments without a page reload has been added with
+5eefab6, bringing it somewhat closer in functionality to the popular [AlienTube](https://github.com/xlexi/alientube) extension, on which it is based (although the extension unfortunately appears now to be fragmented).
+
+As always, there are a couple smaller improvements this week, including some minor fixes for geo-bypass with
+e46e618 and [`245d0b5`](https://github.com/omarroth/invidious/245d0b5), playlist preferences with [`81b4477`](https://github.com/omarroth/invidious/81b4477), and YouTube comments with [`02335f3`](https://github.com/omarroth/invidious/02335f3).
+
+This coming week I'd also recommend keeping an eye on the excellent [FreeTube](https://github.com/FreeTubeApp/FreeTube), which is looking forward to a new release. I've been very lucky to work with [**@PrestonN**](https://github.com/PrestonN) for the past few weeks to improve the Invidious API, and I'm quite looking forward to the new release.
+
+That's all for this week folks, thank you all again for your continued interest and support.
+
+# 0.9.0 (2018-10-08)
+
+## Week 9: Playlists
+
+Not as much to announce this week, but I'm still quite happy to announce a couple things, namely:
+
+Playback support for playlists has finally been added with [`88430a6`](https://github.com/omarroth/invidious/88430a6). You can now view playlists with the `&list=` query param, as you would on YouTube. You can also view mixes with the mentioned `&list=`, although they require some extra handling that I would like to add in the coming week, as well as adding playlist looping and shuffle. I think playback support has been a roadblock for more exciting features such as [#114](https://github.com/omarroth/invidious/issues/114), and I look forward to improving the experience.
+
+Comments have had a bit of a cosmetic upgrade with [#132](https://github.com/omarroth/invidious/issues/132), which I think helps better distinguish between Reddit and YouTube comments, as it makes them appear similarly to their respective sites. You can also now switch between YouTube and Reddit comments with a push of a button, which I think is quite an improvement, especially for newer or less popular videos with fewer comments.
+
+I've had a small breakthrough in speeding up users' subscription feeds with PostgreSQL's [materialized views](https://www.postgresql.org/docs/current/static/rules-materializedviews.html). Without going into too much detail, materialized views essentially cache the result of a query, making it possible to run resource-intensive queries once, rather than every time a user visits their feed. In the coming week I hope to push this out to users, and hopefully close [#173](https://github.com/omarroth/invidious/issues/173).
+
+I haven't had as much time to work on the project this week, but I'm quite happy to have added some new features. Have a great week everyone.
+
+# 0.8.0 (2018-10-02)
+
+## Week 8: Mixes
+
+Hello again!
+
+Mixes have been added with [`20130db`](https://github.com/omarroth/invidious/20130db), which makes it easy to create a playlist of related content. See [#188](https://github.com/omarroth/invidious/issues/188) for more info on how they work. Currently, they return the first 50 videos rather than a continuous feed to avoid tracking by Google/YouTube, which I think is a good trade-off between usability and privacy, and I hope other folks agree. You can create mixes by adding `RD` to the beginning of a video ID, an example is provided [here](https://www.invidio.us/mix?list=RDYE7VzlLtp-4) based on Big Buck Bunny. I've been quite happy with the results returned for the mixes I've tried, and it is not limited to music, which I think is a big plus. To emulate a continuous feed provided many are used to, using the last video of each mix as a new 'seed' has worked well for me. In the coming week I'd like to to add playback support in the player to listen to these easily.
+
+A very big thanks to [**@flourgaz**](https://github.com/flourgaz) for Docker support with [#186](https://github.com/omarroth/invidious/pull/186). This is an enormous improvement in portability for the project, and opens the door for Heroku support (see [#162](https://github.com/omarroth/invidious/issues/162)), and seamless support on Windows. For most users, it should be as easy as running `docker-compose up`.
+
+I've spent quite a bit of time this past week improving support for geo-bypass (see [#92](https://github.com/omarroth/invidious/issues/92)), and am happy to note that Invidious has been able to proxy ~50% of the geo-restricted videos I've tried. In addition, you can now watch geo-restricted videos if you have `dash` enabled as your `preferred quality`, for more details see [#34](https://github.com/omarroth/invidious/issues/34) and [#185](https://github.com/omarroth/invidious/issues/185), or last week's update. For folks interested in replicating these results for themselves, I'd take a look [here](https://gist.github.com/omarroth/3ce0f276c43e0c4b13e7d9cd35524688) for the script used, and [here](https://gist.github.com/omarroth/beffc4a76a7b82a422e1b36a571878ef) for a list of videos restricted in the US.
+
+1080p has seen a fairly smooth roll-out, although there have been a couple issues reported, mainly [#193](https://github.com/omarroth/invidious/issues/193), which is likely an issue in the player. I've also encountered a couple other issues myself that I would like to investigate. Although none are major, I'd like to keep 1080p opt-in for registered users another week to better address these issues.
+
+Have an excellent week everyone.
+
+# 0.7.0 (2018-09-25)
+
+## Week 7: 1080p and Search Types
+
+Hello again everyone! I've got quite a couple announcements this week:
+
+Experimental 1080p support has been added with [`b3ca392`](https://github.com/omarroth/invidious/b3ca392), and can be enabled by going to preferences and changing `preferred video quality` to `dash`. You can find more details [here](https://github.com/omarroth/invidious/issues/34#issuecomment-424171888). Currently quality and speed controls have not yet been integrated into the player, but I'd still appreciate feedback, mainly on any issues with buffering or DASH playback. I hope to integrate 1080p support into the player and push support site-wide in the coming weeks.
+
+You can now filter content types in search with the `type:TYPE` filter. Supported content types are `playlist`, `channel`, and `video`. More info is available [here](https://github.com/omarroth/invidious/issues/126#issuecomment-423823148). I think this is quite an improvement in usability and I hope others find the same.
+
+A [CHANGELOG](https://github.com/omarroth/invidious/blob/master/CHANGELOG.md) has been added to the repository, so folks will now receive a copy of all these updates when cloning. I think this is an improvement in hosting the project, as it is no longer tied to the `/releases` tab on Github or the posts on Patreon.
+
+Recently, users have been reporting 504s when attempting to access their subscriptions, which is tracked in [#173](https://github.com/omarroth/invidious/issues/173). This is most likely caused by an uptick in usage, which I am absolutely grateful for, but unfortunately has resulted in an increase in costs for hosting the site, which is why I will be bumping my goal on Patreon from $60 to $80. I would appreciate any feedback on how subscriptions could be improved.
+
+Other minor improvements include:
+
+- Additional regions added to bypass geo-block with [`9a78523`](https://github.com/omarroth/invidious/9a78523)
+- Fix for playlists containing less than 100 videos (previously shown as empty) with [`35ac887`](https://github.com/omarroth/invidious/35ac887)
+- Fix for `published` date for Reddit comments (previously showing negative seconds) with [`6e09202`](https://github.com/omarroth/invidious/6e09202)
+
+Thank you everyone for your support!
+
+# 0.6.0 (2018-09-18)
+
+## Week 6: Filters and Thumbnails
+
+Hello again! This week I'm happy to mention a couple new features to search as well as some miscellaneous usability improvements.
+
+You can now constrain your search query to a specific channel with the `channel:CHANNEL` filter (see [#165](https://github.com/omarroth/invidious/issues/165) for more details). Unfortunately, other search filters combined with channel search are not yet supported. I hope to add support for them in the coming weeks.
+
+You can also now search only your subscriptions by adding `subscriptions:true` to your query (see [#30](https://github.com/omarroth/invidious/issues/30) for more details). It's not quite ready for widespread use but I would appreciate feedback as the site updates to fully support it. Other search filters are not yet supported with `subscriptions:true`, but I hope to add more functionality to this as well.
+
+With [#153](https://github.com/omarroth/invidious/issues/153) and [#168](https://github.com/omarroth/invidious/issues/168) all images on the site are now proxied through Invidious. In addition to offering the user more protection from Google's eyes, it also allows the site to automatically pick out the highest resolution thumbnail for videos. I think this is quite a large aesthetic improvement and I hope others will find the same.
+
+As a smaller improvement to the site, you can also now view RSS feeds for playlists with [#113](https://github.com/omarroth/invidious/issues/113).
+
+These updates are also now listed under Github's [releases](https://github.com/omarroth/invidious/releases). I'm also planning on adding them as a `CHANGELOG.md` in the repository itself so people can receive a copy with the project's source.
+
+That's all for this week. Thank you everyone for your support!
+
+# 0.5.0 (2018-09-11)
+
+## Week 5: Privacy and Security
+
+I hope everyone had a good weekend! This past week I've been fixing some issues that have been brought to my attention to help better protect users and help them keep their anonymity.
+
+An issue with open referers has been fixed with [`29a2186`](https://github.com/omarroth/invidious/29a2186), which prevents potential redirects to external sites on actions such as login or modifying preferences.
+
+Additionally, X-XSS-Protection, X-Content-Type-Options, and X-Frame-Options headers have been added with [`96234e5`](https://github.com/omarroth/invidious/96234e5), which should keep users safer while using the site.
+
+A potential XSS vector has also been fixed in YouTube comments with [`8c45694`](https://github.com/omarroth/invidious/8c45694).
+
+All the above vulnerabilities were brought to my attention by someone who wishes to remain anonymous, but I would like to say again here how thankful I am. If anyone else would like to get in touch please feel free to email me at omarroth@hotmail.com or omarroth@protonmail.com.
+
+This week a couple changes have been made to better protect user's privacy as well.
+All CSS and JS assets are now served locally with [`3ec684a`](https://github.com/omarroth/invidious/3ec684a), which means users no longer need to whitelist unpkg.com. Although I personally have encountered few issues, I understand that many folks would like to keep their browsing activity contained to as few parties as possible. In the coming week I also hope to proxy YouTube images, so that no user data is sent to Google.
+
+YouTube links in comments now should redirect properly to the Invidious alternate with [`1c8bd67`](https://github.com/omarroth/invidious/1c8bd67) and [`cf63c82`](https://github.com/omarroth/invidious/cf63c82), so users can more easily evade Google tracking.
+
+I'm also happy to mention a couple quality of life features this week:
+
+Invidious now shows a video's "license" if provided, see [#159](https://github.com/omarroth/invidious/issues/159) for more details. You can also search for videos licensed under the creative commons with "QUERY features:creative_commons".
+
+Videos with only one source will always display the cog for changing quality, so that users can see what quality is currently playing. See [#158](https://github.com/omarroth/invidious/issues/158) for more details.
+
+Folks have also probably noticed that the gutters on either side of the screen have been shrunk down quite significantly, so that more of the screen is filled with content. Hopefully this can be improved even more in the coming weeks.
+
+"Music", "Sports", and "Popular on YouTube" channels now properly display their videos. You can subscribe to these channels just as you would normally.
+
+This coming week I'm planning on spending time with my family, so I unfortunately may not be as responsive. I do still hope to add some smaller features for next week however, and I hope to continue development soon.
+Thank you everyone again for your support.
+
+# 0.4.0 (2018-09-06)
+
+## Week 4: Genre Channels
+
+Hello! I hope everyone enjoyed their weekend. Without further ado:
+Just today genre channels have been added with [#119](https://github.com/omarroth/invidious/issues/119). More information on genre channels is available [here](https://support.google.com/youtube/answer/2579942). You can subscribe to them as normally, and view them as RSS. I think they offer an interesting alternative way to find new content and I hope people find them useful.
+
+This past week folks have started reporting 504s on their subscription page (see [#144](https://github.com/omarroth/invidious/issues/144) for more details). Upgrading the database server appeared to fix the issue, as well as providing a smoother experience across the site. Unfortunately, that means I will be increasing the goal from $50 to $60 in order to meet the increased hosting costs.
+
+With [#134](https://github.com/omarroth/invidious/issues/134), comments are now formatted correctly, providing support for bold, italics, and links in comments. I think this improvement makes them much easier to read, and I hope others find the same. Also to note is that links in both comments and the video description now no longer contain any of Google's tracking with [#115](https://github.com/omarroth/invidious/issues/115).
+
+One of the major use cases for Invidious is as a stripped-down version of YouTube. In line with that, I'm happy to announce that you can now hide related videos if you're logged in, for users that prefer an even more lightweight experience.
+
+Finally, I'm pleased to announce that Invidious has hit 100 stars on GitHub. I am very happy that Invidious has proven to be useful to so many people, and I can't say how grateful I am to everyone for their continued support.
+
+Enjoy the rest of your week everyone!
+
+# 0.3.0 (2018-09-06)
+
+## Week 3: Quality of Life
+
+Hello everyone! This week I've been working on some smaller features that will hopefully make the site more functional.
+Search filters have been added with [#126](https://github.com/omarroth/invidious/issues/126). You can now specify 'sort', 'date', 'duration', and 'features' within your query using the 'operator:value' syntax. I'd recommend taking a look [here](https://github.com/omarroth/invidious/blob/master/src/invidious/search.cr#L33-L114) for a list of supported options and at [#126](https://github.com/omarroth/invidious/issues/126) for some examples. This also opens the door for features such as [#30](https://github.com/omarroth/invidious/issues/30) which can be implemented as filters. I think advanced search is a major point in which Invidious can improve on YouTube and hope to add more features soon!
+
+This week a more advanced system for viewing fallback comments has been added (see [#84](https://github.com/omarroth/invidious/issues/84) for more details). You can now specify a comment fallback in your preferences, which Invidious will use. If, for example, no Reddit comments are available for a given video, it can choose to fallback on YouTube comments. This also makes it possible to turn comments off completely for users that prefer a more streamlined experience.
+
+With [#98](https://github.com/omarroth/invidious/issues/98), it is now possible for users to specify preferences without creating an account. You can now change speed, volume, subtitles, autoplay, loop, and quality using query parameters. See the issue above for more details and several examples.
+
+I'd also like to announce that I've set up an account on [Liberapay](https://liberapay.com/omarroth), for patrons that prefer a privacy-friendly alternative to Patreon. Liberapay also does not take any percentage of donations, so I'd recommend donating some to the Liberapay for their hard work. Go check it out!
+
+[Two weeks ago](https://github.com/omarroth/invidious/releases/tag/0.1.0) I mentioned adding 1080p support into the player. Currently, the only thing blocking is [#207](https://github.com/videojs/http-streaming/pull/207) in the excellent [http-streaming](https://github.com/videojs/http-streaming) library. I hope to work with the videojs team to merge it soon and finally implement 1080p support!
+
+That's all for this week, thank you again everyone for your support!
+
+# 0.2.0 (2018-09-06)
+
+## Week 2: Toward Playlists
+
+Sorry for the late update! Not as much to announce this week, but still a couple things of note:
+I'm happy to announce that a playlists page and API endpoint has been added so you can now view playlists. Currently, you cannot watch playlists through the player, but I hope to add that in the coming week as well as adding functionality to add and modify playlists. There is a good conversation on [#114](https://github.com/omarroth/invidious/issues/114) about giving playlists even more functionality, which I think is interesting and would appreciate feedback on.
+
+As an update to the Invidious API announcement last week, I've been working with [**@PrestonN**](https://github.com/PrestonN), the developer of [FreeTube](https://github.com/FreeTubeApp/FreeTube), to help migrate his project to the Invidious API. Because of it's increasing popularity, he has had trouble keeping under the quota set by YouTube's API. I hope to improve the API to meet his and others needs and I'd recommend folks to keep an eye on his excellent project! There is a good discussion with his thoughts [here](https://github.com/FreeTubeApp/FreeTube/issues/100).
+
+A couple of miscellaneous features and bugfixes:
+
+- You can now login to Invidious simultaneously from multiple devices - [#109](https://github.com/omarroth/invidious/issues/109)
+
+- Added a note for scheduled livestreams - [#124](https://github.com/omarroth/invidious/issues/124)
+
+- Changed YouTube comment header to "View x comments" - [#120](https://github.com/omarroth/invidious/issues/120)
+
+Enjoy your week everyone!
+
+# 0.1.0 (2018-09-06)
+
+## Week 1: Invidious API and Geo-Bypass
+
+Hello everyone! This past week there have been quite a few things worthy of mention:
+
+I'm happy to announce the [Invidious Developer API](https://github.com/omarroth/invidious/wiki/API). The Invidious API does not use any of the official YouTube APIs, and instead crawls the site to provide a JSON interface for other developers to use. It's still under development but is already powering [CloudTube](https://github.com/cloudrac3r/cadencegq). The API currently does not have a quota (compared to YouTube) which I hope to continue thanks to continued support from my Patrons. Hopefully other developers find it useful, and I hope to continue to improve it so it can better serve the community.
+
+Just today partial support for bypassing geo-restrictions has been added with [fada57a](https://github.com/omarroth/invidious/commit/fada57a307d66d696d9286fc943c579a3fd22de6). If a video is unblocked in one of: United States, Canada, Germany, France, Japan, Russia, or United Kingdom, then Invidious will be able to serve video info. Currently you will not yet be able to access the video files themselves, but in the coming week I hope to proxy videos so that users can enjoy content across borders.
+
+Support for generating DASH manifests has been fixed, in the coming week I hope to integrate this functionality into the watch page, so users can view videos in 1080p and above.
+
+Thank you everyone for your continued interest and support!
diff --git a/Makefile b/Makefile
index 9eb195df..ec22a0de 100644
--- a/Makefile
+++ b/Makefile
@@ -7,6 +7,11 @@ STATIC := 0
NO_DBG_SYMBOLS := 0
+# Enable multi-threading.
+# Warning: Experimental feature!!
+# invidious is not stable when MT is enabled.
+MT := 0
+
FLAGS ?=
@@ -19,6 +24,10 @@ ifeq ($(STATIC), 1)
FLAGS += --static
endif
+ifeq ($(MT), 1)
+ FLAGS += -Dpreview_mt
+endif
+
ifeq ($(NO_DBG_SYMBOLS), 1)
FLAGS += --no-debug
diff --git a/README.md b/README.md
index 88770383..b139c5f6 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@
**Data import/export**
- Import subscriptions from YouTube, NewPipe and Freetube
-- Import watch history from NewPipe
+- Import watch history from YouTube and NewPipe
- Export subscriptions to NewPipe and Freetube
- Import/Export Invidious user data
@@ -145,18 +145,7 @@ Weblate also allows you to log-in with major SSO providers like Github, Gitlab,
## Projects using Invidious
-- [FreeTube](https://github.com/FreeTubeApp/FreeTube): A libre software YouTube app for privacy.
-- [CloudTube](https://sr.ht/~cadence/tube/): A JavaScript-rich alternate YouTube player.
-- [PeerTubeify](https://gitlab.com/Cha_de_L/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-wa): Progressive Web App connecting on Invidious API's with search, playlists and favorites.
-- [WatchTube](https://github.com/WatchTubeTeam/WatchTube): Powerful YouTube client for Apple Watch.
-- [Yattee](https://github.com/yattee/yattee): Alternative YouTube frontend for iPhone, iPad, Mac and Apple TV.
-- [TubiTui](https://codeberg.org/777/TubiTui): A lightweight, libre, TUI-based YouTube client.
-- [Ytfzf](https://github.com/pystardust/ytfzf): A posix script to find and watch youtube videos from the terminal. (Without API).
-- [Playlet](https://github.com/iBicha/playlet): Unofficial Youtube client for Roku TV.
-- [Clipious](https://github.com/lamarios/clipious): Unofficial Invidious client for Android.
-
+A list of projects and extensions for or utilizing Invidious can be found in the documentation: https://docs.invidious.io/applications/
## Liability
diff --git a/assets/css/carousel.css b/assets/css/carousel.css
new file mode 100644
index 00000000..4bae92e5
--- /dev/null
+++ b/assets/css/carousel.css
@@ -0,0 +1,119 @@
+/*
+Copyright (c) 2024 by Jennifer (https://codepen.io/jwjertzoch/pen/JjyGeRy)
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without restriction,
+ including without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall
+be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+*/
+
+.carousel {
+ margin: 0 auto;
+ overflow: hidden;
+ text-align: center;
+}
+
+.slides {
+ width: 100%;
+ display: flex;
+ overflow-x: scroll;
+ scrollbar-width: none;
+ scroll-snap-type: x mandatory;
+ scroll-behavior: smooth;
+}
+
+.slides::-webkit-scrollbar {
+ display: none;
+}
+
+.slides-item {
+ align-items: center;
+ border-radius: 10px;
+ display: flex;
+ flex-shrink: 0;
+ font-size: 100px;
+ height: 600px;
+ justify-content: center;
+ margin: 0 1rem;
+ position: relative;
+ scroll-snap-align: start;
+ transform: scale(1);
+ transform-origin: center center;
+ transition: transform .5s;
+ width: 100%;
+}
+
+.carousel__nav {
+ padding: 1.25rem .5rem;
+}
+
+.slider-nav {
+ align-items: center;
+ background-color: #ddd;
+ border-radius: 50%;
+ color: #000;
+ display: inline-flex;
+ height: 1.5rem;
+ justify-content: center;
+ padding: .5rem;
+ position: relative;
+ text-decoration: none;
+ width: 1.5rem;
+}
+
+.skip-link {
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ top: auto;
+ width: 1px;
+}
+
+.skip-link:focus {
+ align-items: center;
+ background-color: #000;
+ color: #fff;
+ display: flex;
+ font-size: 30px;
+ height: 30px;
+ justify-content: center;
+ opacity: .8;
+ text-decoration: none;
+ width: 50%;
+ z-index: 1;
+}
+
+.light-theme .slider-nav {
+ background-color: #ddd;
+}
+
+.dark-theme .slider-nav {
+ background-color: #0005;
+}
+
+@media (prefers-color-scheme: light) {
+ .no-theme .slider-nav {
+ background-color: #ddd;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .no-theme .slider-nav {
+ background-color: #0005;
+ }
+}
diff --git a/assets/css/default.css b/assets/css/default.css
index c31b24e5..2cedcf0c 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -13,6 +13,7 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
+ margin: auto;
}
.h-box {
@@ -197,6 +198,7 @@ img.thumbnail {
display: block; /* See: https://stackoverflow.com/a/11635197 */
width: 100%;
object-fit: cover;
+ aspect-ratio: 16 / 9;
}
.thumbnail-placeholder {
@@ -276,7 +278,14 @@ div.thumbnail > .bottom-right-overlay {
display: inline;
}
-.searchbar .pure-form fieldset { padding: 0; }
+.searchbar .pure-form {
+ display: flex;
+}
+
+.searchbar .pure-form fieldset {
+ padding: 0;
+ flex: 1;
+}
.searchbar input[type="search"] {
width: 100%;
@@ -308,6 +317,16 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 14px;
}
+.searchbar #searchbutton {
+ border: none;
+ background: none;
+ margin-top: 0;
+}
+
+.searchbar #searchbutton:hover {
+ color: rgb(0, 182, 240);
+}
+
.user-field {
display: flex;
flex-direction: row;
@@ -392,11 +411,19 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
* Comments & community posts
*/
-#comments {
+.comments {
max-width: 800px;
margin: auto;
}
+/*
+ * We don't want the top and bottom margin on the post page.
+ */
+.comments.post-comments {
+ margin-bottom: 0;
+ margin-top: 0;
+}
+
.video-iframe-wrapper {
position: relative;
height: 0;
@@ -433,16 +460,26 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
*/
footer {
- color: #919191;
margin-top: auto;
padding: 1.5em 0;
text-align: center;
max-height: 30vh;
}
-footer a {
- color: #919191 !important;
- text-decoration: underline;
+.light-theme footer {
+ color: #7c7c7c;
+}
+
+.dark-theme footer {
+ color: #adadad;
+}
+
+.light-theme footer a {
+ color: #7c7c7c !important;
+}
+
+.dark-theme footer a {
+ color: #adadad !important;
}
footer span {
@@ -548,6 +585,14 @@ span > select {
color: #303030;
}
+ .no-theme footer {
+ color: #7c7c7c;
+ }
+
+ .no-theme footer a {
+ color: #7c7c7c !important;
+ }
+
.light-theme .pure-menu-heading {
color: #565d64;
}
@@ -581,7 +626,7 @@ span > select {
}
.dark-theme a {
- color: #a0a0a0;
+ color: #adadad;
text-decoration: none;
}
@@ -635,7 +680,7 @@ body.dark-theme {
}
.no-theme a {
- color: #a0a0a0;
+ color: #adadad;
text-decoration: none;
}
@@ -666,6 +711,14 @@ body.dark-theme {
background-color: inherit;
color: inherit;
}
+
+ .no-theme footer {
+ color: #adadad;
+ }
+
+ .no-theme footer a {
+ color: #adadad !important;
+ }
}
@@ -759,3 +812,7 @@ h1, h2, h3, h4, h5, p,
.channel-emoji {
margin: 0 2px;
}
+
+#download_widget {
+ width: 100%;
+}
diff --git a/assets/css/player.css b/assets/css/player.css
index 50c7a748..9cb400ad 100644
--- a/assets/css/player.css
+++ b/assets/css/player.css
@@ -68,6 +68,7 @@
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
margin-bottom: 2em;
+ padding-top: 2em
}
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px;
diff --git a/assets/js/comments.js b/assets/js/comments.js
new file mode 100644
index 00000000..35ffa96e
--- /dev/null
+++ b/assets/js/comments.js
@@ -0,0 +1,174 @@
+var video_data = JSON.parse(document.getElementById('video_data').textContent);
+
+var spinnerHTML = '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
+var spinnerHTMLwithHR = spinnerHTML + '<hr>';
+
+String.prototype.supplant = function (o) {
+ return this.replace(/{([^{}]*)}/g, function (a, b) {
+ var r = o[b];
+ return typeof r === 'string' || typeof r === 'number' ? r : a;
+ });
+};
+
+function toggle_comments(event) {
+ var target = event.target;
+ var body = target.parentNode.parentNode.parentNode.children[1];
+ if (body.style.display === 'none') {
+ target.textContent = '[ − ]';
+ body.style.display = '';
+ } else {
+ target.textContent = '[ + ]';
+ body.style.display = 'none';
+ }
+}
+
+function hide_youtube_replies(event) {
+ var target = event.target;
+
+ var sub_text = target.getAttribute('data-inner-text');
+ var inner_text = target.getAttribute('data-sub-text');
+
+ var body = target.parentNode.parentNode.children[1];
+ body.style.display = 'none';
+
+ target.textContent = sub_text;
+ target.onclick = show_youtube_replies;
+ target.setAttribute('data-inner-text', inner_text);
+ target.setAttribute('data-sub-text', sub_text);
+}
+
+function show_youtube_replies(event) {
+ var target = event.target;
+
+ var sub_text = target.getAttribute('data-inner-text');
+ var inner_text = target.getAttribute('data-sub-text');
+
+ var body = target.parentNode.parentNode.children[1];
+ body.style.display = '';
+
+ target.textContent = sub_text;
+ target.onclick = hide_youtube_replies;
+ target.setAttribute('data-inner-text', inner_text);
+ target.setAttribute('data-sub-text', sub_text);
+}
+
+function get_youtube_comments() {
+ var comments = document.getElementById('comments');
+
+ var fallback = comments.innerHTML;
+ comments.innerHTML = spinnerHTML;
+
+ var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id
+ var url = baseUrl +
+ '?format=html' +
+ '&hl=' + video_data.preferences.locale +
+ '&thin_mode=' + video_data.preferences.thin_mode;
+
+ if (video_data.ucid) {
+ url += '&ucid=' + video_data.ucid
+ }
+
+ var onNon200 = function (xhr) { comments.innerHTML = fallback; };
+ if (video_data.params.comments[1] === 'youtube')
+ onNon200 = function (xhr) {};
+
+ helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, {
+ on200: function (response) {
+ var commentInnerHtml = ' \
+ <div> \
+ <h3> \
+ <a href="javascript:void(0)">[ − ]</a> \
+ {commentsText} \
+ </h3> \
+ <b> \
+ '
+ if (video_data.support_reddit) {
+ commentInnerHtml += ' <a href="javascript:void(0)" data-comments="reddit"> \
+ {redditComments} \
+ </a> \
+ '
+ }
+ commentInnerHtml += ' </b> \
+ </div> \
+ <div>{contentHtml}</div> \
+ <hr>'
+ commentInnerHtml = commentInnerHtml.supplant({
+ contentHtml: response.contentHtml,
+ redditComments: video_data.reddit_comments_text,
+ commentsText: video_data.comments_text.supplant({
+ // toLocaleString correctly splits number with local thousands separator. e.g.:
+ // '1,234,567.89' for user with English locale
+ // '1 234 567,89' for user with Russian locale
+ // '1.234.567,89' for user with Portuguese locale
+ commentCount: response.commentCount.toLocaleString()
+ })
+ });
+ comments.innerHTML = commentInnerHtml;
+ comments.children[0].children[0].children[0].onclick = toggle_comments;
+ if (video_data.support_reddit) {
+ comments.children[0].children[1].children[0].onclick = swap_comments;
+ }
+ },
+ onNon200: onNon200, // declared above
+ onError: function (xhr) {
+ comments.innerHTML = spinnerHTML;
+ },
+ onTimeout: function (xhr) {
+ comments.innerHTML = spinnerHTML;
+ }
+ });
+}
+
+function get_youtube_replies(target, load_more, load_replies) {
+ var continuation = target.getAttribute('data-continuation');
+
+ var body = target.parentNode.parentNode;
+ var fallback = body.innerHTML;
+ body.innerHTML = spinnerHTML;
+ var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id
+ var url = baseUrl +
+ '?format=html' +
+ '&hl=' + video_data.preferences.locale +
+ '&thin_mode=' + video_data.preferences.thin_mode +
+ '&continuation=' + continuation;
+
+ if (video_data.ucid) {
+ url += '&ucid=' + video_data.ucid
+ }
+ if (load_replies) url += '&action=action_get_comment_replies';
+
+ helpers.xhr('GET', url, {}, {
+ on200: function (response) {
+ if (load_more) {
+ body = body.parentNode.parentNode;
+ body.removeChild(body.lastElementChild);
+ body.insertAdjacentHTML('beforeend', response.contentHtml);
+ } else {
+ body.removeChild(body.lastElementChild);
+
+ var p = document.createElement('p');
+ var a = document.createElement('a');
+ p.appendChild(a);
+
+ a.href = 'javascript:void(0)';
+ a.onclick = hide_youtube_replies;
+ a.setAttribute('data-sub-text', video_data.hide_replies_text);
+ a.setAttribute('data-inner-text', video_data.show_replies_text);
+ a.textContent = video_data.hide_replies_text;
+
+ var div = document.createElement('div');
+ div.innerHTML = response.contentHtml;
+
+ body.appendChild(p);
+ body.appendChild(div);
+ }
+ },
+ onNon200: function (xhr) {
+ body.innerHTML = fallback;
+ },
+ onTimeout: function (xhr) {
+ console.warn('Pulling comments failed');
+ body.innerHTML = fallback;
+ }
+ });
+} \ No newline at end of file
diff --git a/assets/js/handlers.js b/assets/js/handlers.js
index 539974fb..67cd9081 100644
--- a/assets/js/handlers.js
+++ b/assets/js/handlers.js
@@ -91,7 +91,7 @@
var count = document.getElementById('count');
count.textContent--;
- var url = '/token_ajax?action_revoke_token=1&redirect=false' +
+ var url = '/token_ajax?action=revoke_token&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&session=' + target.getAttribute('data-session');
@@ -111,7 +111,7 @@
var count = document.getElementById('count');
count.textContent--;
- var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
+ var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&c=' + target.getAttribute('data-ucid');
diff --git a/assets/js/notifications.js b/assets/js/notifications.js
index 058553d9..55b7a15c 100644
--- a/assets/js/notifications.js
+++ b/assets/js/notifications.js
@@ -10,7 +10,7 @@ var notifications, delivered;
var notifications_mock = { close: function () { } };
function get_subscriptions() {
- helpers.xhr('GET', '/api/v1/auth/subscriptions?fields=authorId', {
+ helpers.xhr('GET', '/api/v1/auth/subscriptions', {
retries: 5,
entity_name: 'subscriptions'
}, {
@@ -22,7 +22,7 @@ function create_notification_stream(subscriptions) {
// sse.js can't be replaced to EventSource in place as it lack support of payload and headers
// see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource
notifications = new SSE(
- '/api/v1/auth/notifications?fields=videoId,title,author,authorId,publishedText,published,authorThumbnails,liveNow', {
+ '/api/v1/auth/notifications', {
withCredentials: true,
payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId; }).join(','),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
diff --git a/assets/js/player.js b/assets/js/player.js
index bb53ac24..353a5296 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -3,7 +3,6 @@ var player_data = JSON.parse(document.getElementById('player_data').textContent)
var video_data = JSON.parse(document.getElementById('video_data').textContent);
var options = {
- preload: 'auto',
liveui: true,
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0],
controlBar: {
@@ -98,11 +97,13 @@ if (video_data.params.quality === 'dash') {
/**
* Function for add time argument to url
+ *
* @param {String} url
+ * @param {String} [base]
* @returns {URL} urlWithTimeArg
*/
-function addCurrentTimeToURL(url) {
- var urlUsed = new URL(url);
+function addCurrentTimeToURL(url, base) {
+ var urlUsed = new URL(url, base);
urlUsed.searchParams.delete('start');
var currentTime = Math.ceil(player.currentTime());
if (currentTime > 0)
@@ -112,6 +113,50 @@ function addCurrentTimeToURL(url) {
return urlUsed;
}
+/**
+ * Global variable to save the last timestamp (in full seconds) at which the external
+ * links were updated by the 'timeupdate' callback below.
+ *
+ * It is initialized to 5s so that the video will always restart from the beginning
+ * if the user hasn't really started watching before switching to the other website.
+ */
+var timeupdate_last_ts = 5;
+
+/**
+ * Callback that updates the timestamp on all external links
+ */
+player.on('timeupdate', function () {
+ // Only update once every second
+ let current_ts = Math.floor(player.currentTime());
+ if (current_ts > timeupdate_last_ts) timeupdate_last_ts = current_ts;
+ else return;
+
+ // YouTube links
+
+ let elem_yt_watch = document.getElementById('link-yt-watch');
+ let elem_yt_embed = document.getElementById('link-yt-embed');
+
+ let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
+ let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
+
+ elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
+ elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
+
+ // Invidious links
+
+ let domain = window.location.origin;
+
+ let elem_iv_embed = document.getElementById('link-iv-embed');
+ let elem_iv_other = document.getElementById('link-iv-other');
+
+ let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
+ let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
+
+ elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
+ elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
+});
+
+
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
@@ -305,7 +350,12 @@ if (video_data.params.save_player_pos) {
const rememberedTime = get_video_time();
let lastUpdated = 0;
- if(!hasTimeParam) set_seconds_after_start(rememberedTime);
+ if(!hasTimeParam) {
+ if (rememberedTime >= video_data.length_seconds - 20)
+ set_seconds_after_start(0);
+ else
+ set_seconds_after_start(rememberedTime);
+ }
player.on('timeupdate', function () {
const raw = player.currentTime();
@@ -701,6 +751,17 @@ if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) {
});
}
+// Safari screen timeout on looped video playback fix
+if (navigator.vendor === 'Apple Computer, Inc.' && !video_data.params.listen && video_data.params.video_loop) {
+ player.loop(false);
+ player.ready(function () {
+ player.on('ended', function () {
+ player.currentTime(0);
+ player.play();
+ });
+ });
+}
+
// Watch on Invidious link
if (location.pathname.startsWith('/embed/')) {
const Button = videojs.getComponent('Button');
diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js
index c92592ac..96a51d70 100644
--- a/assets/js/playlist_widget.js
+++ b/assets/js/playlist_widget.js
@@ -6,7 +6,7 @@ function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1];
var option = select.children[select.selectedIndex];
- var url = '/playlist_ajax?action_add_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=add_video&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid');
@@ -21,7 +21,7 @@ function add_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/playlist_ajax?action_add_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=add_video&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + target.getAttribute('data-plid');
@@ -36,7 +36,7 @@ function remove_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
+ var url = '/playlist_ajax?action=remove_video&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') +
'&playlist_id=' + target.getAttribute('data-plid');
diff --git a/assets/js/post.js b/assets/js/post.js
new file mode 100644
index 00000000..fcbc9155
--- /dev/null
+++ b/assets/js/post.js
@@ -0,0 +1,3 @@
+addEventListener('load', function (e) {
+ get_youtube_comments();
+});
diff --git a/assets/js/subscribe_widget.js b/assets/js/subscribe_widget.js
index 7665a00b..d462e848 100644
--- a/assets/js/subscribe_widget.js
+++ b/assets/js/subscribe_widget.js
@@ -16,7 +16,7 @@ function subscribe() {
subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
- var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
+ var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' +
'&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
@@ -32,7 +32,7 @@ function unsubscribe() {
subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
- var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
+ var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
diff --git a/assets/js/watch.js b/assets/js/watch.js
index 36506abd..d869d40d 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -1,14 +1,4 @@
'use strict';
-var video_data = JSON.parse(document.getElementById('video_data').textContent);
-var spinnerHTML = '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
-var spinnerHTMLwithHR = spinnerHTML + '<hr>';
-
-String.prototype.supplant = function (o) {
- return this.replace(/{([^{}]*)}/g, function (a, b) {
- var r = o[b];
- return typeof r === 'string' || typeof r === 'number' ? r : a;
- });
-};
function toggle_parent(target) {
var body = target.parentNode.parentNode.children[1];
@@ -21,18 +11,6 @@ function toggle_parent(target) {
}
}
-function toggle_comments(event) {
- var target = event.target;
- var body = target.parentNode.parentNode.parentNode.children[1];
- if (body.style.display === 'none') {
- target.textContent = '[ − ]';
- body.style.display = '';
- } else {
- target.textContent = '[ + ]';
- body.style.display = 'none';
- }
-}
-
function swap_comments(event) {
var source = event.target.getAttribute('data-comments');
@@ -43,36 +21,6 @@ function swap_comments(event) {
}
}
-function hide_youtube_replies(event) {
- var target = event.target;
-
- var sub_text = target.getAttribute('data-inner-text');
- var inner_text = target.getAttribute('data-sub-text');
-
- var body = target.parentNode.parentNode.children[1];
- body.style.display = 'none';
-
- target.textContent = sub_text;
- target.onclick = show_youtube_replies;
- target.setAttribute('data-inner-text', inner_text);
- target.setAttribute('data-sub-text', sub_text);
-}
-
-function show_youtube_replies(event) {
- var target = event.target;
-
- var sub_text = target.getAttribute('data-inner-text');
- var inner_text = target.getAttribute('data-sub-text');
-
- var body = target.parentNode.parentNode.children[1];
- body.style.display = '';
-
- target.textContent = sub_text;
- target.onclick = hide_youtube_replies;
- target.setAttribute('data-inner-text', inner_text);
- target.setAttribute('data-sub-text', sub_text);
-}
-
var continue_button = document.getElementById('continue');
if (continue_button) {
continue_button.onclick = continue_autoplay;
@@ -119,6 +67,10 @@ function get_playlist(plid) {
'&format=html&hl=' + video_data.preferences.locale;
}
+ if (video_data.params.listen) {
+ plid_url += '&listen=1'
+ }
+
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) {
playlist.innerHTML = response.playlistHtml;
@@ -208,111 +160,6 @@ function get_reddit_comments() {
});
}
-function get_youtube_comments() {
- var comments = document.getElementById('comments');
-
- var fallback = comments.innerHTML;
- comments.innerHTML = spinnerHTML;
-
- var url = '/api/v1/comments/' + video_data.id +
- '?format=html' +
- '&hl=' + video_data.preferences.locale +
- '&thin_mode=' + video_data.preferences.thin_mode;
-
- var onNon200 = function (xhr) { comments.innerHTML = fallback; };
- if (video_data.params.comments[1] === 'youtube')
- onNon200 = function (xhr) {};
-
- helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, {
- on200: function (response) {
- comments.innerHTML = ' \
- <div> \
- <h3> \
- <a href="javascript:void(0)">[ − ]</a> \
- {commentsText} \
- </h3> \
- <b> \
- <a href="javascript:void(0)" data-comments="reddit"> \
- {redditComments} \
- </a> \
- </b> \
- </div> \
- <div>{contentHtml}</div> \
- <hr>'.supplant({
- contentHtml: response.contentHtml,
- redditComments: video_data.reddit_comments_text,
- commentsText: video_data.comments_text.supplant({
- // toLocaleString correctly splits number with local thousands separator. e.g.:
- // '1,234,567.89' for user with English locale
- // '1 234 567,89' for user with Russian locale
- // '1.234.567,89' for user with Portuguese locale
- commentCount: response.commentCount.toLocaleString()
- })
- });
-
- comments.children[0].children[0].children[0].onclick = toggle_comments;
- comments.children[0].children[1].children[0].onclick = swap_comments;
- },
- onNon200: onNon200, // declared above
- onError: function (xhr) {
- comments.innerHTML = spinnerHTML;
- },
- onTimeout: function (xhr) {
- comments.innerHTML = spinnerHTML;
- }
- });
-}
-
-function get_youtube_replies(target, load_more, load_replies) {
- var continuation = target.getAttribute('data-continuation');
-
- var body = target.parentNode.parentNode;
- var fallback = body.innerHTML;
- body.innerHTML = spinnerHTML;
-
- var url = '/api/v1/comments/' + video_data.id +
- '?format=html' +
- '&hl=' + video_data.preferences.locale +
- '&thin_mode=' + video_data.preferences.thin_mode +
- '&continuation=' + continuation;
- if (load_replies) url += '&action=action_get_comment_replies';
-
- helpers.xhr('GET', url, {}, {
- on200: function (response) {
- if (load_more) {
- body = body.parentNode.parentNode;
- body.removeChild(body.lastElementChild);
- body.insertAdjacentHTML('beforeend', response.contentHtml);
- } else {
- body.removeChild(body.lastElementChild);
-
- var p = document.createElement('p');
- var a = document.createElement('a');
- p.appendChild(a);
-
- a.href = 'javascript:void(0)';
- a.onclick = hide_youtube_replies;
- a.setAttribute('data-sub-text', video_data.hide_replies_text);
- a.setAttribute('data-inner-text', video_data.show_replies_text);
- a.textContent = video_data.hide_replies_text;
-
- var div = document.createElement('div');
- div.innerHTML = response.contentHtml;
-
- body.appendChild(p);
- body.appendChild(div);
- }
- },
- onNon200: function (xhr) {
- body.innerHTML = fallback;
- },
- onTimeout: function (xhr) {
- console.warn('Pulling comments failed');
- body.innerHTML = fallback;
- }
- });
-}
-
if (video_data.play_next) {
player.on('ended', function () {
var url = new URL('https://example.com/watch?v=' + video_data.next_video);
diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js
index f1ac9cb4..06af62cc 100644
--- a/assets/js/watched_widget.js
+++ b/assets/js/watched_widget.js
@@ -6,7 +6,7 @@ function mark_watched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
- var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
+ var url = '/watch_ajax?action=mark_watched&redirect=false' +
'&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, {
@@ -22,7 +22,7 @@ function mark_unwatched(target) {
var count = document.getElementById('count');
count.textContent--;
- var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
+ var url = '/watch_ajax?action=mark_unwatched&redirect=false' +
'&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, {
diff --git a/assets/site.webmanifest b/assets/site.webmanifest
index af9432d7..2db6ed9e 100644
--- a/assets/site.webmanifest
+++ b/assets/site.webmanifest
@@ -15,5 +15,7 @@
],
"theme_color": "#575757",
"background_color": "#575757",
- "display": "standalone"
+ "display": "standalone",
+ "description": "An alternative front-end to YouTube",
+ "start_url": "/"
}
diff --git a/config/config.example.yml b/config/config.example.yml
index b44fcc0e..bc2deda5 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -1,6 +1,6 @@
#########################################
#
-# Database configuration
+# Database and other external servers
#
#########################################
@@ -41,6 +41,19 @@ db:
#check_tables: false
+##
+## Path to an external signature resolver, used to emulate
+## the Youtube client's Javascript. If no such server is
+## available, some videos will not be playable.
+##
+## When this setting is commented out, no external
+## resolver will be used.
+##
+## Accepted values: a path to a UNIX socket or "<IP>:<Port>"
+## Default: <none>
+##
+#signature_server:
+
#########################################
#
@@ -160,6 +173,17 @@ https_only: false
##
#force_resolve:
+##
+## Configuration for using a HTTP proxy
+##
+## If unset, then no HTTP proxy will be used.
+##
+#http_proxy:
+# user:
+# password:
+# host:
+# port:
+
##
## Use Innertube's transcripts API instead of timedtext for closed captions
@@ -173,6 +197,18 @@ https_only: false
##
# use_innertube_for_captions: false
+##
+## Send Google session informations. This is useful when Invidious is blocked
+## by the message "This helps protect our community."
+## See https://github.com/iv-org/invidious/issues/4734.
+##
+## Warning: These strings gives much more identifiable information to Google!
+##
+## Accepted values: String
+## Default: <none>
+##
+# po_token: ""
+# visitor_data: ""
# -----------------------------
# Logging
@@ -197,6 +233,17 @@ https_only: false
##
#log_level: Info
+##
+## Enables colors in logs. Useful for debugging purposes
+## This is overridden if "-k" or "--colorize"
+## are passed on the command line.
+## Colors are also disabled if the environment variable
+## NO_COLOR is present and has any value
+##
+## Accepted values: true, false
+## Default: true
+##
+#colorize_logs: false
# -----------------------------
# Features
@@ -343,21 +390,6 @@ full_refresh: false
##
feed_threads: 1
-##
-## Enable/Disable the polling job that keeps the decryption
-## function (for "secured" videos) up to date.
-##
-## Note: This part of the code generate a small amount of data every minute.
-## This may not be desired if you have bandwidth limits set by your ISP.
-##
-## Note 2: This part of the code is currently broken, so changing
-## this setting has no impact.
-##
-## Accepted values: true, false
-## Default: false
-##
-#decrypt_polling: false
-
jobs:
@@ -393,27 +425,6 @@ jobs:
# -----------------------------
-# Captcha API
-# -----------------------------
-
-##
-## URL of the captcha solving service.
-##
-## Accepted values: any URL
-## Default: https://api.anti-captcha.com
-##
-#captcha_api_url: https://api.anti-captcha.com
-
-##
-## API key for the captcha solving service.
-##
-## Accepted values: a string
-## Default: <none>
-##
-#captcha_key:
-
-
-# -----------------------------
# Miscellaneous
# -----------------------------
@@ -719,6 +730,22 @@ default_user_preferences:
# -----------------------------
##
+ ## This option controls the value of the HTML5 <video> element's
+ ## "preload" attribute.
+ ##
+ ## If set to 'false', no video data will be loaded until the user
+ ## explicitly starts the video by clicking the "Play" button.
+ ## If set to 'true', the web browser will buffer some video data
+ ## while the page is loading.
+ ##
+ ## See: https://www.w3schools.com/tags/att_video_preload.asp
+ ##
+ ## Accepted values: true, false
+ ## Default: true
+ ##
+ #preload: true
+
+ ##
## Automatically play videos on page load.
##
## Accepted values: true, false
diff --git a/docker-compose.yml b/docker-compose.yml
index 6a854475..afda8726 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,15 +32,13 @@ services:
# statistics_enabled: false
hmac_key: "CHANGE_ME!!"
healthcheck:
- test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
+ test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/trending || exit 1
interval: 30s
timeout: 5s
retries: 2
- depends_on:
- - invidious-db
invidious-db:
- image: docker.io/library/postgres:13
+ image: docker.io/library/postgres:14
restart: unless-stopped
volumes:
- postgresdata:/var/lib/postgresql/data
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 761bbdca..900c9e74 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,5 @@
-FROM crystallang/crystal:1.4.1-alpine AS builder
+FROM crystallang/crystal:1.12.2-alpine AS builder
+
RUN apk add --no-cache sqlite-static yaml-static
ARG release
@@ -19,8 +20,7 @@ COPY ./assets/ ./assets/
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \
- --link-flags "-lxml2 -llzma"
-
+ --link-flags "-lxml2 -llzma"
RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \
--release \
@@ -32,9 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \
fi
-
-FROM alpine:3.16
-RUN apk add --no-cache librsvg ttf-opensans tini
+FROM alpine:3.20
+RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious
diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64
index cf9231fb..ce9bab08 100644
--- a/docker/Dockerfile.arm64
+++ b/docker/Dockerfile.arm64
@@ -1,5 +1,6 @@
-FROM alpine:3.16 AS builder
-RUN apk add --no-cache 'crystal=1.4.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
+FROM alpine:3.20 AS builder
+RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \
+ zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release
@@ -32,8 +33,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \
fi
-FROM alpine:3.16
-RUN apk add --no-cache librsvg ttf-opensans tini
+FROM alpine:3.20
+RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious
diff --git a/kubernetes/.gitignore b/kubernetes/.gitignore
deleted file mode 100644
index 0ad51707..00000000
--- a/kubernetes/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/charts/*.tgz
diff --git a/kubernetes/Chart.lock b/kubernetes/Chart.lock
deleted file mode 100644
index cc76e920..00000000
--- a/kubernetes/Chart.lock
+++ /dev/null
@@ -1,6 +0,0 @@
-dependencies:
-- name: postgresql
- repository: https://charts.bitnami.com/bitnami/
- version: 12.1.9
-digest: sha256:71ff342a6c0a98bece3d7fe199983afb2113f8db65a3e3819de875af2c45add7
-generated: "2023-01-20T20:42:32.757707004Z"
diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml
deleted file mode 100644
index 4e4295ba..00000000
--- a/kubernetes/Chart.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
-apiVersion: v2
-name: invidious
-description: Invidious is an alternative front-end to YouTube
-version: 1.1.1
-appVersion: 0.20.1
-keywords:
-- youtube
-- proxy
-- video
-- privacy
-home: https://invidio.us/
-icon: https://raw.githubusercontent.com/iv-org/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png
-sources:
-- https://github.com/iv-org/invidious
-maintainers:
-- name: Leon Klingele
- email: mail@leonklingele.de
-dependencies:
-- name: postgresql
- version: ~12.1.6
- repository: "https://charts.bitnami.com/bitnami/"
-engine: gotpl
diff --git a/kubernetes/README.md b/kubernetes/README.md
index 35478f99..e71f6a86 100644
--- a/kubernetes/README.md
+++ b/kubernetes/README.md
@@ -1,41 +1 @@
-# Invidious Helm chart
-
-Easily deploy Invidious to Kubernetes.
-
-## Installing Helm chart
-
-```sh
-# Build Helm dependencies
-$ helm dep build
-
-# Add PostgreSQL init scripts
-$ kubectl create configmap invidious-postgresql-init \
- --from-file=../config/sql/channels.sql \
- --from-file=../config/sql/videos.sql \
- --from-file=../config/sql/channel_videos.sql \
- --from-file=../config/sql/users.sql \
- --from-file=../config/sql/session_ids.sql \
- --from-file=../config/sql/nonces.sql \
- --from-file=../config/sql/annotations.sql \
- --from-file=../config/sql/playlists.sql \
- --from-file=../config/sql/playlist_videos.sql
-
-# Install Helm app to your Kubernetes cluster
-$ helm install invidious ./
-```
-
-## Upgrading
-
-```sh
-# Upgrading is easy, too!
-$ helm upgrade invidious ./
-```
-
-## Uninstall
-
-```sh
-# Get rid of everything (except database)
-$ helm delete invidious
-
-# To also delete the database, remove all invidious-postgresql PVCs
-```
+The Helm chart has moved to a dedicated GitHub repository: https://github.com/iv-org/invidious-helm-chart/tree/master/invidious \ No newline at end of file
diff --git a/kubernetes/templates/_helpers.tpl b/kubernetes/templates/_helpers.tpl
deleted file mode 100644
index 52158b78..00000000
--- a/kubernetes/templates/_helpers.tpl
+++ /dev/null
@@ -1,16 +0,0 @@
-{{/* vim: set filetype=mustache: */}}
-{{/*
-Expand the name of the chart.
-*/}}
-{{- define "invidious.name" -}}
-{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
-{{- end -}}
-
-{{/*
-Create a default fully qualified app name.
-We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
-*/}}
-{{- define "invidious.fullname" -}}
-{{- $name := default .Chart.Name .Values.nameOverride -}}
-{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
-{{- end -}}
diff --git a/kubernetes/templates/configmap.yaml b/kubernetes/templates/configmap.yaml
deleted file mode 100644
index 58542a31..00000000
--- a/kubernetes/templates/configmap.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: {{ template "invidious.fullname" . }}
- labels:
- app: {{ template "invidious.name" . }}
- chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
- release: {{ .Release.Name }}
-data:
- INVIDIOUS_CONFIG: |
-{{ toYaml .Values.config | indent 4 }}
diff --git a/kubernetes/templates/deployment.yaml b/kubernetes/templates/deployment.yaml
deleted file mode 100644
index bb0b832f..00000000
--- a/kubernetes/templates/deployment.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: {{ template "invidious.fullname" . }}
- labels:
- app: {{ template "invidious.name" . }}
- chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
- release: {{ .Release.Name }}
-spec:
- replicas: {{ .Values.replicaCount }}
- selector:
- matchLabels:
- app: {{ template "invidious.name" . }}
- release: {{ .Release.Name }}
- template:
- metadata:
- labels:
- app: {{ template "invidious.name" . }}
- chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
- release: {{ .Release.Name }}
- spec:
- securityContext:
- runAsUser: {{ .Values.securityContext.runAsUser }}
- runAsGroup: {{ .Values.securityContext.runAsGroup }}
- fsGroup: {{ .Values.securityContext.fsGroup }}
- initContainers:
- - name: wait-for-postgresql
- image: postgres
- args:
- - /bin/sh
- - -c
- - until pg_isready -h {{ .Values.config.db.host }} -p {{ .Values.config.db.port }} -U {{ .Values.config.db.user }}; do echo waiting for database; sleep 2; done;
- containers:
- - name: {{ .Chart.Name }}
- image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
- imagePullPolicy: {{ .Values.image.pullPolicy }}
- ports:
- - containerPort: 3000
- env:
- - name: INVIDIOUS_CONFIG
- valueFrom:
- configMapKeyRef:
- key: INVIDIOUS_CONFIG
- name: {{ template "invidious.fullname" . }}
- securityContext:
- allowPrivilegeEscalation: {{ .Values.securityContext.allowPrivilegeEscalation }}
- capabilities:
- drop:
- - ALL
- resources:
-{{ toYaml .Values.resources | indent 10 }}
- readinessProbe:
- httpGet:
- port: 3000
- path: /
- livenessProbe:
- httpGet:
- port: 3000
- path: /
- initialDelaySeconds: 15
- restartPolicy: Always
diff --git a/kubernetes/templates/hpa.yaml b/kubernetes/templates/hpa.yaml
deleted file mode 100644
index c6fbefe2..00000000
--- a/kubernetes/templates/hpa.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-{{- if .Values.autoscaling.enabled }}
-apiVersion: autoscaling/v1
-kind: HorizontalPodAutoscaler
-metadata:
- name: {{ template "invidious.fullname" . }}
- labels:
- app: {{ template "invidious.name" . }}
- chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
- release: {{ .Release.Name }}
-spec:
- scaleTargetRef:
- apiVersion: apps/v1
- kind: Deployment
- name: {{ template "invidious.fullname" . }}
- minReplicas: {{ .Values.autoscaling.minReplicas }}
- maxReplicas: {{ .Values.autoscaling.maxReplicas }}
- targetCPUUtilizationPercentage: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
-{{- end }}
diff --git a/kubernetes/templates/service.yaml b/kubernetes/templates/service.yaml
deleted file mode 100644
index 01454d4e..00000000
--- a/kubernetes/templates/service.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ template "invidious.fullname" . }}
- labels:
- app: {{ template "invidious.name" . }}
- chart: {{ .Chart.Name }}
- release: {{ .Release.Name }}
-spec:
- type: {{ .Values.service.type }}
- ports:
- - name: http
- port: {{ .Values.service.port }}
- targetPort: 3000
- selector:
- app: {{ template "invidious.name" . }}
- release: {{ .Release.Name }}
-{{- if .Values.service.loadBalancerIP }}
- loadBalancerIP: {{ .Values.service.loadBalancerIP }}
-{{- end }}
diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml
deleted file mode 100644
index 5000c2b6..00000000
--- a/kubernetes/values.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: invidious
-
-image:
- repository: quay.io/invidious/invidious
- tag: latest
- pullPolicy: Always
-
-replicaCount: 1
-
-autoscaling:
- enabled: false
- minReplicas: 1
- maxReplicas: 16
- targetCPUUtilizationPercentage: 50
-
-service:
- type: ClusterIP
- port: 3000
- #loadBalancerIP:
-
-resources: {}
- #requests:
- # cpu: 100m
- # memory: 64Mi
- #limits:
- # cpu: 800m
- # memory: 512Mi
-
-securityContext:
- allowPrivilegeEscalation: false
- runAsUser: 1000
- runAsGroup: 1000
- fsGroup: 1000
-
-# See https://github.com/bitnami/charts/tree/master/bitnami/postgresql
-postgresql:
- image:
- tag: 13
- auth:
- username: kemal
- password: kemal
- database: invidious
- primary:
- initdb:
- username: kemal
- password: kemal
- scriptsConfigMap: invidious-postgresql-init
-
-# Adapted from ../config/config.yml
-config:
- channel_threads: 1
- feed_threads: 1
- db:
- user: kemal
- password: kemal
- host: invidious-postgresql
- port: 5432
- dbname: invidious
- full_refresh: false
- https_only: false
- domain:
diff --git a/locales/ar.json b/locales/ar.json
index 877fb9ff..b6bab59b 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -15,13 +15,13 @@
"New password": "كلمة مرور جديدة",
"New passwords must match": "يَجبُ أن تكون كلمتا المرور متطابقتين",
"Authorize token?": "رمز التفويض؟",
- "Authorize token for `x`?": "السماح بالرمز المميز ل 'x'؟",
+ "Authorize token for `x`?": "السماح بالرمز المميز ل `x`؟",
"Yes": "نعم",
"No": "لا",
"Import and Export Data": "اِستيراد البيانات وتصديرها",
"Import": "استيراد",
"Import Invidious data": "استيراد بيانات JSON Invidious",
- "Import YouTube subscriptions": "استيراد اشتراكات YouTube/OPML",
+ "Import YouTube subscriptions": "استيراد الاشتراكات YouTube بتنسيق CSV أو OPML",
"Import FreeTube subscriptions (.db)": "استيراد اشتراكات فريتيوب (.db)",
"Import NewPipe subscriptions (.json)": "استيراد اشتراكات نيو بايب (.json)",
"Import NewPipe data (.zip)": "استيراد بيانات نيو بايب (.zip)",
@@ -41,7 +41,7 @@
"Time (h:mm:ss):": "الوقت (h:mm:ss):",
"Text CAPTCHA": "نص الكابتشا",
"Image CAPTCHA": "صورة الكابتشا",
- "Sign In": "تسجيل الدخول",
+ "Sign In": "إنشاء حساب",
"Register": "التسجيل",
"E-mail": "البريد الإلكتروني",
"Preferences": "الإعدادات",
@@ -170,7 +170,7 @@
"Password cannot be empty": "لا يمكن أن تكون كلمة السر فارغة",
"Password cannot be longer than 55 characters": "يجب أن لا تتعدى كلمة السر 55 حرفًا",
"Please log in": "الرجاء تسجيل الدخول",
- "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل 'x'",
+ "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل `x`",
"channel:`x`": "قناة:`x`",
"Deleted or invalid channel": "قناة ممسوحة او غير صالحة",
"This channel does not exist.": "هذه القناة غير موجودة.",
@@ -382,11 +382,11 @@
"videoinfo_watch_on_youTube": "مشاهدة على يوتيوب",
"videoinfo_youTube_embed_link": "مضمن",
"videoinfo_invidious_embed_link": "رابط مضمن",
- "user_created_playlists": "'x' إنشاء قوائم التشغيل",
- "user_saved_playlists": "قوائم التشغيل المحفوظة 'x'",
+ "user_created_playlists": "`x` إنشاء قوائم التشغيل",
+ "user_saved_playlists": "قوائم التشغيل المحفوظة `x`",
"Video unavailable": "الفيديو غير متوفر",
"search_filters_features_option_three_sixty": "360°",
- "download_subtitles": "ترجمات - 'x' (.vtt)",
+ "download_subtitles": "ترجمات - `x` (.vtt)",
"invidious": "الخيالي",
"preferences_save_player_pos_label": "حفظ موضع التشغيل: ",
"crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!",
@@ -483,7 +483,7 @@
"comments_view_x_replies_3": "عرض رد {{count}}",
"comments_view_x_replies_4": "عرض الردود {{count}}",
"comments_view_x_replies_5": "عرض رد {{count}}",
- "search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
+ "search_message_use_another_instance": "يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
"comments_points_count_0": "{{count}} نقطة",
"comments_points_count_1": "نقطة واحدة",
"comments_points_count_2": "نقطتان",
@@ -548,5 +548,21 @@
"generic_button_rss": "RSS",
"channel_tab_releases_label": "الإصدارات",
"playlist_button_add_items": "إضافة مقاطع فيديو",
- "channel_tab_podcasts_label": "البودكاست"
+ "channel_tab_podcasts_label": "البودكاست",
+ "generic_channels_count_0": "{{count}} قناة",
+ "generic_channels_count_1": "{{count}} قناة",
+ "generic_channels_count_2": "{{count}} قناتان",
+ "generic_channels_count_3": "{{count}} قنوات",
+ "generic_channels_count_4": "{{count}} قنوات",
+ "generic_channels_count_5": "{{count}} قناة",
+ "Import YouTube watch history (.json)": "استيراد سجل مشاهدة YouTube بصيغة (.json)",
+ "toggle_theme": "تبديل الموضوع",
+ "Add to playlist": "أضف إلى قائمة التشغيل",
+ "Add to playlist: ": "أضف إلى قائمة التشغيل: ",
+ "Answer": "الرد",
+ "Search for videos": "ابحث عن مقاطع الفيديو",
+ "The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
+ "carousel_slide": "الشريحة {{current}} من {{total}}",
+ "carousel_skip": "تخطي الكاروسيل",
+ "carousel_go_to": "انتقل إلى الشريحة `x`"
}
diff --git a/locales/la.json b/locales/be.json
index 0967ef42..0967ef42 100644
--- a/locales/la.json
+++ b/locales/be.json
diff --git a/locales/bg.json b/locales/bg.json
new file mode 100644
index 00000000..baa683c9
--- /dev/null
+++ b/locales/bg.json
@@ -0,0 +1,497 @@
+{
+ "Korean (auto-generated)": "Корейски (автоматично генерирано)",
+ "search_filters_features_option_three_sixty": "360°",
+ "published - reverse": "публикувани - в обратен ред",
+ "preferences_quality_dash_option_worst": "Най-ниско качество",
+ "Password is a required field": "Парола е задължитело поле",
+ "channel_tab_podcasts_label": "Подкасти",
+ "Token is expired, please try again": "Токенът е изтекъл, моля опитайте отново",
+ "Turkish": "Турски",
+ "preferences_save_player_pos_label": "Запази позицията на плейъра: ",
+ "View Reddit comments": "Виж Reddit коментари",
+ "Export data as JSON": "Експортиране на Invidious информацията като JSON",
+ "About": "За сайта",
+ "Save preferences": "Запази промените",
+ "Load more": "Зареди още",
+ "Import/export": "Импортиране/експортиране",
+ "Albanian": "Албански",
+ "New password": "Нова парола",
+ "Southern Sotho": "Южен Сото",
+ "channel_tab_videos_label": "Видеа",
+ "Spanish (Mexico)": "Испански (Мексико)",
+ "preferences_player_style_label": "Стил на плейъра: ",
+ "preferences_region_label": "Държавата на съдържанието: ",
+ "Premieres in `x`": "Премиера в `x`",
+ "Watch history": "История на гледане",
+ "generic_subscriptions_count": "{{count}} абонамент",
+ "generic_subscriptions_count_plural": "{{count}} абонамента",
+ "preferences_continue_label": "Пускай следващото видео автоматично: ",
+ "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. Натисни тук за да видиш коментарите, но обърни внимание, че може да отнеме повече време да заредят.",
+ "Polish": "Полски",
+ "Icelandic": "Исландски",
+ "preferences_local_label": "Пускане на видеа през прокси: ",
+ "Hebrew": "Иврит",
+ "Fallback captions: ": "Резервни надписи: ",
+ "search_filters_title": "Филтри",
+ "search_filters_apply_button": "Приложете избрани филтри",
+ "Download is disabled": "Изтеглянето е деактивирано",
+ "User ID is a required field": "Потребителско име е задължително поле",
+ "comments_points_count": "{{count}} точка",
+ "comments_points_count_plural": "{{count}} точки",
+ "next_steps_error_message_go_to_youtube": "Отидеш в YouTube",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "search_filters_type_option_video": "Видео",
+ "Spanish (Latin America)": "Испански (Латинска Америка)",
+ "Download as: ": "Изтегли като: ",
+ "Default": "По подразбиране",
+ "search_filters_sort_option_views": "Гледания",
+ "search_filters_features_option_four_k": "4K",
+ "Igbo": "Игбо",
+ "Subscriptions": "Абонаменти",
+ "German (auto-generated)": "Немски (автоматично генерирано)",
+ "`x` is live": "`x` е на живо",
+ "Azerbaijani": "Азербайджански",
+ "Premieres `x`": "Премиера `x`",
+ "Japanese (auto-generated)": "Японски (автоматично генерирано)",
+ "preferences_quality_option_medium": "Средно",
+ "footer_donate_page": "Даряване",
+ "Show replies": "Покажи отговорите",
+ "Esperanto": "Есперанто",
+ "search_message_change_filters_or_query": "Опитай да разшириш търсенето си и/или да смениш филтрите.",
+ "CAPTCHA enabled: ": "Активиране на CAPTCHA: ",
+ "View playlist on YouTube": "Виж плейлиста в YouTube",
+ "crash_page_before_reporting": "Преди докладването на бъг, бъди сигурен, че си:",
+ "Top enabled: ": "Активиране на страница с топ видеа: ",
+ "preferences_quality_dash_option_best": "Най-високо",
+ "search_filters_duration_label": "Продължителност",
+ "Slovak": "Словашки",
+ "Channel Sponsor": "Канален спонсор",
+ "generic_videos_count": "{{count}} видео",
+ "generic_videos_count_plural": "{{count}} видеа",
+ "videoinfo_started_streaming_x_ago": "Започна да излъчва преди `x`",
+ "videoinfo_youTube_embed_link": "Вграждане",
+ "channel_tab_streams_label": "Стриймове",
+ "oldest": "най-стари",
+ "playlist_button_add_items": "Добавяне на видеа",
+ "Import NewPipe data (.zip)": "Импортиране на NewPipe информация (.zip)",
+ "Clear watch history": "Изчистване на историята на гледане",
+ "generic_count_minutes": "{{count}} минута",
+ "generic_count_minutes_plural": "{{count}} минути",
+ "published": "публикувани",
+ "Show annotations": "Покажи анотации",
+ "Login enabled: ": "Активиране на впизване: ",
+ "Somali": "Сомалийски",
+ "YouTube comment permalink": "Постоянна връзка на коментарите на YouTube",
+ "Kurdish": "Кюрдски",
+ "search_filters_date_option_hour": "Последния час",
+ "Lao": "Лаоски",
+ "Maltese": "Малтийски",
+ "Register": "Регистрация",
+ "View channel on YouTube": "Виж канала в YouTube",
+ "Playlist privacy": "Поверителен плейлист",
+ "preferences_unseen_only_label": "Показвай само негледаните: ",
+ "Gujarati": "Гуджарати",
+ "Please log in": "Моля влезте",
+ "search_filters_sort_option_rating": "Рейтинг",
+ "Manage subscriptions": "Управление на абонаментите",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_watch_history_label": "Активирай историята на гледане: ",
+ "user_saved_playlists": "`x` запази плейлисти",
+ "preferences_extend_desc_label": "Автоматично разшири описанието на видеото ",
+ "preferences_max_results_label": "Брой видеа показани на началната страница: ",
+ "Spanish (Spain)": "Испански (Испания)",
+ "invidious": "Invidious",
+ "crash_page_refresh": "пробвал да <a href=\"`x`\">опресниш страницата</a>",
+ "Image CAPTCHA": "CAPTCHA с Изображение",
+ "search_filters_features_option_hd": "HD",
+ "Chinese (Hong Kong)": "Китайски (Хонг Конг)",
+ "Import Invidious data": "Импортиране на Invidious JSON информацията",
+ "Blacklisted regions: ": "Неразрешени региони: ",
+ "Only show latest video from channel: ": "Показвай само най-новите видеа в канала: ",
+ "Hmong": "Хмонг",
+ "French": "Френски",
+ "search_filters_type_option_channel": "Канал",
+ "Artist: ": "Артист: ",
+ "generic_count_months": "{{count}} месец",
+ "generic_count_months_plural": "{{count}} месеца",
+ "preferences_annotations_subscribed_label": "Показвай анотаций по подразбиране за абонирани канали? ",
+ "search_message_use_another_instance": " Можеш също да <a href=\"`x`\">търсиш на друга инстанция</a>.",
+ "Danish": "Датски",
+ "generic_subscribers_count": "{{count}} абонат",
+ "generic_subscribers_count_plural": "{{count}} абоната",
+ "Galician": "Галисий",
+ "newest": "най-нови",
+ "Empty playlist": "Плейлиста е празен",
+ "download_subtitles": "Субритри - `x` (.vtt)",
+ "preferences_category_misc": "Различни предпочитания",
+ "Uzbek": "Узбекски",
+ "View JavaScript license information.": "Виж Javascript лиценза.",
+ "Filipino": "Филипински",
+ "Malagasy": "Мадагаскарски",
+ "generic_button_save": "Запиши",
+ "Dark mode: ": "Тъмен режим: ",
+ "Public": "Публичен",
+ "Basque": "Баскски",
+ "channel:`x`": "Канал:`x`",
+ "Armenian": "Арменски",
+ "This channel does not exist.": "Този канал не съществува.",
+ "Luxembourgish": "Люксембургски",
+ "preferences_related_videos_label": "Покажи подобни видеа: ",
+ "English": "Английски",
+ "Delete account": "Изтриване на акаунт",
+ "Gaming": "Игри",
+ "Video mode": "Видео режим",
+ "preferences_dark_mode_label": "Тема: ",
+ "crash_page_search_issue": "потърсил за <a href=\"`x`\">съществуващи проблеми в GitHub</a>",
+ "preferences_category_subscription": "Предпочитания за абонаменти",
+ "last": "най-скорощни",
+ "Chinese (Simplified)": "Китайски (Опростен)",
+ "Could not create mix.": "Създаването на микс е неуспешно.",
+ "generic_button_cancel": "Отказ",
+ "search_filters_type_option_movie": "Филм",
+ "search_filters_date_option_year": "Тази година",
+ "Swedish": "Шведски",
+ "Previous page": "Предишна страница",
+ "none": "нищо",
+ "popular": "най-популярни",
+ "Unsubscribe": "Отписване",
+ "Slovenian": "Словенски",
+ "Nepali": "Непалски",
+ "Time (h:mm:ss):": "Време (h:mm:ss):",
+ "English (auto-generated)": "Английски (автоматично генерирано)",
+ "search_filters_sort_label": "Сортирай по",
+ "View more comments on Reddit": "Виж повече коментари в Reddit",
+ "Sinhala": "Синхалски",
+ "preferences_feed_menu_label": "Меню с препоръки: ",
+ "preferences_autoplay_label": "Автоматично пускане: ",
+ "Pashto": "Пущунски",
+ "English (United States)": "Английски (САЩ)",
+ "Sign In": "Вход",
+ "subscriptions_unseen_notifs_count": "{{count}} невидяно известие",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} невидяни известия",
+ "Log in": "Вход",
+ "Engagement: ": "Участие: ",
+ "Album: ": "Албум: ",
+ "preferences_speed_label": "Скорост по подразбиране: ",
+ "Import FreeTube subscriptions (.db)": "Импортиране на FreeTube абонаменти (.db)",
+ "preferences_quality_option_dash": "DASH (адаптивно качество)",
+ "preferences_show_nick_label": "Показвай потребителското име отгоре: ",
+ "Private": "Частен",
+ "Samoan": "Самоански",
+ "preferences_notifications_only_label": "Показвай само известията (ако има такива): ",
+ "Create playlist": "Създаване на плейлист",
+ "next_steps_error_message_refresh": "Опресниш",
+ "Top": "Топ",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "Malayalam": "Малаялам",
+ "Token": "Токен",
+ "preferences_comments_label": "Коментари по подразбиране: ",
+ "Movies": "Филми",
+ "light": "светла",
+ "Unlisted": "Скрит",
+ "preferences_category_admin": "Администраторни предпочитания",
+ "Erroneous token": "Невалиден токен",
+ "No": "Не",
+ "CAPTCHA is a required field": "CAPTCHA е задължително поле",
+ "Video unavailable": "Неналично видео",
+ "footer_source_code": "Изходен код",
+ "New passwords must match": "Новите пароли трябва да съвпадат",
+ "Playlist does not exist.": "Плейлиста не съществува.",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Експортиране на абонаментите като OPML (за NewPipe и FreeTube)",
+ "search_filters_duration_option_short": "Кратко (< 4 минути)",
+ "search_filters_duration_option_long": "Дълго (> 20 минути)",
+ "tokens_count": "{{count}} токен",
+ "tokens_count_plural": "{{count}} токена",
+ "Yes": "Да",
+ "Dutch": "Холандски",
+ "Arabic": "Арабски",
+ "An alternative front-end to YouTube": "Алтернативен преден план на YouTube",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Виж `x` коментар",
+ "": "Виж `x` коментари"
+ },
+ "Chinese (China)": "Китайски (Китай)",
+ "Italian (auto-generated)": "Италиански (автоматично генерирано)",
+ "alphabetically - reverse": "обратно на азбучния ред",
+ "channel_tab_shorts_label": "Shorts",
+ "`x` marked it with a ❤": "`x` го маркира със ❤",
+ "Current version: ": "Текуща версия: ",
+ "channel_tab_community_label": "Общност",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_360p": "360p",
+ "`x` uploaded a video": "`x` качи видео",
+ "Welsh": "Уелски",
+ "search_message_no_results": "Няма намерени резултати.",
+ "channel_tab_releases_label": "Версии",
+ "Bangla": "Бенгалски",
+ "preferences_quality_dash_option_144p": "144p",
+ "Indonesian": "Индонезийски",
+ "`x` ago": "преди `x`",
+ "Invidious Private Feed for `x`": "Invidious персонални видеа за `x`",
+ "Finnish": "Финландски",
+ "Amharic": "Амхарски",
+ "Malay": "Малайски",
+ "Interlingue": "Интерлинг",
+ "search_filters_date_option_month": "Този месец",
+ "Georgian": "Грузински",
+ "Xhosa": "Кхоса",
+ "Marathi": "Маратхи",
+ "Yoruba": "Йоруба",
+ "Song: ": "Музика: ",
+ "Scottish Gaelic": "Шотландски гелски",
+ "search_filters_features_label": "Функции",
+ "preferences_quality_label": "Предпочитано качество на видеото: ",
+ "generic_channels_count": "{{count}} канал",
+ "generic_channels_count_plural": "{{count}} канала",
+ "Croatian": "Хърватски",
+ "Thai": "Тайски",
+ "Chinese (Taiwan)": "Китайски (Тайван)",
+ "youtube": "YouTube",
+ "Source available here.": "Източник наличен тук.",
+ "LIVE": "На живо",
+ "Ukrainian": "Украински",
+ "Russian": "Руски",
+ "Tajik": "Таджикски",
+ "Token manager": "Управляване на токени",
+ "preferences_quality_dash_label": "Предпочитано DASH качество на видеото: ",
+ "adminprefs_modified_source_code_url_label": "URL до хранилището на променения изходен код",
+ "Japanese": "Японски",
+ "Title": "Заглавие",
+ "Authorize token for `x`?": "Разреши токена за `x`?",
+ "reddit": "Reddit",
+ "permalink": "постоянна връзка",
+ "Trending": "На върха",
+ "Turkish (auto-generated)": "Турски (автоматично генерирано)",
+ "Bulgarian": "Български",
+ "Indonesian (auto-generated)": "Индонезийски (автоматично генерирано)",
+ "Enable web notifications": "Активирай уеб известия",
+ "Western Frisian": "Западен фризски",
+ "search_filters_date_option_week": "Тази седмица",
+ "Yiddish": "Идиш",
+ "preferences_category_player": "Предпочитания за плейъра",
+ "Shared `x` ago": "Споделено преди `x`",
+ "Swahili": "Суахили",
+ "Portuguese (auto-generated)": "Португалски (автоматично генерирано)",
+ "generic_count_years": "{{count}} година",
+ "generic_count_years_plural": "{{count}} години",
+ "Wilson score: ": "Wilson оценка: ",
+ "Genre: ": "Жанр: ",
+ "videoinfo_invidious_embed_link": "Вграждане на линк",
+ "Popular enabled: ": "Активиране на популярната страница: ",
+ "Wrong username or password": "Грешно потребителско име или парола",
+ "Vietnamese": "Виетнамски",
+ "alphabetically": "по азбучен ред",
+ "Afrikaans": "Африкаанс",
+ "Zulu": "Зулуски",
+ "(edited)": "(редактирано)",
+ "Whitelisted regions: ": "Разрешени региони: ",
+ "Spanish (auto-generated)": "Испански (автоматично генерирано)",
+ "Could not fetch comments": "Получаването на коментарите е неуспешно",
+ "Sindhi": "Синдхи",
+ "News": "Новини",
+ "preferences_video_loop_label": "Винаги повтаряй: ",
+ "%A %B %-d, %Y": "%-d %B %Y, %A",
+ "preferences_quality_option_small": "Ниско",
+ "English (United Kingdom)": "Английски (Великобритания)",
+ "Rating: ": "Рейтинг: ",
+ "channel_tab_playlists_label": "Плейлисти",
+ "generic_button_edit": "Редактирай",
+ "Report statistics: ": "Активиране на статистики за репортиране: ",
+ "Cebuano": "Себуано",
+ "Chinese (Traditional)": "Китайски (Традиционен)",
+ "generic_playlists_count": "{{count}} плейлист",
+ "generic_playlists_count_plural": "{{count}} плейлиста",
+ "Import NewPipe subscriptions (.json)": "Импортиране на NewPipe абонаменти (.json)",
+ "Preferences": "Предпочитания",
+ "Subscribe": "Абониране",
+ "Import and Export Data": "Импортиране и експортиране на информация",
+ "preferences_quality_option_hd720": "HD720",
+ "search_filters_type_option_playlist": "Плейлист",
+ "Serbian": "Сръбски",
+ "Kazakh": "Казахски",
+ "Telugu": "Телугу",
+ "search_filters_features_option_purchased": "Купено",
+ "revoke": "отмяна",
+ "search_filters_sort_option_date": "Дата на качване",
+ "preferences_category_data": "Предпочитания за информацията",
+ "search_filters_date_option_none": "Всякаква дата",
+ "Log out": "Излизане",
+ "Search": "Търсене",
+ "preferences_quality_dash_option_auto": "Автоматично",
+ "dark": "тъмна",
+ "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)",
+ "crash_page_report_issue": "Ако никои от горепосочените не помогнаха, моля <a href=\"`x`\">отворете нов проблем в GitHub</a> (предпочитано на Английски) и добавете следния текст в съобщението (НЕ превеждайте този текст):",
+ "Czech": "Чешки",
+ "crash_page_switch_instance": "пробвал да <a href=\"`x`\">ползваш друга инстанция</a>",
+ "generic_count_weeks": "{{count}} седмица",
+ "generic_count_weeks_plural": "{{count}} седмици",
+ "search_filters_features_option_subtitles": "Субтитри",
+ "videoinfo_watch_on_youTube": "Виж в YouTube",
+ "Portuguese": "Португалски",
+ "Music in this video": "Музика в това видео",
+ "Hide replies": "Скрий отговорите",
+ "Password cannot be longer than 55 characters": "Паролата не може да бъде по-дълга от 55 символа",
+ "footer_modfied_source_code": "Променен изходен код",
+ "Bosnian": "Босненски",
+ "Deleted or invalid channel": "Изтрит или невалиден канал",
+ "Popular": "Популярно",
+ "search_filters_type_label": "Тип",
+ "preferences_locale_label": "Език: ",
+ "Playlists": "Плейлисти",
+ "generic_button_rss": "RSS",
+ "Export": "Експортиране",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "Erroneous challenge": "Невалиден тест",
+ "History": "История",
+ "generic_count_hours": "{{count}} час",
+ "generic_count_hours_plural": "{{count}} часа",
+ "Registration enabled: ": "Активиране на регистрация: ",
+ "Music": "Музика",
+ "Incorrect password": "Грешна парола",
+ "Persian": "Перскийски",
+ "Import": "Импортиране",
+ "Import/export data": "Импортиране/Експортиране на информация",
+ "Shared `x`": "Споделено `x`",
+ "Javanese": "Явански",
+ "French (auto-generated)": "Френски (автоматично генерирано)",
+ "Norwegian Bokmål": "Норвежки",
+ "Catalan": "Каталунски",
+ "Hindi": "Хинди",
+ "Tamil": "Тамилски",
+ "search_filters_features_option_live": "На живо",
+ "crash_page_read_the_faq": "прочел <a href=\"`x`\">Често задавани въпроси (FAQ)</a>",
+ "preferences_default_home_label": "Начална страница по подразбиране: ",
+ "Download": "Изтегляне",
+ "Show less": "Покажи по-малко",
+ "Password": "Парола",
+ "User ID": "Потребителско име",
+ "Subscription manager": "Управляване на абонаменти",
+ "search": "търсене",
+ "No such user": "Няма такъв потребител",
+ "View privacy policy.": "Виж политиката за поверителност.",
+ "Only show latest unwatched video from channel: ": "Показвай само най-новите негледани видеа в канала: ",
+ "user_created_playlists": "`x` създаде плейлисти",
+ "Editing playlist `x`": "Редактиране на плейлист `x`",
+ "preferences_thin_mode_label": "Тънък режим: ",
+ "E-mail": "Имейл",
+ "Haitian Creole": "Хаитянски креол",
+ "Irish": "Ирландски",
+ "channel_tab_channels_label": "Канали",
+ "Delete account?": "Изтрий акаунта?",
+ "Redirect homepage to feed: ": "Препращане на началната страница до препоръки ",
+ "Urdu": "Урду",
+ "preferences_vr_mode_label": "Интерактивни 360 градусови видеа (изисква WebGL): ",
+ "Password cannot be empty": "Паролата не може да бъде празна",
+ "Mongolian": "Монголски",
+ "Authorize token?": "Разреши токена?",
+ "search_filters_type_option_all": "Всякакъв тип",
+ "Romanian": "Румънски",
+ "Belarusian": "Беларуски",
+ "channel name - reverse": "име на канал - в обратен ред",
+ "Erroneous CAPTCHA": "Невалидна CAPTCHA",
+ "Watch on YouTube": "Гледай в YouTube",
+ "search_filters_features_option_location": "Местоположение",
+ "Could not pull trending pages.": "Получаването на трендинг страниците е неуспешно.",
+ "German": "Немски",
+ "search_filters_features_option_c_commons": "Creative Commons",
+ "Family friendly? ": "За всяка възраст? ",
+ "Hidden field \"token\" is a required field": "Скритото поле \"токен\" е задължително поле",
+ "Russian (auto-generated)": "Руски (автоматично генерирано)",
+ "preferences_quality_dash_option_480p": "480p",
+ "Corsican": "Корсикански",
+ "Macedonian": "Македонски",
+ "comments_view_x_replies": "Виж {{count}} отговор",
+ "comments_view_x_replies_plural": "Виж {{count}} отговора",
+ "footer_original_source_code": "Оригинален изходен код",
+ "Import YouTube subscriptions": "Импортиране на YouTube/OPML абонаменти",
+ "Lithuanian": "Литовски",
+ "Nyanja": "Нянджа",
+ "Updated `x` ago": "Актуализирано преди `x`",
+ "JavaScript license information": "Информация за Javascript лиценза",
+ "Spanish": "Испански",
+ "Latin": "Латински",
+ "Shona": "Шона",
+ "Portuguese (Brazil)": "Португалски (Бразилия)",
+ "Show more": "Покажи още",
+ "Clear watch history?": "Изчисти историята на търсене?",
+ "Manage tokens": "Управление на токени",
+ "Hausa": "Хауса",
+ "search_filters_features_option_vr180": "VR180",
+ "preferences_category_visual": "Визуални предпочитания",
+ "Italian": "Италиански",
+ "preferences_volume_label": "Сила на звука на плейъра: ",
+ "error_video_not_in_playlist": "Заявеното видео не съществува в този плейлист. <a href=\"`x`\">Натиснете тук за началната страница на плейлиста.</a>",
+ "preferences_listen_label": "Само звук по подразбиране: ",
+ "Dutch (auto-generated)": "Холандски (автоматично генерирано)",
+ "preferences_captions_label": "Надписи по подразбиране: ",
+ "generic_count_days": "{{count}} ден",
+ "generic_count_days_plural": "{{count}} дни",
+ "Hawaiian": "Хавайски",
+ "Could not get channel info.": "Получаването на информация за канала е неуспешно.",
+ "View as playlist": "Виж като плейлист",
+ "Vietnamese (auto-generated)": "Виетнамски (автоматично генерирано)",
+ "search_filters_duration_option_none": "Всякаква продължителност",
+ "preferences_quality_dash_option_240p": "240p",
+ "Latvian": "Латвийски",
+ "search_filters_features_option_hdr": "HDR",
+ "preferences_sort_label": "Сортирай видеата по: ",
+ "Estonian": "Естонски",
+ "Hidden field \"challenge\" is a required field": "Скритото поле \"тест\" е задължително поле",
+ "footer_documentation": "Документация",
+ "Kyrgyz": "Киргизски",
+ "preferences_continue_autoplay_label": "Пускай следващотото видео автоматично: ",
+ "Chinese": "Китайски",
+ "search_filters_sort_option_relevance": "Уместност",
+ "source": "източник",
+ "Fallback comments: ": "Резервни коментари: ",
+ "preferences_automatic_instance_redirect_label": "Автоматично препращане на инстанция (чрез redirect.invidious.io): ",
+ "Maori": "Маори",
+ "generic_button_delete": "Изтрий",
+ "Import YouTube playlist (.csv)": "Импортиране на YouTube плейлист (.csv)",
+ "Switch Invidious Instance": "Смени Invidious инстанция",
+ "channel name": "име на канал",
+ "Audio mode": "Аудио режим",
+ "search_filters_type_option_show": "Сериал",
+ "search_filters_date_option_today": "Днес",
+ "search_filters_features_option_three_d": "3D",
+ "next_steps_error_message": "След което можеш да пробваш да: ",
+ "Hide annotations": "Скрий анотации",
+ "Standard YouTube license": "Стандартен YouTube лиценз",
+ "Text CAPTCHA": "Текст CAPTCHA",
+ "Log in/register": "Вход/регистрация",
+ "Punjabi": "Пенджаби",
+ "Change password": "Смяна на паролата",
+ "License: ": "Лиценз: ",
+ "search_filters_duration_option_medium": "Средно (4 - 20 минути)",
+ "Delete playlist": "Изтриване на плейлист",
+ "Delete playlist `x`?": "Изтрий плейлиста `x`?",
+ "Korean": "Корейски",
+ "Export subscriptions as OPML": "Експортиране на абонаментите като OPML",
+ "unsubscribe": "отписване",
+ "View YouTube comments": "Виж YouTube коментарите",
+ "Kannada": "Каннада",
+ "Not a playlist.": "Невалиден плейлист.",
+ "Wrong answer": "Грешен отговор",
+ "Released under the AGPLv3 on Github.": "Публикувано под AGPLv3 в GitHub.",
+ "Burmese": "Бирмански",
+ "Sundanese": "Сундански",
+ "Hungarian": "Унгарски",
+ "generic_count_seconds": "{{count}} секунда",
+ "generic_count_seconds_plural": "{{count}} секунди",
+ "search_filters_date_label": "Дата на качване",
+ "Greek": "Гръцки",
+ "crash_page_you_found_a_bug": "Изглежда намери бъг в Invidious!",
+ "View all playlists": "Виж всички плейлисти",
+ "Khmer": "Кхмерски",
+ "preferences_annotations_label": "Покажи анотаций по подразбиране: ",
+ "generic_views_count": "{{count}} гледане",
+ "generic_views_count_plural": "{{count}} гледания",
+ "Next page": "Следваща страница",
+ "Import YouTube watch history (.json)": "Импортиране на историята на гледане от YouTube (.json)",
+ "toggle_theme": "Смени темата",
+ "Add to playlist": "Добави към плейлист",
+ "Add to playlist: ": "Добави към плейлист: ",
+ "Answer": "Отговор",
+ "Search for videos": "Търсене на видеа",
+ "The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора."
+}
diff --git a/locales/bn.json b/locales/bn.json
index 9d1c7b24..501a1ca3 100644
--- a/locales/bn.json
+++ b/locales/bn.json
@@ -90,5 +90,7 @@
"preferences_quality_option_medium": "মধ্যম",
"preferences_quality_option_small": "ছোট",
"preferences_quality_dash_option_1080p": "১০৮০পি",
- "preferences_quality_dash_option_720p": "৭২০পি"
+ "preferences_quality_dash_option_720p": "৭২০পি",
+ "Add to playlist": "প্লেলিস্টে যোগ করুন",
+ "Add to playlist: ": "প্লেলিস্টে যোগ করুন: "
}
diff --git a/locales/ca.json b/locales/ca.json
index 4392c2a9..bbcadf89 100644
--- a/locales/ca.json
+++ b/locales/ca.json
@@ -476,5 +476,18 @@
"Redirect homepage to feed: ": "Redirigeix la pàgina d'inici al feed: ",
"Standard YouTube license": "Llicència estàndard de YouTube",
"Download is disabled": "Les baixades s'han inhabilitat",
- "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)"
+ "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)",
+ "channel_tab_podcasts_label": "Podcasts",
+ "playlist_button_add_items": "Afegeix vídeos",
+ "generic_button_save": "Desa",
+ "generic_button_cancel": "Cancel·la",
+ "channel_tab_releases_label": "Publicacions",
+ "generic_channels_count": "{{count}} canal",
+ "generic_channels_count_plural": "{{count}} canals",
+ "generic_button_edit": "Edita",
+ "generic_button_rss": "RSS",
+ "generic_button_delete": "Suprimeix",
+ "Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)",
+ "Answer": "Resposta",
+ "toggle_theme": "Commuta el tema"
}
diff --git a/locales/cs.json b/locales/cs.json
index b2cce0bd..6e66178d 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Import a export dat",
"Import": "Importovat",
"Import Invidious data": "Importovat JSON údaje Invidious",
- "Import YouTube subscriptions": "Importovat odběry z YouTube/OPML",
+ "Import YouTube subscriptions": "Importovat odběry z YouTube CSV nebo OPML",
"Import FreeTube subscriptions (.db)": "Importovat odběry z FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importovat odběry z NewPipe (.json)",
"Import NewPipe data (.zip)": "Importovat údeje z NewPipe (.zip)",
@@ -471,7 +471,7 @@
"search_filters_title": "Filtry",
"search_filters_duration_option_medium": "Střední (4 - 20 minut)",
"search_filters_duration_option_long": "Dlouhá (> 20 minut)",
- "search_message_use_another_instance": " Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.",
+ "search_message_use_another_instance": "Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.",
"search_filters_features_label": "Vlastnosti",
"search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180",
@@ -500,5 +500,18 @@
"channel_tab_releases_label": "Vydání",
"generic_button_edit": "Upravit",
"generic_button_rss": "RSS",
- "playlist_button_add_items": "Přidat videa"
+ "playlist_button_add_items": "Přidat videa",
+ "generic_channels_count_0": "{{count}} kanál",
+ "generic_channels_count_1": "{{count}} kanály",
+ "generic_channels_count_2": "{{count}} kanálů",
+ "Import YouTube watch history (.json)": "Importovat historii sledování z YouTube (.json)",
+ "toggle_theme": "Přepnout motiv",
+ "Add to playlist": "Přidat do playlistu",
+ "Add to playlist: ": "Přidat do playlistu: ",
+ "Answer": "Odpověď",
+ "Search for videos": "Hledat videa",
+ "The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
+ "carousel_slide": "Snímek {{current}} z {{total}}",
+ "carousel_skip": "Přeskočit galerii",
+ "carousel_go_to": "Přejít na snímek `x`"
}
diff --git a/locales/cy.json b/locales/cy.json
new file mode 100644
index 00000000..566e73e1
--- /dev/null
+++ b/locales/cy.json
@@ -0,0 +1,385 @@
+{
+ "Time (h:mm:ss):": "Amser (h:mm:ss):",
+ "Password": "Cyfrinair",
+ "preferences_quality_dash_option_auto": "Awtomatig",
+ "preferences_quality_dash_option_best": "Gorau",
+ "preferences_quality_dash_option_worst": "Gwaethaf",
+ "preferences_quality_dash_option_360p": "360p",
+ "published": "dyddiad cyhoeddi",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "preferences_quality_dash_option_480p": "480p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "preferences_comments_label": "Ffynhonnell sylwadau: ",
+ "preferences_captions_label": "Isdeitlau rhagosodedig: ",
+ "youtube": "YouTube",
+ "reddit": "Reddit",
+ "Fallback captions: ": "Isdeitlau amgen: ",
+ "preferences_related_videos_label": "Dangos fideos perthnasol: ",
+ "dark": "tywyll",
+ "preferences_dark_mode_label": "Thema: ",
+ "light": "golau",
+ "preferences_sort_label": "Trefnu fideo yn ôl: ",
+ "Import/export data": "Mewnforio/allforio data",
+ "Delete account": "Dileu eich cyfrif",
+ "preferences_category_admin": "Hoffterau gweinyddu",
+ "playlist_button_add_items": "Ychwanegu fideos",
+ "Delete playlist": "Dileu'r rhestr chwarae",
+ "Create playlist": "Creu rhestr chwarae",
+ "Show less": "Dangos llai",
+ "Show more": "Dangos rhagor",
+ "Watch on YouTube": "Gwylio ar YouTube",
+ "search_message_no_results": "Dim canlyniadau.",
+ "search_message_change_filters_or_query": "Ceisiwch ehangu eich chwiliad ac/neu newid yr hidlyddion.",
+ "License: ": "Trwydded: ",
+ "Standard YouTube license": "Trwydded safonol YouTube",
+ "Family friendly? ": "Addas i bawb? ",
+ "Wilson score: ": "Sgôr Wilson: ",
+ "Show replies": "Dangos ymatebion",
+ "Music in this video": "Cerddoriaeth yn y fideo hwn",
+ "Artist: ": "Artist: ",
+ "Erroneous CAPTCHA": "CAPTCHA anghywir",
+ "This channel does not exist.": "Dyw'r sianel hon ddim yn bodoli.",
+ "Not a playlist.": "Ddim yn rhestr chwarae.",
+ "Could not fetch comments": "Wedi methu llwytho sylwadau",
+ "Playlist does not exist.": "Dyw'r rhestr chwarae ddim yn bodoli.",
+ "Erroneous challenge": "Her annilys",
+ "channel_tab_podcasts_label": "Podlediadau",
+ "channel_tab_playlists_label": "Rhestrau chwarae",
+ "channel_tab_streams_label": "Fideos byw",
+ "crash_page_read_the_faq": "darllen y <a href=\"`x`\">cwestiynau cyffredin</a>",
+ "crash_page_switch_instance": "ceisio <a href=\"`x`\">defnyddio gweinydd arall</a>",
+ "crash_page_refresh": "ceisio <a href=\"`x`\">ail-lwytho'r dudalen</a>",
+ "search_filters_features_option_four_k": "4K",
+ "search_filters_features_label": "Nodweddion",
+ "search_filters_duration_option_medium": "Canolig (4 - 20 munud)",
+ "search_filters_features_option_live": "Yn fyw",
+ "search_filters_duration_option_long": "Hir (> 20 munud)",
+ "search_filters_date_option_year": "Eleni",
+ "search_filters_type_label": "Math",
+ "search_filters_date_option_month": "Y mis hwn",
+ "generic_views_count_0": "{{count}} o wyliadau",
+ "generic_views_count_1": "{{count}} gwyliad",
+ "generic_views_count_2": "{{count}} wyliad",
+ "generic_views_count_3": "{{count}} o wyliadau",
+ "generic_views_count_4": "{{count}} o wyliadau",
+ "generic_views_count_5": "{{count}} o wyliadau",
+ "Answer": "Ateb",
+ "Add to playlist: ": "Ychwanegu at y rhestr chwarae: ",
+ "Add to playlist": "Ychwanegu at y rhestr chwarae",
+ "generic_button_cancel": "Diddymu",
+ "generic_button_rss": "RSS",
+ "LIVE": "YN FYW",
+ "Import YouTube watch history (.json)": "Mewnforio hanes gwylio YouTube (.json)",
+ "generic_videos_count_0": "{{count}} fideo",
+ "generic_videos_count_1": "{{count}} fideo",
+ "generic_videos_count_2": "{{count}} fideo",
+ "generic_videos_count_3": "{{count}} fideo",
+ "generic_videos_count_4": "{{count}} fideo",
+ "generic_videos_count_5": "{{count}} fideo",
+ "generic_subscribers_count_0": "{{count}} tanysgrifiwr",
+ "generic_subscribers_count_1": "{{count}} tanysgrifiwr",
+ "generic_subscribers_count_2": "{{count}} danysgrifiwr",
+ "generic_subscribers_count_3": "{{count}} thanysgrifiwr",
+ "generic_subscribers_count_4": "{{count}} o danysgrifwyr",
+ "generic_subscribers_count_5": "{{count}} o danysgrifwyr",
+ "Authorize token?": "Awdurdodi'r tocyn?",
+ "Authorize token for `x`?": "Awdurdodi'r tocyn ar gyfer `x`?",
+ "English": "Saesneg",
+ "English (United Kingdom)": "Saesneg (Y Deyrnas Unedig)",
+ "English (United States)": "Saesneg (Yr Unol Daleithiau)",
+ "Afrikaans": "Affricaneg",
+ "English (auto-generated)": "Saesneg (awtomatig)",
+ "Amharic": "Amhareg",
+ "Albanian": "Albaneg",
+ "Arabic": "Arabeg",
+ "crash_page_report_issue": "Os nad yw'r awgrymiadau uchod wedi helpu, <a href=\"`x`\">codwch 'issue' newydd ar Github </a> (yn Saesneg, gorau oll) a chynnwys y testun canlynol yn eich neges (peidiwch â chyfieithu'r testun hwn):",
+ "Search for videos": "Chwilio am fideos",
+ "The Popular feed has been disabled by the administrator.": "Mae'r ffrwd fideos poblogaidd wedi ei hanalluogi gan y gweinyddwr.",
+ "generic_channels_count_0": "{{count}} sianel",
+ "generic_channels_count_1": "{{count}} sianel",
+ "generic_channels_count_2": "{{count}} sianel",
+ "generic_channels_count_3": "{{count}} sianel",
+ "generic_channels_count_4": "{{count}} sianel",
+ "generic_channels_count_5": "{{count}} sianel",
+ "generic_button_delete": "Dileu",
+ "generic_button_edit": "Golygu",
+ "generic_button_save": "Cadw",
+ "Shared `x` ago": "Rhannwyd `x` yn ôl",
+ "Unsubscribe": "Dad-danysgrifio",
+ "Subscribe": "Tanysgrifio",
+ "View channel on YouTube": "Gweld y sianel ar YouTube",
+ "View playlist on YouTube": "Gweld y rhestr chwarae ar YouTube",
+ "newest": "diweddaraf",
+ "oldest": "hynaf",
+ "popular": "poblogaidd",
+ "Next page": "Tudalen nesaf",
+ "Previous page": "Tudalen flaenorol",
+ "Clear watch history?": "Clirio'ch hanes gwylio?",
+ "New password": "Cyfrinair newydd",
+ "Import and Export Data": "Mewnforio ac allforio data",
+ "Import": "Mewnforio",
+ "Import Invidious data": "Mewnforio data JSON Invidious",
+ "Import YouTube subscriptions": "Mewnforio tanysgrifiadau YouTube ar fformat CSV neu OPML",
+ "Import YouTube playlist (.csv)": "Mewnforio rhestr chwarae YouTube (.csv)",
+ "Export": "Allforio",
+ "Export data as JSON": "Allforio data Invidious ar fformat JSON",
+ "Delete account?": "Ydych chi'n siŵr yr hoffech chi ddileu eich cyfrif?",
+ "History": "Hanes",
+ "JavaScript license information": "Gwybodaeth am y drwydded JavaScript",
+ "generic_subscriptions_count_0": "{{count}} tanysgrifiad",
+ "generic_subscriptions_count_1": "{{count}} tanysgrifiad",
+ "generic_subscriptions_count_2": "{{count}} danysgrifiad",
+ "generic_subscriptions_count_3": "{{count}} thanysgrifiad",
+ "generic_subscriptions_count_4": "{{count}} o danysgrifiadau",
+ "generic_subscriptions_count_5": "{{count}} o danysgrifiadau",
+ "Yes": "Iawn",
+ "No": "Na",
+ "Import FreeTube subscriptions (.db)": "Mewnforio tanysgrifiadau FreeTube (.db)",
+ "Import NewPipe subscriptions (.json)": "Mewnforio tanysgrifiadau NewPipe (.json)",
+ "Import NewPipe data (.zip)": "Mewnforio data NewPipe (.zip)",
+ "An alternative front-end to YouTube": "Pen blaen amgen i YouTube",
+ "source": "ffynhonnell",
+ "Log in": "Mewngofnodi",
+ "Log in/register": "Mewngofnodi/Cofrestru",
+ "User ID": "Enw defnyddiwr",
+ "preferences_quality_option_dash": "DASH (ansawdd addasol)",
+ "Sign In": "Mewngofnodi",
+ "Register": "Cofrestru",
+ "E-mail": "Ebost",
+ "Preferences": "Hoffterau",
+ "preferences_category_player": "Hoffterau'r chwaraeydd",
+ "preferences_autoplay_label": "Chwarae'n awtomatig: ",
+ "preferences_local_label": "Llwytho fideos drwy ddirprwy weinydd: ",
+ "preferences_watch_history_label": "Galluogi hanes gwylio: ",
+ "preferences_speed_label": "Cyflymder rhagosodedig: ",
+ "preferences_quality_label": "Ansawdd fideos: ",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Canolig",
+ "preferences_quality_option_small": "Bach",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "preferences_quality_dash_option_720p": "720p",
+ "invidious": "Invidious",
+ "Text CAPTCHA": "CAPTCHA testun",
+ "Image CAPTCHA": "CAPTCHA delwedd",
+ "preferences_continue_label": "Chwarae'r fideo nesaf fel rhagosodiad: ",
+ "preferences_continue_autoplay_label": "Chwarae'r fideo nesaf yn awtomatig: ",
+ "preferences_listen_label": "Sain yn unig: ",
+ "preferences_quality_dash_label": "Ansawdd fideos DASH a ffefrir: ",
+ "preferences_volume_label": "Uchder sain y chwaraeydd: ",
+ "preferences_category_visual": "Hoffterau'r wefan",
+ "preferences_region_label": "Gwlad y cynnwys: ",
+ "preferences_player_style_label": "Arddull y chwaraeydd: ",
+ "Dark mode: ": "Modd tywyll: ",
+ "preferences_thin_mode_label": "Modd tenau: ",
+ "preferences_category_misc": "Hoffterau amrywiol",
+ "preferences_category_subscription": "Hoffterau tanysgrifio",
+ "preferences_max_results_label": "Nifer o fideos a ddangosir yn eich ffrwd: ",
+ "alphabetically": "yr wyddor",
+ "alphabetically - reverse": "yr wyddor - am yn ôl",
+ "published - reverse": "dyddiad cyhoeddi - am yn ôl",
+ "channel name": "enw'r sianel",
+ "channel name - reverse": "enw'r sianel - am yn ôl",
+ "Only show latest video from channel: ": "Dangos fideo diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ",
+ "Only show latest unwatched video from channel: ": "Dangos fideo heb ei wylio diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ",
+ "Enable web notifications": "Galluogi hysbysiadau gwe",
+ "`x` uploaded a video": "uwchlwythodd `x` fideo",
+ "`x` is live": "mae `x` yn darlledu'n fyw",
+ "preferences_category_data": "Hoffterau data",
+ "Clear watch history": "Clirio'ch hanes gwylio",
+ "Change password": "Newid eich cyfrinair",
+ "Manage subscriptions": "Rheoli tanysgrifiadau",
+ "Manage tokens": "Rheoli tocynnau",
+ "Watch history": "Hanes gwylio",
+ "preferences_default_home_label": "Hafan ragosodedig: ",
+ "preferences_show_nick_label": "Dangos eich enw defnyddiwr ar frig y dudalen: ",
+ "preferences_annotations_label": "Dangos nodiadau fel rhagosodiad: ",
+ "preferences_unseen_only_label": "Dangos fideos heb eu gwylio yn unig: ",
+ "preferences_notifications_only_label": "Dangos hysbysiadau yn unig (os oes unrhyw rai): ",
+ "Token manager": "Rheolydd tocynnau",
+ "Token": "Tocyn",
+ "unsubscribe": "dad-danysgrifio",
+ "Subscriptions": "Tanysgrifiadau",
+ "Import/export": "Mewngofnodi/allgofnodi",
+ "search": "chwilio",
+ "Log out": "Allgofnodi",
+ "View privacy policy.": "Polisi preifatrwydd",
+ "Trending": "Pynciau llosg",
+ "Public": "Cyhoeddus",
+ "Private": "Preifat",
+ "Updated `x` ago": "Diweddarwyd `x` yn ôl",
+ "Delete playlist `x`?": "Ydych chi'n siŵr yr hoffech chi ddileu'r rhestr chwarae `x`?",
+ "Title": "Teitl",
+ "Playlist privacy": "Preifatrwydd y rhestr chwarae",
+ "search_message_use_another_instance": " Gallwch hefyd <a href=\"`x`\">chwilio ar weinydd arall</a>.",
+ "Popular enabled: ": "Tudalen fideos poblogaidd wedi'i galluogi: ",
+ "CAPTCHA enabled: ": "CAPTCHA wedi'i alluogi: ",
+ "Registration enabled: ": "Cofrestru wedi'i alluogi: ",
+ "Save preferences": "Cadw'r hoffterau",
+ "Subscription manager": "Rheolydd tanysgrifio",
+ "revoke": "tynnu",
+ "subscriptions_unseen_notifs_count_0": "{{count}} hysbysiad heb ei weld",
+ "subscriptions_unseen_notifs_count_1": "{{count}} hysbysiad heb ei weld",
+ "subscriptions_unseen_notifs_count_2": "{{count}} hysbysiad heb eu gweld",
+ "subscriptions_unseen_notifs_count_3": "{{count}} hysbysiad heb eu gweld",
+ "subscriptions_unseen_notifs_count_4": "{{count}} hysbysiad heb eu gweld",
+ "subscriptions_unseen_notifs_count_5": "{{count}} hysbysiad heb eu gweld",
+ "Released under the AGPLv3 on Github.": "Cyhoeddwyd dan drwydded AGPLv3 ar GitHub",
+ "Unlisted": "Heb ei restru",
+ "Switch Invidious Instance": "Newid gweinydd Invidious",
+ "Report statistics: ": "Galluogi ystadegau'r gweinydd: ",
+ "View all playlists": "Gweld pob rhestr chwarae",
+ "Editing playlist `x`": "Yn golygu'r rhestr chwarae `x`",
+ "Whitelisted regions: ": "Rhanbarthau a ganiateir: ",
+ "Blacklisted regions: ": "Rhanbarthau a rwystrir: ",
+ "Song: ": "Cân: ",
+ "Album: ": "Albwm: ",
+ "Shared `x`": "Rhannwyd `x`",
+ "View YouTube comments": "Dangos sylwadau YouTube",
+ "View more comments on Reddit": "Dangos rhagor o sylwadau ar Reddit",
+ "View Reddit comments": "Dangos sylwadau Reddit",
+ "Hide replies": "Cuddio ymatebion",
+ "Incorrect password": "Cyfrinair anghywir",
+ "Wrong answer": "Ateb anghywir",
+ "CAPTCHA is a required field": "Rhaid rhoi'r CAPTCHA",
+ "User ID is a required field": "Rhaid rhoi enw defnyddiwr",
+ "Password is a required field": "Rhaid rhoi cyfrinair",
+ "Wrong username or password": "Enw defnyddiwr neu gyfrinair anghywir",
+ "Password cannot be empty": "All y cyfrinair ddim bod yn wag",
+ "Password cannot be longer than 55 characters": "All y cyfrinair ddim bod yn hirach na 55 nod",
+ "Please log in": "Mewngofnodwch",
+ "channel:`x`": "sianel: `x`",
+ "Deleted or invalid channel": "Sianel wedi'i dileu neu'n annilys",
+ "Could not get channel info.": "Wedi methu llwytho gwybodaeth y sianel.",
+ "`x` ago": "`x` yn ôl",
+ "Load more": "Llwytho rhagor",
+ "Empty playlist": "Rhestr chwarae wag",
+ "Hide annotations": "Cuddio nodiadau",
+ "Show annotations": "Dangos nodiadau",
+ "Premieres in `x`": "Yn dechrau mewn `x`",
+ "Premieres `x`": "Yn dechrau `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.": "Helo! Mae'n ymddangos eich bod wedi diffodd JavaScript. Cliciwch yma i weld sylwadau, ond cofiwch y gall gymryd mwy o amser i'w llwytho.",
+ "View `x` comments": {
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Gweld `x` sylw",
+ "": "Gweld `x` sylw"
+ },
+ "Could not create mix.": "Wedi methu creu'r cymysgiad hwn.",
+ "Erroneous token": "Tocyn annilys",
+ "No such user": "Dyw'r defnyddiwr hwn ddim yn bodoli",
+ "Token is expired, please try again": "Mae'r tocyn hwn wedi dod i ben, ceisiwch eto",
+ "Bangla": "Bangleg",
+ "Basque": "Basgeg",
+ "Bulgarian": "Bwlgareg",
+ "Catalan": "Catalaneg",
+ "Chinese": "Tsieineeg",
+ "Chinese (China)": "Tsieineeg (Tsieina)",
+ "Chinese (Hong Kong)": "Tsieineeg (Hong Kong)",
+ "Chinese (Taiwan)": "Tsieineeg (Taiwan)",
+ "Danish": "Daneg",
+ "Dutch": "Iseldireg",
+ "Esperanto": "Esperanteg",
+ "Finnish": "Ffinneg",
+ "French": "Ffrangeg",
+ "German": "Almaeneg",
+ "Greek": "Groeg",
+ "Could not pull trending pages.": "Wedi methu llwytho tudalennau pynciau llosg.",
+ "Hidden field \"challenge\" is a required field": "Mae'r maes cudd \"her\" yn ofynnol",
+ "Hidden field \"token\" is a required field": "Mae'r maes cudd \"tocyn\" yn ofynnol",
+ "Hebrew": "Hebraeg",
+ "Hungarian": "Hwngareg",
+ "Irish": "Gwyddeleg",
+ "Italian": "Eidaleg",
+ "Welsh": "Cymraeg",
+ "generic_count_hours_0": "{{count}} awr",
+ "generic_count_hours_1": "{{count}} awr",
+ "generic_count_hours_2": "{{count}} awr",
+ "generic_count_hours_3": "{{count}} awr",
+ "generic_count_hours_4": "{{count}} awr",
+ "generic_count_hours_5": "{{count}} awr",
+ "generic_count_minutes_0": "{{count}} munud",
+ "generic_count_minutes_1": "{{count}} munud",
+ "generic_count_minutes_2": "{{count}} funud",
+ "generic_count_minutes_3": "{{count}} munud",
+ "generic_count_minutes_4": "{{count}} o funudau",
+ "generic_count_minutes_5": "{{count}} o funudau",
+ "generic_count_weeks_0": "{{count}} wythnos",
+ "generic_count_weeks_1": "{{count}} wythnos",
+ "generic_count_weeks_2": "{{count}} wythnos",
+ "generic_count_weeks_3": "{{count}} wythnos",
+ "generic_count_weeks_4": "{{count}} wythnos",
+ "generic_count_weeks_5": "{{count}} wythnos",
+ "generic_count_seconds_0": "{{count}} eiliad",
+ "generic_count_seconds_1": "{{count}} eiliad",
+ "generic_count_seconds_2": "{{count}} eiliad",
+ "generic_count_seconds_3": "{{count}} eiliad",
+ "generic_count_seconds_4": "{{count}} o eiliadau",
+ "generic_count_seconds_5": "{{count}} o eiliadau",
+ "Fallback comments: ": "Sylwadau amgen: ",
+ "Popular": "Poblogaidd",
+ "preferences_locale_label": "Iaith: ",
+ "About": "Ynghylch",
+ "Search": "Chwilio",
+ "search_filters_features_option_c_commons": "Comin Creu",
+ "search_filters_features_option_subtitles": "Isdeitlau (CC)",
+ "search_filters_features_option_hd": "HD",
+ "permalink": "dolen barhaol",
+ "search_filters_duration_option_short": "Byr (< 4 munud)",
+ "search_filters_duration_option_none": "Unrhyw hyd",
+ "search_filters_duration_label": "Hyd",
+ "search_filters_type_option_show": "Rhaglen",
+ "search_filters_type_option_movie": "Ffilm",
+ "search_filters_type_option_playlist": "Rhestr chwarae",
+ "search_filters_type_option_channel": "Sianel",
+ "search_filters_type_option_video": "Fideo",
+ "search_filters_type_option_all": "Unrhyw fath",
+ "search_filters_date_option_week": "Yr wythnos hon",
+ "search_filters_date_option_today": "Heddiw",
+ "search_filters_date_option_hour": "Yr awr ddiwethaf",
+ "search_filters_date_option_none": "Unrhyw ddyddiad",
+ "search_filters_date_label": "Dyddiad uwchlwytho",
+ "search_filters_title": "Hidlyddion",
+ "Playlists": "Rhestrau chwarae",
+ "Video mode": "Modd fideo",
+ "Audio mode": "Modd sain",
+ "Channel Sponsor": "Noddwr y sianel",
+ "(edited)": "(golygwyd)",
+ "Download": "Islwytho",
+ "Movies": "Ffilmiau",
+ "News": "Newyddion",
+ "Gaming": "Gemau",
+ "Music": "Cerddoriaeth",
+ "Download is disabled": "Mae islwytho wedi'i analluogi",
+ "Download as: ": "Islwytho fel: ",
+ "View as playlist": "Gweld fel rhestr chwarae",
+ "Default": "Rhagosodiad",
+ "YouTube comment permalink": "Dolen barhaol i'r sylw ar YouTube",
+ "crash_page_before_reporting": "Cyn adrodd nam, sicrhewch eich bod wedi:",
+ "crash_page_search_issue": "<a href=\"`x`\">chwilio am y nam ar GitHub</a>",
+ "videoinfo_watch_on_youTube": "Gwylio ar YouTube",
+ "videoinfo_started_streaming_x_ago": "Yn ffrydio'n fyw ers `x` o funudau",
+ "videoinfo_invidious_embed_link": "Dolen mewnblannu",
+ "footer_documentation": "Dogfennaeth",
+ "footer_donate_page": "Rhoddi",
+ "Current version: ": "Fersiwn gyfredol: ",
+ "search_filters_apply_button": "Rhoi'r hidlyddion ar waith",
+ "search_filters_sort_option_date": "Dyddiad uwchlwytho",
+ "search_filters_sort_option_relevance": "Perthnasedd",
+ "search_filters_sort_label": "Trefnu yn ôl",
+ "search_filters_features_option_location": "Lleoliad",
+ "search_filters_features_option_hdr": "HDR",
+ "search_filters_features_option_three_d": "3D",
+ "search_filters_features_option_vr180": "VR180",
+ "search_filters_features_option_three_sixty": "360°",
+ "videoinfo_youTube_embed_link": "Mewnblannu",
+ "download_subtitles": "Isdeitlau - `x` (.vtt)",
+ "user_created_playlists": "`x` rhestr chwarae wedi'u creu",
+ "user_saved_playlists": "`x` rhestr chwarae wedi'u cadw",
+ "Video unavailable": "Fideo ddim ar gael",
+ "crash_page_you_found_a_bug": "Mae'n debyg eich bod wedi dod o hyd i nam yn Invidious!",
+ "channel_tab_channels_label": "Sianeli",
+ "channel_tab_community_label": "Cymuned",
+ "channel_tab_shorts_label": "Fideos byrion",
+ "channel_tab_videos_label": "Fideos"
+}
diff --git a/locales/da.json b/locales/da.json
index 16607546..9cbb446a 100644
--- a/locales/da.json
+++ b/locales/da.json
@@ -165,12 +165,12 @@
"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 ind",
- "channel:`x`": "kanal: 'x'",
+ "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",
- "`x` ago": "'x' siden",
+ "`x` ago": "`x` siden",
"Load more": "Hent flere",
"Could not create mix.": "Kunne ikke skabe blanding.",
"Empty playlist": "Tom playliste",
@@ -452,5 +452,40 @@
"crash_page_you_found_a_bug": "Det ser ud til, at du har fundet en fejl i Invidious!",
"crash_page_read_the_faq": "læs <a href=\"`x`\">Ofte stillede spørgsmål (FAQ)</a>",
"crash_page_search_issue": "søgte efter <a href=\"`x`\">eksisterende problemer på GitHub</a>",
- "search_filters_title": "Filter"
+ "search_filters_title": "Filter",
+ "playlist_button_add_items": "Tilføj videoer",
+ "search_message_no_results": "Ingen resultater fundet.",
+ "Import YouTube watch history (.json)": "Importer YouTube afspilningshistorik (.json)",
+ "search_message_change_filters_or_query": "Prøv at udvide din søgeforspørgsel og/eller ændre filtrene.",
+ "search_message_use_another_instance": " Du kan også <a href=\"`x`\">søge på en anden instans</a>.",
+ "Music in this video": "Musik i denne video",
+ "search_filters_date_option_none": "Enhver dato",
+ "search_filters_type_option_all": "Enhver type",
+ "search_filters_duration_option_none": "Enhver varighed",
+ "search_filters_duration_option_medium": "Medium (4 - 20 minutter)",
+ "search_filters_features_option_vr180": "VR180",
+ "generic_channels_count": "{{count}} kanal",
+ "generic_channels_count_plural": "{{count}} kanaler",
+ "Import YouTube playlist (.csv)": "Importer YouTube playliste (.csv)",
+ "Standard YouTube license": "Standard Youtube-licens",
+ "Album: ": "Album: ",
+ "Channel Sponsor": "Kanal-sponsor",
+ "Song: ": "Sang: ",
+ "channel_tab_playlists_label": "Playlister",
+ "channel_tab_channels_label": "Kanaler",
+ "Artist: ": "Kunstner: ",
+ "search_filters_date_label": "Uploaddato",
+ "generic_button_delete": "Slet",
+ "generic_button_edit": "Rediger",
+ "generic_button_save": "Gem",
+ "generic_button_cancel": "Afbryd",
+ "generic_button_rss": "RSS",
+ "Popular enabled: ": "Populær aktiveret: ",
+ "search_filters_apply_button": "Anvend udvalgte filtre",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_streams_label": "Livestreams",
+ "channel_tab_podcasts_label": "Podcasts",
+ "channel_tab_releases_label": "Udgivelser",
+ "Download is disabled": "Download er slået fra",
+ "error_video_not_in_playlist": "Den ønskede video findes ikke i denne playliste. <a href=\"`x`\">Klik her for playlistens startside.</a>"
}
diff --git a/locales/de.json b/locales/de.json
index 7ec7b9ef..a9a62619 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -22,7 +22,7 @@
"Import and Export Data": "Daten importieren und exportieren",
"Import": "Importieren",
"Import Invidious data": "Invidious-JSON-Daten importieren",
- "Import YouTube subscriptions": "YouTube-/OPML-Abonnements importieren",
+ "Import YouTube subscriptions": "YouTube-CSV/OPML-Abonnements importieren",
"Import FreeTube subscriptions (.db)": "FreeTube Abonnements importieren (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe Abonnements importieren (.json)",
"Import NewPipe data (.zip)": "NewPipe Daten importieren (.zip)",
@@ -48,6 +48,7 @@
"Preferences": "Einstellungen",
"preferences_category_player": "Wiedergabeeinstellungen",
"preferences_video_loop_label": "Immer wiederholen: ",
+ "preferences_preload_label": "Videodaten vorladen: ",
"preferences_autoplay_label": "Automatisch abspielen: ",
"preferences_continue_label": "Immer automatisch nächstes Video abspielen: ",
"preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ",
@@ -98,7 +99,7 @@
"Change password": "Passwort ändern",
"Manage subscriptions": "Abonnements verwalten",
"Manage tokens": "Tokens verwalten",
- "Watch history": "Verlauf",
+ "Watch history": "Wiedergabeverlauf",
"Delete account": "Account löschen",
"preferences_category_admin": "Administrator-Einstellungen",
"preferences_default_home_label": "Standard-Startseite: ",
@@ -149,7 +150,7 @@
"Whitelisted regions: ": "Erlaubte Regionen: ",
"Blacklisted regions: ": "Unerlaubte Regionen: ",
"Shared `x`": "Geteilt `x`",
- "Premieres in `x`": "Zuerst gesehen in `x`",
+ "Premieres in `x`": "Premiere 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.",
"View YouTube comments": "YouTube Kommentare anzeigen",
@@ -323,7 +324,7 @@
"channel_tab_community_label": "Gemeinschaft",
"search_filters_sort_option_relevance": "Relevanz",
"search_filters_sort_option_rating": "Bewertung",
- "search_filters_sort_option_date": "Datum",
+ "search_filters_sort_option_date": "Hochladedatum",
"search_filters_sort_option_views": "Aufrufe",
"search_filters_type_label": "Inhaltstyp",
"search_filters_duration_label": "Dauer",
@@ -455,7 +456,7 @@
"Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)",
"search_filters_title": "Filtern",
"search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.",
- "search_message_use_another_instance": " Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
+ "search_message_use_another_instance": "Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
"Popular enabled: ": "„Beliebt“-Seite aktiviert: ",
"search_message_no_results": "Keine Ergebnisse gefunden.",
"search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)",
@@ -477,11 +478,25 @@
"Standard YouTube license": "Standard YouTube-Lizenz",
"Song: ": "Musik: ",
"Download is disabled": "Herunterladen ist deaktiviert",
- "Import YouTube playlist (.csv)": "YouTube Playlist Importieren (.csv)",
+ "Import YouTube playlist (.csv)": "YouTube Wiedergabeliste importieren (.csv)",
"generic_button_delete": "Löschen",
"generic_button_edit": "Bearbeiten",
"generic_button_save": "Speichern",
"generic_button_cancel": "Abbrechen",
"generic_button_rss": "RSS",
- "playlist_button_add_items": "Videos hinzufügen"
+ "playlist_button_add_items": "Videos hinzufügen",
+ "channel_tab_podcasts_label": "Podcasts",
+ "channel_tab_releases_label": "Veröffentlichungen",
+ "generic_channels_count": "{{count}} Kanal",
+ "generic_channels_count_plural": "{{count}} Kanäle",
+ "Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
+ "Answer": "Antwort",
+ "The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.",
+ "Add to playlist": "Einer Wiedergabeliste hinzufügen",
+ "Search for videos": "Nach Videos suchen",
+ "toggle_theme": "Thema wechseln",
+ "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
+ "carousel_go_to": "Zu Folie `x` gehen",
+ "carousel_slide": "Folie {{current}} von {{total}}",
+ "carousel_skip": "Karussell überspringen"
}
diff --git a/locales/el.json b/locales/el.json
index 13cff649..38550458 100644
--- a/locales/el.json
+++ b/locales/el.json
@@ -41,7 +41,7 @@
"Time (h:mm:ss):": "Ώρα (ω:λλ:δδ):",
"Text CAPTCHA": "Κείμενο CAPTCHA",
"Image CAPTCHA": "Εικόνα CAPTCHA",
- "Sign In": "Σύνδεση",
+ "Sign In": "Εγγραφή",
"Register": "Εγγραφή",
"E-mail": "Ηλεκτρονικό ταχυδρομείο",
"Preferences": "Προτιμήσεις",
@@ -145,7 +145,7 @@
"View YouTube comments": "Προβολή σχολίων από το YouTube",
"View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίων",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίου",
"": "Προβολή `x` σχολίων"
},
"View Reddit comments": "Προβολή σχολίων από το Reddit",
@@ -349,7 +349,7 @@
"crash_page_you_found_a_bug": "Φαίνεται ότι βρήκατε ένα σφάλμα στο Invidious!",
"crash_page_before_reporting": "Πριν αναφέρετε ένα σφάλμα, βεβαιωθείτε ότι έχετε:",
"crash_page_refresh": "προσπαθήσει να <a href=\"`x`\">ανανεώσετε τη σελίδα</a>",
- "crash_page_read_the_faq": "διαβάσει τις <a href=\"`x`\">Συχνές Ερωτήσεις (ΣΕ)</a>",
+ "crash_page_read_the_faq": "διαβάστε τις <a href=\"`x`\">Συχνές Ερωτήσεις (ΣΕ)</a>",
"crash_page_search_issue": "αναζητήσει για <a href=\"`x`\">υπάρχοντα θέματα στο GitHub</a>",
"generic_views_count": "{{count}} προβολή",
"generic_views_count_plural": "{{count}} προβολές",
@@ -442,5 +442,57 @@
"search_filters_type_option_show": "Μπάρα προόδου διαβάσματος",
"preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ",
"search_filters_title": "Φίλτρο",
- "search_message_no_results": "Δε βρέθηκαν αποτελέσματα."
+ "search_message_no_results": "Δε βρέθηκαν αποτελέσματα.",
+ "channel_tab_podcasts_label": "Podcast",
+ "preferences_save_player_pos_label": "Αποθήκευση σημείου αναπαραγωγής: ",
+ "search_filters_apply_button": "Εφαρμογή επιλεγμένων φίλτρων",
+ "Download is disabled": "Είναι απενεργοποιημένη η λήψη",
+ "comments_points_count": "{{count}} βαθμός",
+ "comments_points_count_plural": "{{count}} βαθμοί",
+ "search_filters_sort_option_views": "Προβολές",
+ "search_message_change_filters_or_query": "Προσπαθήστε να διευρύνετε το ερώτημα αναζήτησης ή/και να αλλάξετε τα φίλτρα.",
+ "Channel Sponsor": "Χορηγός Καναλιού",
+ "channel_tab_streams_label": "Ζωντανή μετάδοση",
+ "playlist_button_add_items": "Προσθήκη βίντεο",
+ "Artist: ": "Καλλιτέχνης: ",
+ "search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
+ "generic_button_save": "Αποθήκευση",
+ "generic_button_cancel": "Ακύρωση",
+ "subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} μη αναγνωσμένες ειδοποιήσεις",
+ "Album: ": "Δίσκος: ",
+ "tokens_count": "{{count}} σύμβολο",
+ "tokens_count_plural": "{{count}} σύμβολα",
+ "channel_tab_shorts_label": "Short",
+ "channel_tab_releases_label": "Κυκλοφορίες",
+ "Song: ": "Τραγούδι: ",
+ "generic_channels_count": "{{count}} κανάλι",
+ "generic_channels_count_plural": "{{count}} κανάλια",
+ "Popular enabled: ": "Ενεργοποιημένα Δημοφιλή: ",
+ "channel_tab_playlists_label": "Λίστες αναπαραγωγής",
+ "generic_button_edit": "Επεξεργασία",
+ "search_filters_date_option_none": "Οποιαδήποτε ημερομηνία",
+ "crash_page_switch_instance": "προσπάθεια <a href=\"`x`\">χρήσης άλλου instance</a>",
+ "Music in this video": "Μουσική σε αυτό το βίντεο",
+ "generic_button_rss": "RSS",
+ "channel_tab_channels_label": "Κανάλια",
+ "search_filters_type_option_all": "Οποιοσδήποτε τύπος",
+ "search_filters_features_option_vr180": "VR180",
+ "error_video_not_in_playlist": "Το αιτούμενο βίντεο δεν υπάρχει στη δεδομένη λίστα αναπαραγωγής. <a href=\"`x`\">Πατήστε εδώ για επιστροφή στη κεντρική σελίδα λιστών αναπαραγωγής.</a>",
+ "search_filters_duration_option_none": "Οποιαδήποτε διάρκεια",
+ "preferences_automatic_instance_redirect_label": "Αυτόματη ανακατεύθυνση instance (εναλλακτική σε redirect.invidious.io): ",
+ "generic_button_delete": "Διαγραφή",
+ "Import YouTube playlist (.csv)": "Εισαγωγή λίστας αναπαραγωγής YouTube (.csv)",
+ "Switch Invidious Instance": "Αλλαγή Instance Invidious",
+ "Standard YouTube license": "Τυπική άδεια YouTube",
+ "search_filters_duration_option_medium": "Μεσαία (4 - 20 λεπτά)",
+ "search_filters_date_label": "Ημερομηνία αναφόρτωσης",
+ "Search for videos": "Αναζήτηση βίντεο",
+ "The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
+ "Answer": "Απάντηση",
+ "Add to playlist": "Λίιστα αναπαραγωγής",
+ "Add to playlist: ": "Λίστα αναπαραγωγής: ",
+ "carousel_slide": "Εικόνα {{current}}απο {{total}}",
+ "carousel_go_to": "Πήγαινε στην εικόνα`x`",
+ "toggle_theme": "Αλλαγή θέματος"
}
diff --git a/locales/en-US.json b/locales/en-US.json
index 573fb71d..381bcab5 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -1,4 +1,9 @@
{
+ "Add to playlist": "Add to playlist",
+ "Add to playlist: ": "Add to playlist: ",
+ "Answer": "Answer",
+ "Search for videos": "Search for videos",
+ "The Popular feed has been disabled by the administrator.": "The Popular feed has been disabled by the administrator.",
"generic_channels_count": "{{count}} channel",
"generic_channels_count_plural": "{{count}} channels",
"generic_views_count": "{{count}} view",
@@ -39,8 +44,9 @@
"Import and Export Data": "Import and Export Data",
"Import": "Import",
"Import Invidious data": "Import Invidious JSON data",
- "Import YouTube subscriptions": "Import YouTube/OPML subscriptions",
+ "Import YouTube subscriptions": "Import YouTube CSV or OPML subscriptions",
"Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)",
+ "Import YouTube watch history (.json)": "Import YouTube watch history (.json)",
"Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)",
"Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)",
"Import NewPipe data (.zip)": "Import NewPipe data (.zip)",
@@ -66,6 +72,7 @@
"Preferences": "Preferences",
"preferences_category_player": "Player preferences",
"preferences_video_loop_label": "Always loop: ",
+ "preferences_preload_label": "Preload video data: ",
"preferences_autoplay_label": "Autoplay: ",
"preferences_continue_label": "Play next by default: ",
"preferences_continue_autoplay_label": "Autoplay next video: ",
@@ -185,7 +192,7 @@
"Switch Invidious Instance": "Switch Invidious Instance",
"search_message_no_results": "No results found.",
"search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.",
- "search_message_use_another_instance": " You can also <a href=\"`x`\">search on another instance</a>.",
+ "search_message_use_another_instance": "You can also <a href=\"`x`\">search on another instance</a>.",
"Hide annotations": "Hide annotations",
"Show annotations": "Show annotations",
"Genre: ": "Genre: ",
@@ -280,6 +287,7 @@
"Esperanto": "Esperanto",
"Estonian": "Estonian",
"Filipino": "Filipino",
+ "Filipino (auto-generated)": "Filipino (auto-generated)",
"Finnish": "Finnish",
"French": "French",
"French (auto-generated)": "French (auto-generated)",
@@ -417,7 +425,7 @@
"search_filters_title": "Filters",
"search_filters_date_label": "Upload date",
"search_filters_date_option_none": "Any date",
- "search_filters_date_option_hour": "Last Hour",
+ "search_filters_date_option_hour": "Last hour",
"search_filters_date_option_today": "Today",
"search_filters_date_option_week": "This week",
"search_filters_date_option_month": "This month",
@@ -449,7 +457,7 @@
"search_filters_sort_label": "Sort By",
"search_filters_sort_option_relevance": "Relevance",
"search_filters_sort_option_rating": "Rating",
- "search_filters_sort_option_date": "Upload Date",
+ "search_filters_sort_option_date": "Upload date",
"search_filters_sort_option_views": "View count",
"search_filters_apply_button": "Apply selected filters",
"Current version: ": "Current version: ",
@@ -487,5 +495,9 @@
"channel_tab_releases_label": "Releases",
"channel_tab_playlists_label": "Playlists",
"channel_tab_community_label": "Community",
- "channel_tab_channels_label": "Channels"
+ "channel_tab_channels_label": "Channels",
+ "toggle_theme": "Toggle Theme",
+ "carousel_slide": "Slide {{current}} of {{total}}",
+ "carousel_skip": "Skip the Carousel",
+ "carousel_go_to": "Go to slide `x`"
}
diff --git a/locales/eo.json b/locales/eo.json
index 6d1b0bc1..7276c890 100644
--- a/locales/eo.json
+++ b/locales/eo.json
@@ -484,5 +484,7 @@
"channel_tab_podcasts_label": "Podkastoj",
"generic_button_cancel": "Nuligi",
"channel_tab_releases_label": "Eldonoj",
- "generic_button_save": "Konservi"
+ "generic_button_save": "Konservi",
+ "generic_channels_count": "{{count}} kanalo",
+ "generic_channels_count_plural": "{{count}} kanaloj"
}
diff --git a/locales/es.json b/locales/es.json
index b4a56030..fda29198 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Importación y exportación de datos",
"Import": "Importar",
"Import Invidious data": "Importar datos JSON de Invidious",
- "Import YouTube subscriptions": "Importar suscripciones de YouTube/OPML",
+ "Import YouTube subscriptions": "Importar suscripciones CSV u OPML de YouTube",
"Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)",
@@ -90,7 +90,7 @@
"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",
+ "`x` is live": "`x` está en directo",
"preferences_category_data": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos",
@@ -102,7 +102,7 @@
"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: ",
+ "preferences_show_nick_label": "Mostrar nombre de usuario encima: ",
"Top enabled: ": "¿Habilitar los destacados? ",
"CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ",
"Login enabled: ": "¿Habilitar el inicio de sesión? ",
@@ -133,7 +133,7 @@
"Create playlist": "Crear lista de reproducción",
"Title": "Título",
"Playlist privacy": "Privacidad de la lista de reproducción",
- "Editing playlist `x`": "Editando la lista de reproducción 'x'",
+ "Editing playlist `x`": "Editando la lista de reproducción `x`",
"Show more": "Mostrar más",
"Show less": "Mostrar menos",
"Watch on YouTube": "Ver en YouTube",
@@ -144,13 +144,13 @@
"License: ": "Licencia: ",
"Family friendly? ": "¿Filtrar contenidos? ",
"Wilson score: ": "Puntuación Wilson: ",
- "Engagement: ": "Compromiso: ",
+ "Engagement: ": "Retención: ",
"Whitelisted regions: ": "Regiones permitidas: ",
"Blacklisted regions: ": "Regiones bloqueadas: ",
"Shared `x`": "Compartido `x`",
"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 tienes JavaScript desactivado. Haz clic aquí para ver los comentarios, pero tengas en cuenta que pueden tardar un poco más en cargarse.",
+ "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 tienes JavaScript desactivado. Haz clic aquí para ver los comentarios, ten en cuenta que pueden tardar un poco más en cargar.",
"View YouTube comments": "Ver los comentarios de YouTube",
"View more comments on Reddit": "Ver más comentarios en Reddit",
"View `x` comments": {
@@ -312,7 +312,7 @@
"Download as: ": "Descargar como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
- "YouTube comment permalink": "Enlace permanente de YouTube del comentario",
+ "YouTube comment permalink": "Enlace permanente de comentario de YouTube",
"permalink": "enlace permanente",
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio",
@@ -324,10 +324,10 @@
"search_filters_sort_option_rating": "Valoración",
"search_filters_sort_option_date": "Fecha de subida",
"search_filters_sort_option_views": "Visualizaciones",
- "search_filters_type_label": "tipo de contenido",
- "search_filters_duration_label": "duración",
- "search_filters_features_label": "funcionalidades",
- "search_filters_sort_label": "ordenar",
+ "search_filters_type_label": "Tipo de contenido",
+ "search_filters_duration_label": "Duración",
+ "search_filters_features_label": "Funcionalidades",
+ "search_filters_sort_label": "Ordenar",
"search_filters_date_option_hour": "Última hora",
"search_filters_date_option_today": "Hoy",
"search_filters_date_option_week": "Esta semana",
@@ -390,43 +390,58 @@
"search_filters_features_option_three_sixty": "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 reproducciones",
- "generic_videos_count": "{{count}} video",
- "generic_videos_count_plural": "{{count}} video",
- "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",
+ "generic_views_count_0": "{{count}} visualización",
+ "generic_views_count_1": "{{count}} visualizaciones",
+ "generic_views_count_2": "{{count}} visualizaciones",
+ "generic_subscribers_count_0": "{{count}} suscriptor",
+ "generic_subscribers_count_1": "{{count}} suscriptores",
+ "generic_subscribers_count_2": "{{count}} suscriptores",
+ "generic_subscriptions_count_0": "{{count}} suscripción",
+ "generic_subscriptions_count_1": "{{count}} suscripciones",
+ "generic_subscriptions_count_2": "{{count}} suscripciones",
+ "subscriptions_unseen_notifs_count_0": "{{count}} notificación sin ver",
+ "subscriptions_unseen_notifs_count_1": "{{count}} notificaciones sin ver",
+ "subscriptions_unseen_notifs_count_2": "{{count}} notificaciones sin ver",
+ "generic_count_days_0": "{{count}} día",
+ "generic_count_days_1": "{{count}} días",
+ "generic_count_days_2": "{{count}} días",
+ "comments_view_x_replies_0": "Ver {{count}} respuesta",
+ "comments_view_x_replies_1": "Ver {{count}} respuestas",
+ "comments_view_x_replies_2": "Ver {{count}} respuestas",
+ "generic_count_weeks_0": "{{count}} semana",
+ "generic_count_weeks_1": "{{count}} semanas",
+ "generic_count_weeks_2": "{{count}} semanas",
+ "generic_playlists_count_0": "{{count}} lista de reproducción",
+ "generic_playlists_count_1": "{{count}} listas de reproducciones",
+ "generic_playlists_count_2": "{{count}} listas de reproducciones",
+ "generic_videos_count_0": "{{count}} video",
+ "generic_videos_count_1": "{{count}} videos",
+ "generic_videos_count_2": "{{count}} videos",
+ "generic_count_months_0": "{{count}} mes",
+ "generic_count_months_1": "{{count}} meses",
+ "generic_count_months_2": "{{count}} meses",
+ "comments_points_count_0": "{{count}} punto",
+ "comments_points_count_1": "{{count}} puntos",
+ "comments_points_count_2": "{{count}} puntos",
+ "generic_count_years_0": "{{count}} año",
+ "generic_count_years_1": "{{count}} años",
+ "generic_count_years_2": "{{count}} años",
+ "generic_count_hours_0": "{{count}} hora",
+ "generic_count_hours_1": "{{count}} horas",
+ "generic_count_hours_2": "{{count}} horas",
+ "generic_count_minutes_0": "{{count}} minuto",
+ "generic_count_minutes_1": "{{count}} minutos",
+ "generic_count_minutes_2": "{{count}} minutos",
+ "generic_count_seconds_0": "{{count}} segundo",
+ "generic_count_seconds_1": "{{count}} segundos",
+ "generic_count_seconds_2": "{{count}} segundos",
"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 verbatim el siguiente texto en tu mensaje:",
+ "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):",
"English (United States)": "Inglés (Estados Unidos)",
"Cantonese (Hong Kong)": "Cantonés (Hong Kong)",
"Dutch (auto-generated)": "Neerlandés (generados automáticamente)",
@@ -454,15 +469,16 @@
"search_message_no_results": "No se han encontrado resultados.",
"search_message_change_filters_or_query": "Pruebe ampliar la consulta de búsqueda y/o a cambiar los filtros.",
"search_filters_title": "Filtros",
- "search_filters_date_label": "fecha de subida",
+ "search_filters_date_label": "Fecha de subida",
"search_filters_date_option_none": "Cualquier fecha",
"search_filters_type_option_all": "Cualquier tipo",
"search_filters_duration_option_none": "Cualquier duración",
"search_filters_features_option_vr180": "VR180",
"search_filters_apply_button": "Aplicar filtros",
- "tokens_count": "{{count}} token",
- "tokens_count_plural": "{{count}} tokens",
- "search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokens",
+ "tokens_count_2": "{{count}} tokens",
+ "search_message_use_another_instance": "También puedes <a href=\"`x`\">buscar en otra instancia</a>.",
"Popular enabled: ": "¿Habilitar la sección popular? ",
"error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>",
"channel_tab_streams_label": "Directos",
@@ -484,5 +500,18 @@
"generic_button_cancel": "Cancelar",
"generic_button_rss": "RSS",
"channel_tab_podcasts_label": "Podcasts",
- "channel_tab_releases_label": "Publicaciones"
+ "channel_tab_releases_label": "Publicaciones",
+ "generic_channels_count_0": "{{count}} canal",
+ "generic_channels_count_1": "{{count}} canales",
+ "generic_channels_count_2": "{{count}} canales",
+ "Import YouTube watch history (.json)": "Importar el historial de las visualizaciones de YouTube (.json)",
+ "toggle_theme": "Alternar tema",
+ "Add to playlist: ": "Añadir a la lista de reproducción: ",
+ "Add to playlist": "Añadir a la lista de reproducción",
+ "Answer": "Respuesta",
+ "Search for videos": "Buscar por vídeos",
+ "The Popular feed has been disabled by the administrator.": "El feed Popular ha sido desactivado por el administrador.",
+ "carousel_slide": "Diapositiva {{current}} de {{total}}",
+ "carousel_skip": "Saltar el carrusel",
+ "carousel_go_to": "Ir a la diapositiva `x`"
}
diff --git a/locales/eu.json b/locales/eu.json
index 8b365270..fbca537b 100644
--- a/locales/eu.json
+++ b/locales/eu.json
@@ -161,13 +161,13 @@
"Source available here.": "Iturburua hemen eskura.",
"View JavaScript license information.": "JavaScriptaren lizentzi adierazpena ikusi.",
"Blacklisted regions: ": "zerrenda beltzaren zonaldeak: ",
- "Premieres `x`": "'x' estrenaldiak",
+ "Premieres `x`": "`x` estrenaldiak",
"Wrong answer": "Erantzun ez zuzena",
"Password is a required field": "Pasahitza beharrezkoa da",
"Wrong username or password": "Pasahitza edo ezizena gaizki",
"Password cannot be longer than 55 characters": "Pasahitza 55 karaktere baino luzeagoa ezin da izan",
"This channel does not exist.": "Kanal hau ez dago.",
- "`x` ago": "duela 'x'",
+ "`x` ago": "duela `x`",
"Czech": "Txekiera",
"preferences_region_label": "Herrialdeko edukiera: ",
"preferences_sort_label": "Bideoak ordenatu: ",
@@ -207,24 +207,24 @@
"Public": "Orokorra",
"Unlisted": "Ez zerrendatua",
"Subscription manager": "Harpidetzen kudeatzailea",
- "Updated `x` ago": "Duela 'x' eguneratua",
+ "Updated `x` ago": "Duela `x` eguneratua",
"Hide replies": "Erantzunak izkutatu",
"preferences_thin_mode_label": "Urri eran: ",
"Show replies": "Erantzunak erakutsi",
"Watch on YouTube": "YouTuben ikusi",
- "Premieres in `x`": "'x'eko estrenaldiak",
- "Delete playlist `x`?": "'x' zerrenda ezabatu nahi?",
+ "Premieres in `x`": "`x`eko estrenaldiak",
+ "Delete playlist `x`?": "`x` zerrenda ezabatu nahi?",
"Token is expired, please try again": "Token kadukatua, saiatu berriro",
"CAPTCHA enabled: ": "CAPTCHA gaitu: ",
"Released under the AGPLv3 on Github.": "GitHubeko AGPLv3pean argitaratuta.",
- "channel:`x`": "Kanal: 'x'",
+ "channel:`x`": "Kanal: `x`",
"Georgian": "Georgiera",
"Incorrect password": "Pasahitza gaizki",
"Playlist does not exist.": "Zerrenda ez da existitzen.",
"preferences_category_misc": "Askotariko lehentasunak",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "'x' iruzkina ikusi",
- "": "'x' iruzkinak ikusi"
+ "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iruzkina ikusi",
+ "": "`x` iruzkinak ikusi"
},
"Report statistics: ": "Estatistikak adierazi: ",
"preferences_max_results_label": "Jotzeko bideo zerrendaren luzera: ",
@@ -237,7 +237,7 @@
"Hidden field \"challenge\" is a required field": "\"challenge\" eremu ezkutua beharrezkoa da",
"German": "Alemaniarra",
"View YouTube comments": "YouTubeko iruzkinak ikusi",
- "`x` is live": "'x' bizirik darrai",
+ "`x` is live": "`x` bizirik darrai",
"Password cannot be empty": "Pasahitza ezin da hutsik utzi",
"preferences_video_loop_label": "Beti begiztatu: ",
"Only show latest unwatched video from channel: ": "kanalaren azken bideo ezikusia erakutsi soilik ",
@@ -261,9 +261,9 @@
"Hide annotations": "Oharrak izkutatu",
"Title": "Titulua",
"channel name": "Kanalaren izena",
- "Authorize token for `x`?": "Baimendu tokena 'x'tzako?",
+ "Authorize token for `x`?": "Baimendu tokena `x`tzako?",
"Private": "Pribatua",
- "Editing playlist `x`": "'x' zerrenda editatu",
+ "Editing playlist `x`": "`x` zerrenda editatu",
"Could not pull trending pages.": "Ezin ekarri orri arrakastatsuak.",
"crash_page_read_the_faq": "Bide <a href=\"`x`\"> (FAQ) ohiko galderak</a>"
}
diff --git a/locales/fa.json b/locales/fa.json
index 9b6c625d..b146385e 100644
--- a/locales/fa.json
+++ b/locales/fa.json
@@ -1,9 +1,14 @@
{
- "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}} اشتراک ها",
+ "generic_views_count": "{{count}} بازدید",
+ "generic_views_count_plural": "{{count}} بازدید",
+ "generic_videos_count": "{{count}} ویدئو",
+ "generic_videos_count_plural": "{{count}} ویدئو",
+ "generic_playlists_count": "{{count}} فهرست پخش",
+ "generic_playlists_count_plural": "{{count}} فهرست پخش",
+ "generic_subscribers_count": "{{count}} دنبال کننده",
+ "generic_subscribers_count_plural": "{{count}} دنبال کننده",
+ "generic_subscriptions_count": "{{count}} اشتراک",
+ "generic_subscriptions_count_plural": "{{count}} اشتراک",
"LIVE": "زنده",
"Shared `x` ago": "`x` پیش به اشتراک گذاشته شده",
"Unsubscribe": "لغو اشتراک",
@@ -12,7 +17,7 @@
"View playlist on YouTube": "دیدن فهرست پخش در یوتیوب",
"newest": "تازه‌ترین",
"oldest": "کهنه‌ترین",
- "popular": "محبوب",
+ "popular": "پرطرفدار",
"last": "آخرین",
"Next page": "صفحه بعد",
"Previous page": "صفحه قبل",
@@ -26,7 +31,7 @@
"Import and Export Data": "درون‌برد و برون‌برد داده",
"Import": "درون‌برد",
"Import Invidious data": "وارد کردن داده JSON اینویدیوس",
- "Import YouTube subscriptions": "وارد کردن اشتراک OPML/ یوتیوب",
+ "Import YouTube subscriptions": "وارد کردن فایل CSV یا OPML سابسکرایب های یوتیوب",
"Import FreeTube subscriptions (.db)": "درون‌برد اشتراک‌های فری‌تیوب (.db)",
"Import NewPipe subscriptions (.json)": "درون‌برد اشتراک‌های نیوپایپ (.json)",
"Import NewPipe data (.zip)": "درون‌برد داده نیوپایپ (.zip)",
@@ -117,13 +122,15 @@
"Subscription manager": "مدیریت اشتراک",
"Token manager": "مدیر توکن",
"Token": "توکن",
- "tokens_count_0": "{{count}} توکن ها",
+ "tokens_count": "{{count}} توکن",
+ "tokens_count_plural": "{{count}} توکن",
"Import/export": "وارد کردن/خارج کردن",
"unsubscribe": "لغو اشتراک",
"revoke": "ابطال",
"Subscriptions": "اشتراک ها",
- "subscriptions_unseen_notifs_count_0": "{{count}} اعلان نادیده",
- "search": "جستجو",
+ "subscriptions_unseen_notifs_count": "{{count}} اعلان نادیده",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} اعلان نادیده",
+ "search": "جست و جو",
"Log out": "خروج",
"Released under the AGPLv3 on Github.": "منتشر شده تحت پروانه AGPLv3 روی گیت‌هاب.",
"Source available here.": "منبع اینجا دردسترس است.",
@@ -183,10 +190,12 @@
"This channel does not exist.": "این کانال وجود ندارد.",
"Could not get channel info.": "نمیتوان اطلاعات کانال را دریافت کرد.",
"Could not fetch comments": "نمیتوان نظرات را دریافت کرد",
- "comments_view_x_replies_0": "نمایش {{count}} پاسخ ها",
+ "comments_view_x_replies": "نمایش {{count}} پاسخ",
+ "comments_view_x_replies_plural": "نمایش {{count}} پاسخ",
"`x` ago": "`x` پیش",
"Load more": "بارگذاری بیشتر",
- "comments_points_count_0": "{{count}} نقطه ها",
+ "comments_points_count": "{{count}} نقطه",
+ "comments_points_count_plural": "{{count}} نقطه",
"Could not create mix.": "نمیتوان میکس ساخت.",
"Empty playlist": "سیاههٔ پخش خالی",
"Not a playlist.": "یک سیاههٔ پخش نیست.",
@@ -304,16 +313,23 @@
"Yiddish": "ییدیش",
"Yoruba": "یوروبایی",
"Zulu": "زولو",
- "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}} ثانیه",
+ "generic_count_years": "{{count}} سال",
+ "generic_count_years_plural": "{{count}} سال",
+ "generic_count_months": "{{count}} ماه",
+ "generic_count_months_plural": "{{count}} ماه",
+ "generic_count_weeks": "{{count}} هفته",
+ "generic_count_weeks_plural": "{{count}} هفته",
+ "generic_count_days": "{{count}} روز",
+ "generic_count_days_plural": "{{count}} روز",
+ "generic_count_hours": "{{count}} ساعت",
+ "generic_count_hours_plural": "{{count}} ساعت",
+ "generic_count_minutes": "{{count}} دقیقه",
+ "generic_count_minutes_plural": "{{count}} دقیقه",
+ "generic_count_seconds": "{{count}} ثانیه",
+ "generic_count_seconds_plural": "{{count}} ثانیه",
"Fallback comments: ": "نظرات عقب گرد: ",
- "Popular": "محبوب",
- "Search": "جستجو",
+ "Popular": "پربیننده",
+ "Search": "جست و جو",
"Top": "بالا",
"About": "درباره",
"Rating: ": "رتبه دهی: ",
@@ -344,7 +360,7 @@
"search_filters_duration_label": "مدت",
"search_filters_features_label": "ویژگی‌ها",
"search_filters_sort_label": "به ترتیب",
- "search_filters_date_option_hour": "یک ساعت گذشته",
+ "search_filters_date_option_hour": "ساعت گذشته",
"search_filters_date_option_today": "امروز",
"search_filters_date_option_week": "این هفته",
"search_filters_date_option_month": "این ماه",
@@ -445,5 +461,40 @@
"Song: ": "آهنگ: ",
"Channel Sponsor": "اسپانسر کانال",
"Standard YouTube license": "پروانه استاندارد YouTube",
- "search_message_use_another_instance": " شما همچنین می‌توانید <a href=\"`x`\">در نمونه دیگر هم جستجو کنید</a>."
+ "search_message_use_another_instance": "همچنین می‌توانید <a href=\"`x`\">در نمونه‌ای دیگر هم جست‌وجو کنید</a>.",
+ "Download is disabled": "دریافت غیرفعال است",
+ "crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:",
+ "playlist_button_add_items": "افزودن ویدیو",
+ "user_saved_playlists": "فهرست‌های پخش ذخیره شده",
+ "crash_page_refresh": "که صفحه را <a href=\"`x`\">بازنشانی</a> کرده‌اید",
+ "generic_button_save": "ذخیره",
+ "generic_button_cancel": "لغو",
+ "generic_channels_count": "{{count}} کانال",
+ "generic_channels_count_plural": "{{count}} کانال",
+ "generic_button_edit": "ویرایش",
+ "crash_page_switch_instance": "که تلاش کرده‌اید <a href=\"`x`\">از یک نمونهٔ دیگر</a> استفاده کنید",
+ "generic_button_rss": "خوراک RSS",
+ "crash_page_read_the_faq": "که <a href=\"`x`\">سوالات بیشتر پرسیده شده (FAQ)</a> را خوانده‌اید",
+ "generic_button_delete": "حذف",
+ "Import YouTube playlist (.csv)": "واردکردن فهرست‌پخش YouTube (.csv)",
+ "Import YouTube watch history (.json)": "وارد کردن فهرست پخش YouTube (.json)",
+ "crash_page_you_found_a_bug": "به نظر می‌رسد که ایرادی در Invidious پیدا کرده‌اید!",
+ "channel_tab_podcasts_label": "پادکست‌ها",
+ "channel_tab_streams_label": "پخش زنده‌ها",
+ "channel_tab_shorts_label": "Shortها",
+ "channel_tab_playlists_label": "فهرست‌های پخش",
+ "channel_tab_channels_label": "کانال‌ها",
+ "error_video_not_in_playlist": "ویدیوی درخواستی معلق به این فهرست پخش نیست. <a href=\"`x`\">کلیک کنید تا به صفحهٔ اصلی فهرست پخش بروید.</a>",
+ "Add to playlist": "به لیست پخش افزوده شود",
+ "Answer": "پاسخ",
+ "Search for videos": "جست و جو برای ویدیوها",
+ "Add to playlist: ": "افزودن به لیست پخش ",
+ "The Popular feed has been disabled by the administrator.": "بخش ویدیوهای پرطرفدار توسط مدیر غیرفعال شده است.",
+ "carousel_slide": "اسلاید {{current}} از {{total}}",
+ "carousel_skip": "رد شدن از گرداننده",
+ "carousel_go_to": "به اسلاید `x` برو",
+ "crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
+ "crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
+ "channel_tab_releases_label": "آثار",
+ "toggle_theme": "تغییر وضعیت تم"
}
diff --git a/locales/fi.json b/locales/fi.json
index 5d8578a5..b0df1e46 100644
--- a/locales/fi.json
+++ b/locales/fi.json
@@ -14,7 +14,7 @@
"Clear watch history?": "Tyhjennä katseluhistoria?",
"New password": "Uusi salasana",
"New passwords must match": "Uusien salasanojen täytyy täsmätä",
- "Authorize token?": "Valuutetaanko tunnus?",
+ "Authorize token?": "Valtuutetaanko tunnus?",
"Authorize token for `x`?": "Valtuutetaanko tunnus `x`:lle?",
"Yes": "Kyllä",
"No": "Ei",
@@ -28,7 +28,7 @@
"Export": "Vie",
"Export subscriptions as OPML": "Vie tilaukset OPML-muodossa",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Vie tilaukset OPML-muodossa (NewPipe & FreeTube)",
- "Export data as JSON": "Vie Invidious-data JSON-muodossa",
+ "Export data as JSON": "Vie Invidiousin tiedot JSON-muodossa",
"Delete account?": "Poista tili?",
"History": "Historia",
"An alternative front-end to YouTube": "Vaihtoehtoinen front-end YouTubelle",
@@ -46,12 +46,12 @@
"E-mail": "Sähköposti",
"Preferences": "Asetukset",
"preferences_category_player": "Soittimen asetukset",
- "preferences_video_loop_label": "Toista jatkuvasti aina: ",
- "preferences_autoplay_label": "Automaattinen toisto: ",
+ "preferences_video_loop_label": "Toista aina uudelleen: ",
+ "preferences_autoplay_label": "Automaattinen toiston aloitus: ",
"preferences_continue_label": "Toista seuraava oletuksena: ",
- "preferences_continue_autoplay_label": "Toista seuraava video automaattisesti: ",
+ "preferences_continue_autoplay_label": "Aloita seuraava video automaattisesti: ",
"preferences_listen_label": "Kuuntele oletuksena: ",
- "preferences_local_label": "Proxytä videot: ",
+ "preferences_local_label": "Videot välityspalvelimen kautta: ",
"preferences_speed_label": "Oletusnopeus: ",
"preferences_quality_label": "Ensisijainen videon laatu: ",
"preferences_volume_label": "Soittimen äänenvoimakkuus: ",
@@ -63,7 +63,7 @@
"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 (vaatii WebGL:n): ",
+ "preferences_vr_mode_label": "Interaktiiviset 360-videot (vaatii WebGL:n): ",
"preferences_category_visual": "Visuaaliset asetukset",
"preferences_player_style_label": "Soittimen tyyli: ",
"Dark mode: ": "Tumma tila: ",
@@ -137,9 +137,9 @@
"Show less": "Näytä vähemmän",
"Watch on YouTube": "Katso YouTubessa",
"Switch Invidious Instance": "Vaihda Invidious-instanssia",
- "Hide annotations": "Piilota merkkaukset",
- "Show annotations": "Näytä merkkaukset",
- "Genre: ": "Genre: ",
+ "Hide annotations": "Piilota huomautukset",
+ "Show annotations": "Näytä huomautukset",
+ "Genre: ": "Tyylilaji: ",
"License: ": "Lisenssi: ",
"Family friendly? ": "Kaiken ikäisille sopiva? ",
"Wilson score: ": "Wilson-pistemäärä: ",
@@ -168,7 +168,7 @@
"Wrong username or password": "Väärä käyttäjänimi tai salasana",
"Password cannot be empty": "Salasana ei voi olla tyhjä",
"Password cannot be longer than 55 characters": "Salasana ei voi olla yli 55 merkkiä pitkä",
- "Please log in": "Kirjaudu sisään, ole hyvä",
+ "Please log in": "Kirjaudu sisään",
"Invidious Private Feed for `x`": "Invidiousin yksityinen syöte `x`:lle",
"channel:`x`": "kanava:`x`",
"Deleted or invalid channel": "Poistettu tai virheellinen kanava",
@@ -178,7 +178,7 @@
"`x` ago": "`x` sitten",
"Load more": "Lataa lisää",
"Could not create mix.": "Sekoituksen luominen epäonnistui.",
- "Empty playlist": "Tyhjennä soittolista",
+ "Empty playlist": "Tyhjä soittolista",
"Not a playlist.": "Ei ole soittolista.",
"Playlist does not exist.": "Soittolistaa ei ole olemassa.",
"Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnistui.",
@@ -216,11 +216,11 @@
"Filipino": "filipino",
"Finnish": "suomi",
"French": "ranska",
- "Galician": "galego",
+ "Galician": "galicia",
"Georgian": "georgia",
"German": "saksa",
"Greek": "kreikka",
- "Gujarati": "gujarati",
+ "Gujarati": "gudžarati",
"Haitian Creole": "haitinkreoli",
"Hausa": "hausa",
"Hawaiian": "havaiji",
@@ -327,11 +327,11 @@
"search_filters_duration_label": "Kesto",
"search_filters_features_label": "Ominaisuudet",
"search_filters_sort_label": "Luokittele",
- "search_filters_date_option_hour": "Viimeisin tunti",
+ "search_filters_date_option_hour": "Tunnin sisään",
"search_filters_date_option_today": "Tänään",
- "search_filters_date_option_week": "Tämä viikko",
- "search_filters_date_option_month": "Tämä kuukausi",
- "search_filters_date_option_year": "Tämä vuosi",
+ "search_filters_date_option_week": "Tällä viikolla",
+ "search_filters_date_option_month": "Tässä kuussa",
+ "search_filters_date_option_year": "Tänä vuonna",
"search_filters_type_option_video": "Video",
"search_filters_type_option_channel": "Kanava",
"search_filters_type_option_playlist": "Soittolista",
@@ -346,7 +346,7 @@
"search_filters_features_option_location": "Sijainti",
"search_filters_features_option_hdr": "HDR",
"Current version: ": "Tämänhetkinen versio: ",
- "next_steps_error_message": "Sinun tulisi kokeilla seuraavia: ",
+ "next_steps_error_message": "Kokeile seuraavia: ",
"next_steps_error_message_refresh": "Päivitä",
"next_steps_error_message_go_to_youtube": "Siirry YouTubeen",
"generic_count_hours": "{{count}} tunti",
@@ -391,7 +391,7 @@
"subscriptions_unseen_notifs_count": "{{count}} näkemätön ilmoitus",
"subscriptions_unseen_notifs_count_plural": "{{count}} näkemätöntä ilmoitusta",
"crash_page_switch_instance": "yrittänyt <a href=\"`x`\">käyttää toista instassia</a>",
- "videoinfo_invidious_embed_link": "Upotuslinkki",
+ "videoinfo_invidious_embed_link": "Upotettava linkki",
"user_saved_playlists": "`x` tallennetua soittolistaa",
"crash_page_report_issue": "Jos mikään näistä ei auttanut, <a href=\"`x`\">avaathan uuden issuen GitHubissa</a> (mieluiten englanniksi) ja sisällytät seuraavan tekstin viestissäsi (ÄLÄ käännä tätä tekstiä):",
"preferences_quality_option_hd720": "HD720",
@@ -410,7 +410,7 @@
"preferences_quality_dash_option_auto": "Auto",
"preferences_quality_dash_option_best": "Paras",
"preferences_quality_option_dash": "DASH (mukautuva laatu)",
- "preferences_quality_dash_label": "Haluttava DASH-videolaatu: ",
+ "preferences_quality_dash_label": "Ensisijainen DASH-videolaatu: ",
"generic_count_years": "{{count}} vuosi",
"generic_count_years_plural": "{{count}} vuotta",
"search_filters_features_option_purchased": "Ostettu",
@@ -421,39 +421,39 @@
"preferences_save_player_pos_label": "Tallenna toistokohta: ",
"footer_donate_page": "Lahjoita",
"footer_source_code": "Lähdekoodi",
- "adminprefs_modified_source_code_url_label": "URL muokattuun lähdekoodirepositoryyn",
- "Released under the AGPLv3 on Github.": "Julkaistu AGPLv3-lisenssin alla GitHubissa.",
+ "adminprefs_modified_source_code_url_label": "URL muokatun lähdekoodin repositorioon",
+ "Released under the AGPLv3 on Github.": "Julkaistu AGPLv3-lisenssillä GitHubissa.",
"search_filters_duration_option_short": "Lyhyt (< 4 minuuttia)",
"search_filters_duration_option_long": "Pitkä (> 20 minuuttia)",
"footer_documentation": "Dokumentaatio",
"footer_original_source_code": "Alkuperäinen lähdekoodi",
"footer_modfied_source_code": "Muokattu lähdekoodi",
- "Japanese (auto-generated)": "Japani (automaattisesti luotu)",
- "German (auto-generated)": "Saksa (automaattisesti luotu)",
+ "Japanese (auto-generated)": "japani (automaattisesti luotu)",
+ "German (auto-generated)": "saksa (automaattisesti luotu)",
"Portuguese (auto-generated)": "portugali (automaattisesti luotu)",
"Russian (auto-generated)": "Venäjä (automaattisesti luotu)",
"preferences_watch_history_label": "Ota katseluhistoria käyttöön: ",
- "English (United Kingdom)": "Englanti (Iso-Britannia)",
- "English (United States)": "Englanti (Yhdysvallat)",
- "Cantonese (Hong Kong)": "Kantoninkiina (Hong Kong)",
- "Chinese": "Kiina",
- "Chinese (China)": "Kiina (Kiina)",
- "Chinese (Hong Kong)": "Kiina (Hong Kong)",
- "Chinese (Taiwan)": "Kiina (Taiwan)",
- "Dutch (auto-generated)": "Hollanti (automaattisesti luotu)",
- "French (auto-generated)": "Ranska (automaattisesti luotu)",
- "Indonesian (auto-generated)": "Indonesia (automaattisesti luotu)",
- "Interlingue": "Interlingue",
+ "English (United Kingdom)": "englanti (Iso-Britannia)",
+ "English (United States)": "englanti (Yhdysvallat)",
+ "Cantonese (Hong Kong)": "kantoninkiina (Hongkong)",
+ "Chinese": "kiina",
+ "Chinese (China)": "kiina (Kiina)",
+ "Chinese (Hong Kong)": "kiina (Hongkong)",
+ "Chinese (Taiwan)": "kiina (Taiwan)",
+ "Dutch (auto-generated)": "hollanti (automaattisesti luotu)",
+ "French (auto-generated)": "ranska (automaattisesti luotu)",
+ "Indonesian (auto-generated)": "indonesia (automaattisesti luotu)",
+ "Interlingue": "interlingue",
"Italian (auto-generated)": "Italia (automaattisesti luotu)",
- "Korean (auto-generated)": "Korea (automaattisesti luotu)",
+ "Korean (auto-generated)": "korea (automaattisesti luotu)",
"Portuguese (Brazil)": "portugali (Brasilia)",
- "Spanish (auto-generated)": "Espanja (automaattisesti luotu)",
- "Spanish (Mexico)": "Espanja (Meksiko)",
- "Spanish (Spain)": "Espanja (Espanja)",
- "Turkish (auto-generated)": "Turkki (automaattisesti luotu)",
- "Vietnamese (auto-generated)": "Vietnam (automaattisesti luotu)",
- "search_filters_title": "Suodatin",
- "search_message_no_results": "Ei tuloksia löydetty.",
+ "Spanish (auto-generated)": "espanja (automaattisesti luotu)",
+ "Spanish (Mexico)": "espanja (Meksiko)",
+ "Spanish (Spain)": "espanja (Espanja)",
+ "Turkish (auto-generated)": "turkki (automaattisesti luotu)",
+ "Vietnamese (auto-generated)": "vietnam (automaattisesti luotu)",
+ "search_filters_title": "Suodattimet",
+ "search_message_no_results": "Tuloksia ei löytynyt.",
"search_message_change_filters_or_query": "Yritä hakukyselysi laajentamista ja/tai suodattimien muuttamista.",
"search_filters_duration_option_none": "Mikä tahansa kesto",
"search_filters_features_option_vr180": "VR180",
@@ -464,5 +464,37 @@
"search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: ",
- "error_video_not_in_playlist": "Pyydettyä videota ei löydy tästä soittolistasta. <a href=\"`x`\">Klikkaa tähän päästäksesi soittolistan etusivulle.</a>"
+ "error_video_not_in_playlist": "Pyydettyä videota ei ole tässä soittolistassa. <a href=\"`x`\">Klikkaa tästä päästäksesi soittolistan kotisivulle.</a>",
+ "Import YouTube playlist (.csv)": "Tuo YouTube-soittolista (.csv)",
+ "Music in this video": "Musiikki tässä videossa",
+ "Add to playlist": "Lisää soittolistaan",
+ "Add to playlist: ": "Lisää soittolistaan: ",
+ "Search for videos": "Etsi videoita",
+ "generic_button_rss": "RSS",
+ "Answer": "Vastaus",
+ "Standard YouTube license": "Vakio YouTube-lisenssi",
+ "Song: ": "Kappale: ",
+ "Album: ": "Albumi: ",
+ "Download is disabled": "Lataus on poistettu käytöstä",
+ "Channel Sponsor": "Kanavan sponsori",
+ "channel_tab_podcasts_label": "Podcastit",
+ "channel_tab_releases_label": "Julkaisut",
+ "channel_tab_shorts_label": "Shorts-videot",
+ "carousel_slide": "Dia {{current}}/{{total}}",
+ "carousel_skip": "Ohita karuselli",
+ "carousel_go_to": "Siirry diaan `x`",
+ "channel_tab_playlists_label": "Soittolistat",
+ "channel_tab_channels_label": "Kanavat",
+ "generic_button_delete": "Poista",
+ "generic_button_edit": "Muokkaa",
+ "generic_button_save": "Tallenna",
+ "generic_button_cancel": "Peru",
+ "playlist_button_add_items": "Lisää videoita",
+ "Artist: ": "Esittäjä: ",
+ "channel_tab_streams_label": "Suoratoistot",
+ "generic_channels_count": "{{count}} kanava",
+ "generic_channels_count_plural": "{{count}} kanavaa",
+ "The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
+ "Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
+ "toggle_theme": "Vaihda teemaa"
}
diff --git a/locales/fr.json b/locales/fr.json
index 7fea8f14..6147a159 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1,18 +1,24 @@
{
- "generic_channels_count": "{{count}} chaîne",
- "generic_channels_count_plural": "{{count}} chaînes",
- "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",
+ "generic_channels_count_0": "{{count}} chaîne",
+ "generic_channels_count_1": "{{count}} de chaînes",
+ "generic_channels_count_2": "{{count}} chaînes",
+ "generic_views_count_0": "{{count}} vue",
+ "generic_views_count_1": "{{count}} de vues",
+ "generic_views_count_2": "{{count}} vues",
+ "generic_videos_count_0": "{{count}} vidéo",
+ "generic_videos_count_1": "{{count}} de vidéos",
+ "generic_videos_count_2": "{{count}} vidéos",
+ "generic_playlists_count_0": "{{count}} liste de lecture",
+ "generic_playlists_count_1": "{{count}} listes de lecture",
+ "generic_playlists_count_2": "{{count}} listes de lecture",
+ "generic_subscribers_count_0": "{{count}} abonné",
+ "generic_subscribers_count_1": "{{count}} d'abonnés",
+ "generic_subscribers_count_2": "{{count}} abonnés",
+ "generic_subscriptions_count_0": "{{count}} abonnement",
+ "generic_subscriptions_count_1": "{{count}} d'abonnements",
+ "generic_subscriptions_count_2": "{{count}} abonnements",
"generic_button_delete": "Supprimer",
- "generic_button_edit": "Editer",
+ "generic_button_edit": "Modifier",
"generic_button_save": "Enregistrer",
"generic_button_cancel": "Annuler",
"generic_button_rss": "RSS",
@@ -38,7 +44,7 @@
"Import and Export Data": "Importer et exporter des données",
"Import": "Importer",
"Import Invidious data": "Importer des données Invidious au format JSON",
- "Import YouTube subscriptions": "Importer des abonnements YouTube/OPML",
+ "Import YouTube subscriptions": "Importer des abonnements YouTube aux formats OPML/CSV",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
@@ -130,14 +136,16 @@
"Subscription manager": "Gestionnaire d'abonnement",
"Token manager": "Gestionnaire de token",
"Token": "Token",
- "tokens_count": "{{count}} jeton",
- "tokens_count_plural": "{{count}} jetons",
+ "tokens_count_0": "{{count}} jeton",
+ "tokens_count_1": "{{count}} de jetons",
+ "tokens_count_2": "{{count}} jetons",
"Import/export": "Importer/Exporter",
"unsubscribe": "se désabonner",
"revoke": "révoquer",
"Subscriptions": "Abonnements",
- "subscriptions_unseen_notifs_count": "{{count}} notification non vue",
- "subscriptions_unseen_notifs_count_plural": "{{count}} notifications non vues",
+ "subscriptions_unseen_notifs_count_0": "{{count}} notification non vue",
+ "subscriptions_unseen_notifs_count_1": "{{count}} de notifications non vues",
+ "subscriptions_unseen_notifs_count_2": "{{count}} notifications non vues",
"search": "rechercher",
"Log out": "Se déconnecter",
"Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 sur GitHub.",
@@ -199,12 +207,14 @@
"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",
- "comments_view_x_replies": "Voir {{count}} réponse",
- "comments_view_x_replies_plural": "Voir {{count}} réponses",
+ "comments_view_x_replies_0": "Voir {{count}} réponse",
+ "comments_view_x_replies_1": "Voir {{count}} de réponses",
+ "comments_view_x_replies_2": "Voir {{count}} réponses",
"`x` ago": "il y a `x`",
"Load more": "Voir plus",
- "comments_points_count": "{{count}} point",
- "comments_points_count_plural": "{{count}} points",
+ "comments_points_count_0": "{{count}} point",
+ "comments_points_count_1": "{{count}} de points",
+ "comments_points_count_2": "{{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.",
@@ -322,20 +332,27 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zoulou",
- "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",
+ "generic_count_years_0": "{{count}} an",
+ "generic_count_years_1": "{{count}} ans",
+ "generic_count_years_2": "{{count}} ans",
+ "generic_count_months_0": "{{count}} mois",
+ "generic_count_months_1": "{{count}} mois",
+ "generic_count_months_2": "{{count}} mois",
+ "generic_count_weeks_0": "{{count}} semaine",
+ "generic_count_weeks_1": "{{count}} semaines",
+ "generic_count_weeks_2": "{{count}} semaines",
+ "generic_count_days_0": "{{count}} jour",
+ "generic_count_days_1": "{{count}} jours",
+ "generic_count_days_2": "{{count}} jours",
+ "generic_count_hours_0": "{{count}} heure",
+ "generic_count_hours_1": "{{count}} heures",
+ "generic_count_hours_2": "{{count}} heures",
+ "generic_count_minutes_0": "{{count}} minute",
+ "generic_count_minutes_1": "{{count}} minutes",
+ "generic_count_minutes_2": "{{count}} minutes",
+ "generic_count_seconds_0": "{{count}} seconde",
+ "generic_count_seconds_1": "{{count}} secondes",
+ "generic_count_seconds_2": "{{count}} secondes",
"Fallback comments: ": "Commentaires alternatifs : ",
"Popular": "Populaire",
"Search": "Rechercher",
@@ -467,7 +484,7 @@
"search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)",
"search_filters_apply_button": "Appliquer les filtres",
"search_message_no_results": "Aucun résultat.",
- "search_message_use_another_instance": " Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.",
+ "search_message_use_another_instance": "Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.",
"search_filters_type_option_all": "Tous les types",
"search_filters_date_label": "Date d'ajout",
"search_filters_features_option_vr180": "VR180",
@@ -486,5 +503,15 @@
"Download is disabled": "Le téléchargement est désactivé",
"Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)",
"channel_tab_releases_label": "Parutions",
- "channel_tab_podcasts_label": "Émissions audio"
+ "channel_tab_podcasts_label": "Émissions audio",
+ "Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
+ "Add to playlist: ": "Ajouter à la playlist : ",
+ "Add to playlist": "Ajouter à la playlist",
+ "Answer": "Répondre",
+ "Search for videos": "Rechercher des vidéos",
+ "The Popular feed has been disabled by the administrator.": "Le flux populaire a été désactivé par l'administrateur.",
+ "carousel_skip": "Passez le carrousel",
+ "carousel_slide": "Diapositive {{current}} sur {{total}}",
+ "carousel_go_to": "Aller à la diapositive `x`",
+ "toggle_theme": "Changer le Thème"
}
diff --git a/locales/hi.json b/locales/hi.json
index 21807c50..0a1c09dd 100644
--- a/locales/hi.json
+++ b/locales/hi.json
@@ -62,7 +62,7 @@
"Import and Export Data": "डेटा को आयात और निर्यात करें",
"Import": "आयात करें",
"Import Invidious data": "Invidious JSON डेटा आयात करें",
- "Import YouTube subscriptions": "YouTube/OPML सदस्यताएँ आयात करें",
+ "Import YouTube subscriptions": "YouTube CSV या OPML सदस्यताएँ आयात करें",
"Import FreeTube subscriptions (.db)": "FreeTube सदस्यताएँ आयात करें (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe सदस्यताएँ आयात करें (.json)",
"Import NewPipe data (.zip)": "NewPipe डेटा आयात करें (.zip)",
@@ -476,7 +476,7 @@
"generic_button_cancel": "रद्द करें",
"generic_button_rss": "आरएसएस",
"generic_button_edit": "संपादित करें",
- "generic_button_delete": "मिटाएं",
+ "generic_button_delete": "हटाएं",
"playlist_button_add_items": "वीडियो जोड़ें",
"Song: ": "गाना: ",
"channel_tab_podcasts_label": "पाॅडकास्ट",
@@ -484,5 +484,17 @@
"Import YouTube playlist (.csv)": "YouTube प्लेलिस्ट (.csv) आयात करें",
"Standard YouTube license": "मानक यूट्यूब लाइसेंस",
"Channel Sponsor": "चैनल प्रायोजक",
- "Download is disabled": "डाउनलोड करना अक्षम है"
+ "Download is disabled": "डाउनलोड करना अक्षम है",
+ "generic_channels_count": "{{count}} चैनल",
+ "generic_channels_count_plural": "{{count}} चैनल",
+ "Import YouTube watch history (.json)": "YouTube पर देखने का इतिहास आयात करें (.json)",
+ "Add to playlist": "प्लेलिस्ट में जोड़ें",
+ "Answer": "जवाब",
+ "The Popular feed has been disabled by the administrator.": "लोकप्रिय फ़ीड व्यवस्थापक द्वारा अक्षम कर दिया गया है।",
+ "toggle_theme": "थीम टॉगल करें",
+ "carousel_slide": "{{total}} में से स्लाइड {{current}}",
+ "carousel_skip": "कैरोसेल छोड़ें",
+ "Add to playlist: ": "प्लेलिस्ट में जोड़ें: ",
+ "Search for videos": "वीडियो खोजें",
+ "carousel_go_to": "स्लाइड `x` पर जाएँ"
}
diff --git a/locales/hr.json b/locales/hr.json
index ba3dd5e5..7b76a41f 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Uvezi i izvezi podatke",
"Import": "Uvezi",
"Import Invidious data": "Uvezi Invidious JSON podatke",
- "Import YouTube subscriptions": "Uvezi YouTube/OPML pretplate",
+ "Import YouTube subscriptions": "Uvezi YouTube CSV ili OPML pretplate",
"Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)",
"Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)",
"Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)",
@@ -449,30 +449,30 @@
"Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
"Chinese": "Kineski",
"Chinese (Taiwan)": "Kineski (Tajvan)",
- "Dutch (auto-generated)": "Nizozemski (automatski generiran)",
- "French (auto-generated)": "Francuski (automatski generiran)",
- "Indonesian (auto-generated)": "Indonezijski (automatski generiran)",
+ "Dutch (auto-generated)": "Nizozemski (automatski generirano)",
+ "French (auto-generated)": "Francuski (automatski generirano)",
+ "Indonesian (auto-generated)": "Indonezijski (automatski generirano)",
"Interlingue": "Interlingua",
- "Japanese (auto-generated)": "Japanski (automatski generiran)",
- "Russian (auto-generated)": "Ruski (automatski generiran)",
- "Turkish (auto-generated)": "Turski (automatski generiran)",
- "Vietnamese (auto-generated)": "Vijetnamski (automatski generiran)",
+ "Japanese (auto-generated)": "Japanski (automatski generirano)",
+ "Russian (auto-generated)": "Ruski (automatski generirano)",
+ "Turkish (auto-generated)": "Turski (automatski generirano)",
+ "Vietnamese (auto-generated)": "Vijetnamski (automatski generirano)",
"Spanish (Spain)": "Španjolski (Španjolska)",
- "Italian (auto-generated)": "Talijanski (automatski generiran)",
+ "Italian (auto-generated)": "Talijanski (automatski generirano)",
"Portuguese (Brazil)": "Portugalski (Brazil)",
"Spanish (Mexico)": "Španjolski (Meksiko)",
- "German (auto-generated)": "Njemački (automatski generiran)",
+ "German (auto-generated)": "Njemački (automatski generirano)",
"Chinese (China)": "Kineski (Kina)",
"Chinese (Hong Kong)": "Kineski (Hong Kong)",
- "Korean (auto-generated)": "Korejski (automatski generiran)",
- "Portuguese (auto-generated)": "Portugalski (automatski generiran)",
- "Spanish (auto-generated)": "Španjolski (automatski generiran)",
+ "Korean (auto-generated)": "Korejski (automatski generirano)",
+ "Portuguese (auto-generated)": "Portugalski (automatski generirano)",
+ "Spanish (auto-generated)": "Španjolski (automatski generirano)",
"preferences_watch_history_label": "Aktiviraj povijest gledanja: ",
"search_filters_title": "Filtri",
"search_filters_date_option_none": "Bilo koji datum",
"search_filters_date_label": "Datum prijenosa",
"search_message_no_results": "Nema rezultata.",
- "search_message_use_another_instance": " Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.",
+ "search_message_use_another_instance": "Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.",
"search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.",
"search_filters_features_option_vr180": "VR180",
"search_filters_duration_option_none": "Bilo koje duljine",
@@ -500,5 +500,18 @@
"generic_button_save": "Spremi",
"generic_button_cancel": "Odustani",
"generic_button_rss": "RSS",
- "channel_tab_releases_label": "Izdanja"
+ "channel_tab_releases_label": "Izdanja",
+ "generic_channels_count_0": "{{count}} kanal",
+ "generic_channels_count_1": "{{count}} kanala",
+ "generic_channels_count_2": "{{count}} kanala",
+ "Import YouTube watch history (.json)": "Uvezi YouTube povijest gledanja (.json)",
+ "Add to playlist": "Dodaj u zbirku",
+ "Add to playlist: ": "Dodaj u zbirku: ",
+ "Answer": "Odgovor",
+ "Search for videos": "Traži videa",
+ "The Popular feed has been disabled by the administrator.": "Popularni feed je administrator deaktivirao.",
+ "toggle_theme": "Uklj./Isklj. temu",
+ "carousel_slide": "Kadar {{current}} od {{total}}",
+ "carousel_go_to": "Idi na kadar `x`",
+ "carousel_skip": "Preskoči vrtuljak"
}
diff --git a/locales/hu-HU.json b/locales/hu-HU.json
index 1899b71c..8fbdd82f 100644
--- a/locales/hu-HU.json
+++ b/locales/hu-HU.json
@@ -464,5 +464,23 @@
"search_filters_features_option_vr180": "180°-os virtuális valóság",
"search_filters_apply_button": "Keresés a megadott szűrőkkel",
"Popular enabled: ": "Népszerű engedélyezve ",
- "error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>"
+ "error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>",
+ "generic_button_delete": "Törlés",
+ "generic_button_rss": "RSS",
+ "Import YouTube playlist (.csv)": "Youtube lejátszási lista (.csv) importálása",
+ "Standard YouTube license": "Alap YouTube-licensz",
+ "Add to playlist": "Hozzáadás lejátszási listához",
+ "Add to playlist: ": "Hozzáadás a lejátszási listához: ",
+ "Answer": "Válasz",
+ "Search for videos": "Keresés videókhoz",
+ "generic_channels_count": "{{count}} csatorna",
+ "generic_channels_count_plural": "{{count}} csatornák",
+ "generic_button_edit": "Szerkesztés",
+ "generic_button_save": "Mentés",
+ "generic_button_cancel": "Mégsem",
+ "playlist_button_add_items": "Videók hozzáadása",
+ "Music in this video": "Zene ezen videóban",
+ "Song: ": "Dal: ",
+ "Album: ": "Album: ",
+ "Import YouTube watch history (.json)": "Youtube megtekintési előzmények (.json) importálása"
}
diff --git a/locales/ia.json b/locales/ia.json
new file mode 100644
index 00000000..236ec4b4
--- /dev/null
+++ b/locales/ia.json
@@ -0,0 +1,45 @@
+{
+ "New password": "Nove contrasigno",
+ "preferences_player_style_label": "Stylo de reproductor: ",
+ "preferences_region_label": "Pais de contento: ",
+ "oldest": "plus ancian",
+ "published": "data de publication",
+ "invidious": "Invidious",
+ "Image CAPTCHA": "Imagine CAPTCHA",
+ "newest": "plus nove",
+ "generic_button_save": "Salveguardar",
+ "Dark mode: ": "Modo obscur: ",
+ "preferences_dark_mode_label": "Thema: ",
+ "preferences_category_subscription": "Preferentias de subscription",
+ "last": "ultime",
+ "generic_button_cancel": "Cancellar",
+ "popular": "popular",
+ "Time (h:mm:ss):": "Tempore (h:mm:ss):",
+ "preferences_autoplay_label": "Reproduction automatic: ",
+ "Sign In": "Aperir le session",
+ "Log in": "Initiar le session",
+ "preferences_speed_label": "Velocitate per predefinition: ",
+ "preferences_comments_label": "Commentos predefinite: ",
+ "light": "clar",
+ "No": "Non",
+ "youtube": "YouTube",
+ "LIVE": "IN DIRECTO",
+ "reddit": "Reddit",
+ "preferences_category_player": "Preferentias de reproductor",
+ "Preferences": "Preferentias",
+ "preferences_quality_dash_option_auto": "Automatic",
+ "dark": "obscur",
+ "generic_button_rss": "RSS",
+ "Export": "Exportar",
+ "History": "Chronologia",
+ "Password": "Contrasigno",
+ "User ID": "ID de usator",
+ "E-mail": "E-mail",
+ "Delete account?": "Deler conto?",
+ "preferences_volume_label": "Volumine del reproductor: ",
+ "preferences_sort_label": "Ordinar le videos per: ",
+ "Next page": "Pagina sequente",
+ "Previous page": "Pagina previe",
+ "Yes": "Si",
+ "Import": "Importar"
+}
diff --git a/locales/id.json b/locales/id.json
index ef677251..4c6e8548 100644
--- a/locales/id.json
+++ b/locales/id.json
@@ -446,5 +446,29 @@
"crash_page_read_the_faq": "baca <a href=\"`x`\">Soal Sering Ditanya (SSD/FAQ)</a>",
"crash_page_search_issue": "mencari <a href=\"`x`\">isu yang ada di GitHub</a>",
"crash_page_report_issue": "Jika yang di atas tidak membantu, <a href=\"`x`\">buka isu baru di GitHub</a> (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):",
- "Popular enabled: ": "Populer diaktifkan: "
+ "Popular enabled: ": "Populer diaktifkan: ",
+ "channel_tab_podcasts_label": "Podcast",
+ "Download is disabled": "Download dinonaktifkan",
+ "Channel Sponsor": "Saluran Sponsor",
+ "channel_tab_streams_label": "Streaming langsung",
+ "playlist_button_add_items": "Tambahkan video",
+ "Artist: ": "Artis: ",
+ "generic_button_save": "Simpan",
+ "generic_button_cancel": "Batal",
+ "Album: ": "Album: ",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_releases_label": "Terbit",
+ "Interlingue": "Interlingue",
+ "Song: ": "Lagu: ",
+ "generic_channels_count_0": "Saluran {{count}}",
+ "channel_tab_playlists_label": "Daftar putar",
+ "generic_button_edit": "Ubah",
+ "Music in this video": "Musik dalam video ini",
+ "generic_button_rss": "RSS",
+ "channel_tab_channels_label": "Saluran",
+ "error_video_not_in_playlist": "Video yang diminta tidak ada dalam daftar putar ini. <a href=\"`x`\">Klik di sini untuk halaman beranda daftar putar.</a>",
+ "generic_button_delete": "Hapus",
+ "Import YouTube playlist (.csv)": "Impor daftar putar YouTube (.csv)",
+ "Standard YouTube license": "Lisensi YouTube standar",
+ "Import YouTube watch history (.json)": "Impor riwayat tontonan YouTube (.json)"
}
diff --git a/locales/is.json b/locales/is.json
index ea4c4693..9d13c5cf 100644
--- a/locales/is.json
+++ b/locales/is.json
@@ -1,39 +1,39 @@
{
"LIVE": "BEINT",
- "Shared `x` ago": "Deilt `x` síðan",
+ "Shared `x` ago": "Deilt fyrir `x` síðan",
"Unsubscribe": "Afskrá",
"Subscribe": "Áskrifa",
"View channel on YouTube": "Skoða rás á YouTube",
- "View playlist on YouTube": "Skoða spilunarlisti á YouTube",
+ "View playlist on YouTube": "Skoða spilunarlista á YouTube",
"newest": "nýjasta",
"oldest": "elsta",
"popular": "vinsælt",
"last": "síðast",
"Next page": "Næsta síða",
"Previous page": "Fyrri síða",
- "Clear watch history?": "Hreinsa áhorfssögu?",
+ "Clear watch history?": "Hreinsa áhorfsferil?",
"New password": "Nýtt lykilorð",
"New passwords must match": "Nýtt lykilorð verður að passa",
- "Authorize token?": "Leyfa tákn?",
- "Authorize token for `x`?": "Leyfa tákn fyrir `x`?",
+ "Authorize token?": "Leyfa teikn?",
+ "Authorize token for `x`?": "Leyfa teikn fyrir `x`?",
"Yes": "Já",
"No": "Nei",
- "Import and Export Data": "Innflutningur og Útflutningur Gagna",
+ "Import and Export Data": "Inn- og útflutningur gagna",
"Import": "Flytja inn",
- "Import Invidious data": "Flytja inn Invidious gögn",
- "Import YouTube subscriptions": "Flytja inn YouTube áskriftir",
+ "Import Invidious data": "Flytja inn Invidious JSON-gögn",
+ "Import YouTube subscriptions": "Flytja inn YouTube CSV eða OPML-áskriftir",
"Import FreeTube subscriptions (.db)": "Flytja inn FreeTube áskriftir (.db)",
"Import NewPipe subscriptions (.json)": "Flytja inn NewPipe áskriftir (.json)",
"Import NewPipe data (.zip)": "Flytja inn NewPipe gögn (.zip)",
"Export": "Flytja út",
"Export subscriptions as OPML": "Flytja út áskriftir sem OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Flytja út áskriftir sem OPML (fyrir NewPipe & FreeTube)",
- "Export data as JSON": "Flytja út gögn sem JSON",
+ "Export data as JSON": "Flytja út Invidious-gögn sem JSON",
"Delete account?": "Eyða reikningi?",
- "History": "Saga",
- "An alternative front-end to YouTube": "Önnur framhlið fyrir YouTube",
- "JavaScript license information": "JavaScript leyfi upplýsingar",
- "source": "uppspretta",
+ "History": "Ferill",
+ "An alternative front-end to YouTube": "Annað viðmót fyrir YouTube",
+ "JavaScript license information": "Upplýsingar um notkunarleyfi JavaScript",
+ "source": "uppruni",
"Log in": "Skrá inn",
"Log in/register": "Innskráning/nýskráning",
"User ID": "Notandakenni",
@@ -47,33 +47,33 @@
"Preferences": "Kjörstillingar",
"preferences_category_player": "Kjörstillingar spilara",
"preferences_video_loop_label": "Alltaf lykkja: ",
- "preferences_autoplay_label": "Spila sjálfkrafa: ",
+ "preferences_autoplay_label": "Sjálfvirk spilun: ",
"preferences_continue_label": "Spila næst sjálfgefið: ",
- "preferences_continue_autoplay_label": "Spila næst sjálfkrafa: ",
+ "preferences_continue_autoplay_label": "Spila næsta myndskeið sjálfkrafa: ",
"preferences_listen_label": "Hlusta sjálfgefið: ",
- "preferences_local_label": "Proxy myndbönd? ",
+ "preferences_local_label": "Milliþjónn fyrir myndskeið: ",
"preferences_speed_label": "Sjálfgefinn hraði: ",
- "preferences_quality_label": "Æskilegt myndbands gæði: ",
+ "preferences_quality_label": "Æskileg gæði myndmerkis: ",
"preferences_volume_label": "Spilara hljóðstyrkur: ",
"preferences_comments_label": "Sjálfgefin ummæli: ",
"youtube": "YouTube",
- "reddit": "reddit",
+ "reddit": "Reddit",
"preferences_captions_label": "Sjálfgefin texti: ",
"Fallback captions: ": "Varatextar: ",
- "preferences_related_videos_label": "Sýna tengd myndbönd? ",
+ "preferences_related_videos_label": "Sýna tengd myndskeið? ",
"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: ",
+ "preferences_player_style_label": "Stíll spilara: ",
+ "Dark mode: ": "Dökkur hamur: ",
"preferences_dark_mode_label": "Þema: ",
- "dark": "dimmt",
+ "dark": "dökkt",
"light": "ljóst",
- "preferences_thin_mode_label": "Þunnt ham: ",
+ "preferences_thin_mode_label": "Grannur hamur: ",
"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: ",
- "preferences_max_results_label": "Fjöldi myndbanda sem sýndir eru í straumi: ",
- "preferences_sort_label": "Raða myndbönd eftir: ",
+ "Redirect homepage to feed: ": "Endurbeina heimasíðu að streymi: ",
+ "preferences_max_results_label": "Fjöldi myndskeiða sem sýnd eru í streymi: ",
+ "preferences_sort_label": "Raða myndskeiðum eftir: ",
"published": "birt",
"published - reverse": "birt - afturábak",
"alphabetically": "í stafrófsröð",
@@ -88,31 +88,31 @@
"`x` uploaded a video": "`x` hlóð upp myndband",
"`x` is live": "`x` er í beinni",
"preferences_category_data": "Gagnastillingar",
- "Clear watch history": "Hreinsa áhorfssögu",
+ "Clear watch history": "Hreinsa áhorfsferil",
"Import/export data": "Flytja inn/út gögn",
"Change password": "Breyta lykilorði",
- "Manage subscriptions": "Stjórna áskriftum",
- "Manage tokens": "Stjórna tákn",
- "Watch history": "Áhorfssögu",
+ "Manage subscriptions": "Sýsla með áskriftir",
+ "Manage tokens": "Sýsla með teikn",
+ "Watch history": "Áhorfsferill",
"Delete account": "Eyða reikningi",
"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? ",
+ "preferences_feed_menu_label": "Streymisvalmynd: ",
+ "Top enabled: ": "Vinsælast virkt? ",
"CAPTCHA enabled: ": "CAPTCHA virk? ",
"Login enabled: ": "Innskráning virk? ",
"Registration enabled: ": "Nýskráning virkjuð? ",
- "Report statistics: ": "Skrá talnagögn? ",
+ "Report statistics: ": "Skrá tölfræði? ",
"Save preferences": "Vista stillingar",
"Subscription manager": "Áskriftarstjóri",
- "Token manager": "Táknstjóri",
- "Token": "Tákn",
+ "Token manager": "Teiknastjórnun",
+ "Token": "Teikn",
"Import/export": "Flytja inn/út",
"unsubscribe": "afskrá",
"revoke": "afturkalla",
"Subscriptions": "Áskriftir",
"search": "leita",
- "Log out": "Útskrá",
+ "Log out": "Skrá út",
"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.",
@@ -122,13 +122,13 @@
"Private": "Einka",
"View all playlists": "Skoða alla spilunarlista",
"Updated `x` ago": "Uppfært `x` síðann",
- "Delete playlist `x`?": "Eiða spilunarlista `x`?",
- "Delete playlist": "Eiða spilunarlista",
+ "Delete playlist `x`?": "Eyða spilunarlista `x`?",
+ "Delete playlist": "Eyða spilunarlista",
"Create playlist": "Búa til spilunarlista",
"Title": "Titill",
- "Playlist privacy": "Spilunarlista opinberri",
- "Editing playlist `x`": "Að breyta spilunarlista `x`",
- "Watch on YouTube": "Horfa á YouTube",
+ "Playlist privacy": "Friðhelgi spilunarlista",
+ "Editing playlist `x`": "Breyti spilunarlista `x`",
+ "Watch on YouTube": "Skoða á YouTube",
"Hide annotations": "Fela glósur",
"Show annotations": "Sýna glósur",
"Genre: ": "Tegund: ",
@@ -160,26 +160,26 @@
"Wrong username or password": "Rangt notandanafn eða lykilorð",
"Password cannot be empty": "Lykilorð má ekki vera autt",
"Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir",
- "Please log in": "Vinsamlegast skráðu þig inn",
- "Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`",
+ "Please log in": "Skráðu þig inn",
+ "Invidious Private Feed for `x`": "Persónulegt Invidious-streymi fyrir `x`",
"channel:`x`": "rás:`x`",
"Deleted or invalid channel": "Eytt eða ógild rás",
"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 get channel info.": "Ekki tókst að fá upplýsingar um rásina.",
"Could not fetch comments": "Ekki tókst að sækja ummæli",
"`x` ago": "`x` síðan",
"Load more": "Hlaða meira",
"Could not create mix.": "Ekki tókst að búa til blöndu.",
"Empty playlist": "Tómur spilunarlisti",
- "Not a playlist.": "Ekki spilunarlisti.",
+ "Not a playlist.": "Er ekki spilunarlisti.",
"Playlist does not exist.": "Spilunarlisti er ekki til.",
"Could not pull trending pages.": "Ekki tókst að draga vinsælar síður.",
"Hidden field \"challenge\" is a required field": "Falinn reitur \"áskorun\" er nauðsynlegur reitur",
- "Hidden field \"token\" is a required field": "Falinn reitur \"tákn\" er nauðsynlegur reitur",
+ "Hidden field \"token\" is a required field": "Falinn reitur \"teikn\" er nauðsynlegur reitur",
"Erroneous challenge": "Röng áskorun",
- "Erroneous token": "Rangt tákn",
+ "Erroneous token": "Rangt teikn",
"No such user": "Enginn slíkur notandi",
- "Token is expired, please try again": "Tákn er útrunnið, vinsamlegast reyndu aftur",
+ "Token is expired, please try again": "Teiknið er útrunnið, reyndu aftur",
"English": "Enska",
"English (auto-generated)": "Enska (sjálfkrafa)",
"Afrikaans": "Afríkanska",
@@ -267,14 +267,14 @@
"Somali": "Sómalska",
"Southern Sotho": "Suður Sótó",
"Spanish": "Spænska",
- "Spanish (Latin America)": "Spænska (Rómönsku Ameríka)",
+ "Spanish (Latin America)": "Spænska (Rómanska Ameríka)",
"Sundanese": "Sundaneska",
"Swahili": "Svahílí",
"Swedish": "Sænska",
"Tajik": "Tadsikíska",
"Tamil": "Tamílska",
"Telugu": "Telúgú",
- "Thai": "Taílenska",
+ "Thai": "Tælenska",
"Turkish": "Tyrkneska",
"Ukrainian": "Úkraníska",
"Urdu": "Úrdú",
@@ -286,9 +286,9 @@
"Yiddish": "Jiddíska",
"Yoruba": "Jórúba",
"Zulu": "Zúlú",
- "Fallback comments: ": "Vara ummæli: ",
+ "Fallback comments: ": "Ummæli til vara: ",
"Popular": "Vinsælt",
- "Top": "Topp",
+ "Top": "Vinsælast",
"About": "Um",
"Rating: ": "Einkunn: ",
"preferences_locale_label": "Tungumál: ",
@@ -307,9 +307,194 @@
"`x` marked it with a ❤": "`x` merkti það með ❤",
"Audio mode": "Hljóð ham",
"Video mode": "Myndband ham",
- "channel_tab_videos_label": "Myndbönd",
+ "channel_tab_videos_label": "Myndskeið",
"Playlists": "Spilunarlistar",
"channel_tab_community_label": "Samfélag",
"Current version: ": "Núverandi útgáfa: ",
- "preferences_watch_history_label": "Virkja áhorfssögu: "
+ "preferences_watch_history_label": "Virkja áhorfsferil: ",
+ "Chinese (China)": "Kínverska (Kína)",
+ "Turkish (auto-generated)": "Tyrkneska (sjálfvirkt útbúið)",
+ "Search": "Leita",
+ "preferences_save_player_pos_label": "Vista staðsetningu í afspilun: ",
+ "Popular enabled: ": "Vinsælt virkjað: ",
+ "search_filters_features_option_purchased": "Keypt",
+ "Standard YouTube license": "Staðlað YouTube-notkunarleyfi",
+ "French (auto-generated)": "Franska (sjálfvirkt útbúið)",
+ "Spanish (Spain)": "Spænska (Spánn)",
+ "search_filters_title": "Síur",
+ "search_filters_date_label": "Dags. innsendingar",
+ "search_filters_features_option_four_k": "4K",
+ "search_filters_features_option_hd": "HD",
+ "crash_page_read_the_faq": "lesið <a href=\"`x`\">Algengar spurningar (FAQ)</a>",
+ "Add to playlist": "Bæta á spilunarlista",
+ "Add to playlist: ": "Bæta á spilunarlista: ",
+ "Answer": "Svar",
+ "Search for videos": "Leita að myndskeiðum",
+ "generic_channels_count": "{{count}} rás",
+ "generic_channels_count_plural": "{{count}} rásir",
+ "generic_videos_count": "{{count}} myndskeið",
+ "generic_videos_count_plural": "{{count}} myndskeið",
+ "The Popular feed has been disabled by the administrator.": "Kerfisstjórinn hefur gert Vinsælt-streymið óvirkt.",
+ "generic_playlists_count": "{{count}} spilunarlisti",
+ "generic_playlists_count_plural": "{{count}} spilunarlistar",
+ "generic_subscribers_count": "{{count}} áskrifandi",
+ "generic_subscribers_count_plural": "{{count}} áskrifendur",
+ "generic_subscriptions_count": "{{count}} áskrift",
+ "generic_subscriptions_count_plural": "{{count}} áskriftir",
+ "generic_button_delete": "Eyða",
+ "Import YouTube watch history (.json)": "Flytja inn YouTube áhorfsferil (.json)",
+ "preferences_vr_mode_label": "Gagnvirk 360 gráðu myndskeið (krefst WebGL): ",
+ "preferences_quality_dash_option_auto": "Sjálfvirkt",
+ "preferences_quality_dash_option_best": "Best",
+ "preferences_quality_dash_option_worst": "Verst",
+ "preferences_quality_dash_label": "Æskileg DASH-gæði myndmerkis: ",
+ "preferences_extend_desc_label": "Sjálfvirkt útvíkka lýsingu á myndskeiði: ",
+ "preferences_region_label": "Land efnis: ",
+ "preferences_show_nick_label": "Birta gælunafn efst: ",
+ "tokens_count": "{{count}} teikn",
+ "tokens_count_plural": "{{count}} teikn",
+ "subscriptions_unseen_notifs_count": "{{count}} óskoðuð tilkynning",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} óskoðaðar tilkynningar",
+ "Released under the AGPLv3 on Github.": "Gefið út með AGPLv3-notkunarleyfi á GitHub.",
+ "Music in this video": "Tónlist í þessu myndskeiði",
+ "Artist: ": "Flytjandi: ",
+ "Album: ": "Hljómplata: ",
+ "comments_view_x_replies": "Skoða {{count}} svar",
+ "comments_view_x_replies_plural": "Skoða {{count}} svör",
+ "comments_points_count": "{{count}} punktur",
+ "comments_points_count_plural": "{{count}} punktar",
+ "Cantonese (Hong Kong)": "Kantónska (Hong Kong)",
+ "Chinese": "Kínverska",
+ "Chinese (Hong Kong)": "Kínverska (Hong Kong)",
+ "Chinese (Taiwan)": "Kínverska (Taívan)",
+ "Japanese (auto-generated)": "Japanska (sjálfvirkt útbúið)",
+ "generic_count_minutes": "{{count}} mínúta",
+ "generic_count_minutes_plural": "{{count}} mínútur",
+ "generic_count_seconds": "{{count}} sekúnda",
+ "generic_count_seconds_plural": "{{count}} sekúndur",
+ "search_filters_date_option_hour": "Síðustu klukkustund",
+ "search_filters_apply_button": "Virkja valdar síur",
+ "next_steps_error_message_go_to_youtube": "Fara á YouTube",
+ "footer_original_source_code": "Upprunalegur grunnkóði",
+ "videoinfo_started_streaming_x_ago": "Byrjaði streymi fyrir `x` síðan",
+ "next_steps_error_message": "Á eftir þessu ættirðu að prófa: ",
+ "videoinfo_invidious_embed_link": "Ívefja tengil",
+ "download_subtitles": "Skjátextar - `x` (.vtt)",
+ "user_created_playlists": "`x` útbjó spilunarlista",
+ "user_saved_playlists": "`x` vistaði spilunarlista",
+ "Video unavailable": "Myndskeið ekki tiltækt",
+ "videoinfo_watch_on_youTube": "Skoða á YouTube",
+ "crash_page_you_found_a_bug": "Það lítur út eins og þú hafir fundið galla í Invidious!",
+ "crash_page_before_reporting": "Áður en þú tilkynnir villu, gakktu úr skugga um að þú hafir:",
+ "crash_page_switch_instance": "reynt að <a href=\"`x`\">nota annað tilvik</a>",
+ "crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að <a href=\"`x`\">opna nýja verkbeiðni (issue) á GitHub</a> (helst á ensku) og láta fylgja eftirfarandi texta í skilaboðunum þínum (alls EKKI þýða þennan texta):",
+ "channel_tab_shorts_label": "Stuttmyndir",
+ "carousel_slide": "Skyggna {{current}} af {{total}}",
+ "carousel_go_to": "Fara á skyggnu `x`",
+ "channel_tab_streams_label": "Bein streymi",
+ "channel_tab_playlists_label": "Spilunarlistar",
+ "toggle_theme": "Víxla þema",
+ "carousel_skip": "Sleppa hringekjunni",
+ "preferences_quality_option_medium": "Miðlungs",
+ "search_message_use_another_instance": "Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.",
+ "footer_source_code": "Grunnkóði",
+ "English (United Kingdom)": "Enska (Bretland)",
+ "English (United States)": "Enska (Bandarísk)",
+ "Vietnamese (auto-generated)": "Víetnamska (sjálfvirkt útbúið)",
+ "generic_count_months": "{{count}} mánuður",
+ "generic_count_months_plural": "{{count}} mánuðir",
+ "search_filters_sort_option_rating": "Einkunn",
+ "videoinfo_youTube_embed_link": "Ívefja",
+ "error_video_not_in_playlist": "Umbeðið myndskeið fyrirfinnst ekki í þessum spilunarlista. <a href=\"`x`\">Smelltu hér til að fara á heimasíðu spilunarlistans.</a>",
+ "generic_views_count": "{{count}} áhorf",
+ "generic_views_count_plural": "{{count}} áhorf",
+ "playlist_button_add_items": "Bæta við myndskeiðum",
+ "Show more": "Sýna meira",
+ "Show less": "Sýna minna",
+ "Song: ": "Lag: ",
+ "channel_tab_podcasts_label": "Hlaðvörp (podcasts)",
+ "channel_tab_releases_label": "Útgáfur",
+ "Download is disabled": "Niðurhal er óvirkt",
+ "search_filters_features_option_location": "Staðsetning",
+ "preferences_quality_dash_option_720p": "720p",
+ "Switch Invidious Instance": "Skipta um Invidious-tilvik",
+ "search_message_no_results": "Engar niðurstöður fundust.",
+ "search_message_change_filters_or_query": "Reyndu að víkka leitarsviðið og/eða breyta síunum.",
+ "Dutch (auto-generated)": "Hollenska (sjálfvirkt útbúið)",
+ "German (auto-generated)": "Þýska (sjálfvirkt útbúið)",
+ "Indonesian (auto-generated)": "Indónesíska (sjálfvirkt útbúið)",
+ "Interlingue": "Interlingue",
+ "Italian (auto-generated)": "Ítalska (sjálfvirkt útbúið)",
+ "Russian (auto-generated)": "Rússneska (sjálfvirkt útbúið)",
+ "Spanish (auto-generated)": "Spænska (sjálfvirkt útbúið)",
+ "Spanish (Mexico)": "Spænska (Mexíkó)",
+ "generic_count_hours": "{{count}} klukkustund",
+ "generic_count_hours_plural": "{{count}} klukkustundir",
+ "generic_count_years": "{{count}} ár",
+ "generic_count_years_plural": "{{count}} ár",
+ "generic_count_weeks": "{{count}} vika",
+ "generic_count_weeks_plural": "{{count}} vikur",
+ "search_filters_date_option_none": "Hvaða dagsetning sem er",
+ "Channel Sponsor": "Styrktaraðili rásar",
+ "search_filters_date_option_week": "Í þessari viku",
+ "search_filters_date_option_month": "Í þessum mánuði",
+ "search_filters_date_option_year": "Á þessu ári",
+ "search_filters_type_option_playlist": "Spilunarlisti",
+ "search_filters_type_option_show": "Þáttur",
+ "search_filters_duration_label": "Tímalengd",
+ "search_filters_duration_option_long": "Langt (> 20 mínútur)",
+ "search_filters_features_option_live": "Beint",
+ "search_filters_features_option_three_sixty": "360°",
+ "search_filters_features_option_vr180": "VR180",
+ "search_filters_features_option_three_d": "3D",
+ "search_filters_features_option_hdr": "HDR",
+ "search_filters_sort_label": "Raða eftir",
+ "search_filters_sort_option_relevance": "Samsvörun",
+ "footer_donate_page": "Styrkja",
+ "footer_modfied_source_code": "Breyttur grunnkóði",
+ "crash_page_refresh": "reynt að <a href=\"`x`\">endurlesa síðuna</a>",
+ "crash_page_search_issue": "leitað að <a href=\"`x`\">fyrirliggjandi villum á GitHub</a>",
+ "none": "ekkert",
+ "adminprefs_modified_source_code_url_label": "Slóð á gagnasafn með breyttum grunnkóða",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_small": "Lítið",
+ "preferences_category_misc": "Ýmsar kjörstillingar",
+ "preferences_automatic_instance_redirect_label": "Sjálfvirk endurbeining tilvika (farið til vara á redirect.invidious.io): ",
+ "Portuguese (auto-generated)": "Portúgalska (sjálfvirkt útbúið)",
+ "Portuguese (Brazil)": "Portúgalska (Brasilía)",
+ "generic_button_edit": "Breyta",
+ "generic_button_save": "Vista",
+ "generic_button_cancel": "Hætta við",
+ "generic_button_rss": "RSS",
+ "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_480p": "480p",
+ "preferences_quality_dash_option_360p": "360p",
+ "preferences_quality_dash_option_240p": "240p",
+ "preferences_quality_dash_option_144p": "144p",
+ "invidious": "Invidious",
+ "Korean (auto-generated)": "Kóreska (sjálfvirkt útbúið)",
+ "generic_count_days": "{{count}} dagur",
+ "generic_count_days_plural": "{{count}} dagar",
+ "search_filters_date_option_today": "Í dag",
+ "search_filters_type_label": "Tegund",
+ "search_filters_type_option_all": "Hvaða tegund sem er",
+ "search_filters_type_option_video": "Myndskeið",
+ "search_filters_type_option_channel": "Rás",
+ "search_filters_type_option_movie": "Kvikmynd",
+ "search_filters_duration_option_none": "Hvaða lengd sem er",
+ "search_filters_duration_option_short": "Stutt (< 4 mínútur)",
+ "search_filters_duration_option_medium": "Miðlungs (4 - 20 mínútur)",
+ "search_filters_features_label": "Eiginleikar",
+ "search_filters_features_option_subtitles": "Skjátextar/CC",
+ "search_filters_features_option_c_commons": "Creative Commons",
+ "search_filters_sort_option_date": "Dags. innsendingar",
+ "search_filters_sort_option_views": "Fjöldi áhorfa",
+ "next_steps_error_message_refresh": "Endurlesa",
+ "footer_documentation": "Leiðbeiningar",
+ "channel_tab_channels_label": "Rásir",
+ "Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
+ "preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
}
diff --git a/locales/it.json b/locales/it.json
index 894eb97f..309adb13 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -1,10 +1,13 @@
{
- "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",
+ "generic_subscribers_count_0": "{{count}} iscritto",
+ "generic_subscribers_count_1": "{{count}} iscritti",
+ "generic_subscribers_count_2": "{{count}} iscritti",
+ "generic_videos_count_0": "{{count}} video",
+ "generic_videos_count_1": "{{count}} video",
+ "generic_videos_count_2": "{{count}} video",
+ "generic_playlists_count_0": "{{count}} playlist",
+ "generic_playlists_count_1": "{{count}} playlist",
+ "generic_playlists_count_2": "{{count}} playlist",
"LIVE": "IN DIRETTA",
"Shared `x` ago": "Condiviso `x` fa",
"Unsubscribe": "Disiscriviti",
@@ -27,7 +30,7 @@
"Import and Export Data": "Importazione ed esportazione dati",
"Import": "Importa",
"Import Invidious data": "Importa dati Invidious in formato JSON",
- "Import YouTube subscriptions": "Importa le iscrizioni da YouTube/OPML",
+ "Import YouTube subscriptions": "Importa iscrizioni in CSV o OPML di YouTube",
"Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)",
"Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)",
@@ -113,16 +116,19 @@
"Subscription manager": "Gestione delle iscrizioni",
"Token manager": "Gestione dei gettoni",
"Token": "Gettone",
- "generic_subscriptions_count": "{{count}} iscrizione",
- "generic_subscriptions_count_plural": "{{count}} iscrizioni",
- "tokens_count": "{{count}} gettone",
- "tokens_count_plural": "{{count}} gettoni",
+ "generic_subscriptions_count_0": "{{count}} iscrizione",
+ "generic_subscriptions_count_1": "{{count}} iscrizioni",
+ "generic_subscriptions_count_2": "{{count}} iscrizioni",
+ "tokens_count_0": "{{count}} gettone",
+ "tokens_count_1": "{{count}} gettoni",
+ "tokens_count_2": "{{count}} gettoni",
"Import/export": "Importa/esporta",
"unsubscribe": "disiscriviti",
"revoke": "revoca",
"Subscriptions": "Iscrizioni",
- "subscriptions_unseen_notifs_count": "{{count}} notifica non visualizzata",
- "subscriptions_unseen_notifs_count_plural": "{{count}} notifiche non visualizzate",
+ "subscriptions_unseen_notifs_count_0": "{{count}} notifica non visualizzata",
+ "subscriptions_unseen_notifs_count_1": "{{count}} notifiche non visualizzate",
+ "subscriptions_unseen_notifs_count_2": "{{count}} notifiche non visualizzate",
"search": "Cerca",
"Log out": "Esci",
"Source available here.": "Codice sorgente.",
@@ -151,8 +157,9 @@
"Whitelisted regions: ": "Regioni in lista bianca: ",
"Blacklisted regions: ": "Regioni in lista nera: ",
"Shared `x`": "Condiviso `x`",
- "generic_views_count": "{{count}} visualizzazione",
- "generic_views_count_plural": "{{count}} visualizzazioni",
+ "generic_views_count_0": "{{count}} visualizzazione",
+ "generic_views_count_1": "{{count}} visualizzazioni",
+ "generic_views_count_2": "{{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, ma considera che il caricamento potrebbe richiedere più tempo.",
@@ -300,20 +307,27 @@
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zulu",
- "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",
+ "generic_count_years_0": "{{count}} anno",
+ "generic_count_years_1": "{{count}} anni",
+ "generic_count_years_2": "{{count}} anni",
+ "generic_count_months_0": "{{count}} mese",
+ "generic_count_months_1": "{{count}} mesi",
+ "generic_count_months_2": "{{count}} mesi",
+ "generic_count_weeks_0": "{{count}} settimana",
+ "generic_count_weeks_1": "{{count}} settimane",
+ "generic_count_weeks_2": "{{count}} settimane",
+ "generic_count_days_0": "{{count}} giorno",
+ "generic_count_days_1": "{{count}} giorni",
+ "generic_count_days_2": "{{count}} giorni",
+ "generic_count_hours_0": "{{count}} ora",
+ "generic_count_hours_1": "{{count}} ore",
+ "generic_count_hours_2": "{{count}} ore",
+ "generic_count_minutes_0": "{{count}} minuto",
+ "generic_count_minutes_1": "{{count}} minuti",
+ "generic_count_minutes_2": "{{count}} minuti",
+ "generic_count_seconds_0": "{{count}} secondo",
+ "generic_count_seconds_1": "{{count}} secondi",
+ "generic_count_seconds_2": "{{count}} secondi",
"Fallback comments: ": "Commenti alternativi: ",
"Popular": "Popolare",
"Search": "Cerca",
@@ -417,10 +431,12 @@
"search_filters_duration_option_short": "Corto (< 4 minuti)",
"search_filters_duration_option_long": "Lungo (> 20 minuti)",
"search_filters_features_option_purchased": "Acquistato",
- "comments_view_x_replies": "Vedi {{count}} risposta",
- "comments_view_x_replies_plural": "Vedi {{count}} risposte",
- "comments_points_count": "{{count}} punto",
- "comments_points_count_plural": "{{count}} punti",
+ "comments_view_x_replies_0": "Vedi {{count}} risposta",
+ "comments_view_x_replies_1": "Vedi {{count}} risposte",
+ "comments_view_x_replies_2": "Vedi {{count}} risposte",
+ "comments_points_count_0": "{{count}} punto",
+ "comments_points_count_1": "{{count}} punti",
+ "comments_points_count_2": "{{count}} punti",
"Portuguese (auto-generated)": "Portoghese (generati automaticamente)",
"crash_page_you_found_a_bug": "Sembra che tu abbia trovato un bug in Invidious!",
"crash_page_switch_instance": "provato a <a href=\"`x`\">usare un'altra istanza</a>",
@@ -433,7 +449,7 @@
"Portuguese (Brazil)": "Portoghese (Brasile)",
"preferences_watch_history_label": "Attiva cronologia di riproduzione: ",
"French (auto-generated)": "Francese (generati automaticamente)",
- "search_message_use_another_instance": " Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
+ "search_message_use_another_instance": "Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
"search_message_no_results": "Nessun risultato trovato.",
"search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.",
"English (United States)": "Inglese (Stati Uniti)",
@@ -484,5 +500,18 @@
"generic_button_delete": "Elimina",
"generic_button_save": "Salva",
"playlist_button_add_items": "Aggiungi video",
- "channel_tab_podcasts_label": "Podcast"
+ "channel_tab_podcasts_label": "Podcast",
+ "generic_channels_count_0": "{{count}} canale",
+ "generic_channels_count_1": "{{count}} canali",
+ "generic_channels_count_2": "{{count}} canali",
+ "Import YouTube watch history (.json)": "Importa la cronologia delle visualizzazioni di YouTube (.json)",
+ "Answer": "Risposta",
+ "toggle_theme": "Cambia Tema",
+ "Add to playlist": "Aggiungi alla playlist",
+ "Add to playlist: ": "Aggiungi alla playlist ",
+ "Search for videos": "Cerca dei video",
+ "The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
+ "carousel_slide": "Fotogramma {{current}} di {{total}}",
+ "carousel_skip": "Salta la galleria",
+ "carousel_go_to": "Vai al fotogramma `x`"
}
diff --git a/locales/ja.json b/locales/ja.json
index 6fc02e2d..7fc9d604 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -53,7 +53,7 @@
"preferences_category_player": "プレイヤーの設定",
"preferences_video_loop_label": "常にループ: ",
"preferences_autoplay_label": "自動再生: ",
- "preferences_continue_label": "次の動画を自動再生: ",
+ "preferences_continue_label": "次の動画に移動: ",
"preferences_continue_autoplay_label": "次の動画を自動再生: ",
"preferences_listen_label": "音声モードを使用: ",
"preferences_local_label": "動画視聴にプロキシを経由: ",
@@ -68,7 +68,7 @@
"preferences_related_videos_label": "関連動画を表示: ",
"preferences_annotations_label": "最初からアノテーションを表示: ",
"preferences_extend_desc_label": "動画の説明文を自動的に拡張: ",
- "preferences_vr_mode_label": "対話的な360°動画 (WebGL が必要): ",
+ "preferences_vr_mode_label": "対話的な360°動画 (WebGLが必要): ",
"preferences_category_visual": "外観設定",
"preferences_player_style_label": "プレイヤーのスタイル: ",
"Dark mode: ": "ダークモード: ",
@@ -125,9 +125,9 @@
"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 JavaScript license information.": "JavaScriptライセンス情報",
"View privacy policy.": "個人情報保護方針",
"Trending": "急上昇",
"Public": "公開",
@@ -144,7 +144,7 @@
"Show more": "もっと見る",
"Show less": "表示を少なく",
"Watch on YouTube": "YouTubeで視聴",
- "Switch Invidious Instance": "Invidious インスタンスの変更",
+ "Switch Invidious Instance": "Invidiousインスタンスの変更",
"Hide annotations": "アノテーションを隠す",
"Show annotations": "アノテーションを表示",
"Genre: ": "ジャンル: ",
@@ -363,9 +363,9 @@
"search_filters_features_option_location": "場所",
"search_filters_features_option_hdr": "HDR",
"Current version: ": "現在のバージョン: ",
- "next_steps_error_message": "下記のものを試して下さい: ",
- "next_steps_error_message_refresh": "再読込",
- "next_steps_error_message_go_to_youtube": "YouTubeへ",
+ "next_steps_error_message": "以下をお試しください: ",
+ "next_steps_error_message_refresh": "再読み込み",
+ "next_steps_error_message_go_to_youtube": "YouTubeを開く",
"search_filters_duration_option_short": "4分未満",
"footer_documentation": "説明書",
"footer_source_code": "ソースコード",
@@ -396,7 +396,7 @@
"download_subtitles": "字幕 - `x` (.vtt)",
"search_filters_features_option_purchased": "購入済み",
"preferences_quality_option_dash": "DASH (適応的画質)",
- "preferences_quality_dash_option_worst": "最悪",
+ "preferences_quality_dash_option_worst": "最低",
"preferences_quality_dash_option_best": "最高",
"videoinfo_started_streaming_x_ago": "`x`前に配信を開始",
"videoinfo_watch_on_youTube": "YouTubeで視聴",
@@ -434,7 +434,7 @@
"crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す",
"crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
"Popular enabled: ": "人気動画を有効化 ",
- "search_message_use_another_instance": " <a href=\"`x`\">別のインスタンス上での検索</a>も可能です。",
+ "search_message_use_another_instance": "<a href=\"`x`\">別のインスタンス上での検索</a>も可能です。",
"search_filters_apply_button": "選択したフィルターを適用",
"user_saved_playlists": "`x`個の保存済みの再生リスト",
"crash_page_you_found_a_bug": "Invidious のバグのようです!",
@@ -459,7 +459,7 @@
"Song: ": "曲: ",
"Channel Sponsor": "チャンネルのスポンサー",
"Standard YouTube license": "標準 Youtube ライセンス",
- "Download is disabled": "ダウンロード: このインスタンスでは未対応",
+ "Download is disabled": "ダウンロード: このインスタンスは未対応",
"Import YouTube playlist (.csv)": "YouTube 再生リストをインポート (.csv)",
"generic_button_delete": "削除",
"generic_button_cancel": "キャンセル",
@@ -468,5 +468,16 @@
"generic_button_edit": "編集",
"generic_button_save": "保存",
"generic_button_rss": "RSS",
- "playlist_button_add_items": "動画を追加"
+ "playlist_button_add_items": "動画を追加",
+ "generic_channels_count_0": "{{count}}個のチャンネル",
+ "Import YouTube watch history (.json)": "YouTube 視聴履歴をインポート (.json)",
+ "Add to playlist": "再生リストに追加",
+ "Add to playlist: ": "再生リストに追加: ",
+ "Answer": "回答",
+ "Search for videos": "動画を検索",
+ "The Popular feed has been disabled by the administrator.": "人気の動画のページは管理者によって無効にされています。",
+ "carousel_go_to": "スライド`x`を表示",
+ "carousel_slide": "スライド{{current}} / 全{{total}}個中",
+ "carousel_skip": "画像のスライド表示をスキップ",
+ "toggle_theme": "テーマの切り替え"
}
diff --git a/locales/ko.json b/locales/ko.json
index e02a8316..4864860a 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -12,8 +12,8 @@
"Dark mode: ": "다크 모드: ",
"preferences_player_style_label": "플레이어 스타일: ",
"preferences_category_visual": "환경 설정",
- "preferences_vr_mode_label": "VR 영상 활성화(WebGL 필요): ",
- "preferences_extend_desc_label": "자동으로 비디오 설명을 확장: ",
+ "preferences_vr_mode_label": "360도 영상 활성화 (WebGL 필요): ",
+ "preferences_extend_desc_label": "자동으로 비디오 설명 펼치기: ",
"preferences_annotations_label": "기본으로 주석 표시: ",
"preferences_related_videos_label": "관련 동영상 보기: ",
"Fallback captions: ": "대체 자막: ",
@@ -46,9 +46,9 @@
"source": "출처",
"JavaScript license information": "자바스크립트 라이선스 정보",
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
- "History": "역사",
+ "History": "시청 기록",
"Delete account?": "계정을 삭제 하시겠습니까?",
- "Export data as JSON": "JSON으로 데이터 내보내기",
+ "Export data as JSON": "인비디어스 데이터 내보내기 (.json)",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)",
"Export subscriptions as OPML": "OPML로 구독 내보내기",
"Export": "내보내기",
@@ -65,13 +65,13 @@
"Authorize token?": "토큰을 승인하시겠습니까?",
"New passwords must match": "새 비밀번호는 일치해야 합니다",
"New password": "새 비밀번호",
- "Clear watch history?": "재생 기록을 삭제 하시겠습니까?",
+ "Clear watch history?": "시청 기록을 지우시겠습니까?",
"Previous page": "이전 페이지",
"Next page": "다음 페이지",
"last": "마지막",
"Shared `x` ago": "`x` 전",
"popular": "인기",
- "oldest": "오래된순",
+ "oldest": "과거순",
"newest": "최신순",
"View playlist on YouTube": "유튜브에서 재생목록 보기",
"View channel on YouTube": "유튜브에서 채널 보기",
@@ -123,7 +123,7 @@
"Create playlist": "재생목록 생성",
"Trending": "급상승",
"Delete playlist": "재생목록 삭제",
- "Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?",
+ "Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?",
"Updated `x` ago": "`x` 전에 업데이트됨",
"Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.",
"View all playlists": "모든 재생목록 보기",
@@ -267,7 +267,7 @@
"Bulgarian": "불가리아어",
"Bosnian": "보스니아어",
"Belarusian": "벨라루스어",
- "View more comments on Reddit": "레딧에서 더 많은 댓글 보기",
+ "View more comments on Reddit": "레딧에서 댓글 더 보기",
"View YouTube comments": "유튜브 댓글 보기",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.",
"Shared `x`": "`x` 업로드",
@@ -351,7 +351,7 @@
"News": "뉴스",
"Gaming": "게임",
"Music": "음악",
- "Default": "디폴트",
+ "Default": "전체",
"Rating: ": "평점: ",
"About": "정보",
"Top": "최고",
@@ -419,7 +419,7 @@
"Portuguese (Brazil)": "포르투갈어 (브라질)",
"search_message_no_results": "결과가 없습니다.",
"search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.",
- "search_message_use_another_instance": " 당신은 <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
+ "search_message_use_another_instance": " <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.",
"English (United States)": "영어 (미국)",
"Chinese": "중국어",
"Chinese (China)": "중국어 (중국)",
@@ -460,7 +460,7 @@
"Music in this video": "동영상 속 음악",
"Artist: ": "아티스트: ",
"Download is disabled": "다운로드가 비활성화 되어있음",
- "Import YouTube playlist (.csv)": "유튜브 플레이리스트 가져오기 (.csv)",
+ "Import YouTube playlist (.csv)": "유튜브 재생목록 가져오기 (.csv)",
"playlist_button_add_items": "동영상 추가",
"channel_tab_podcasts_label": "팟캐스트",
"generic_button_delete": "삭제",
@@ -468,5 +468,16 @@
"generic_button_save": "저장",
"generic_button_cancel": "취소",
"generic_button_rss": "RSS",
- "channel_tab_releases_label": "출시"
+ "channel_tab_releases_label": "발매",
+ "generic_channels_count_0": "{{count}} 채널",
+ "Import YouTube watch history (.json)": "유튜브 시청 기록 가져오기 (.json)",
+ "Add to playlist": "재생목록에 추가",
+ "Add to playlist: ": "재생목록에 추가: ",
+ "Answer": "답",
+ "The Popular feed has been disabled by the administrator.": "관리자가 인기 피드를 비활성화했습니다.",
+ "carousel_skip": "캐러셀 건너뛰기",
+ "carousel_go_to": "`x` 슬라이드로 이동",
+ "Search for videos": "비디오 검색",
+ "toggle_theme": "테마 전환",
+ "carousel_slide": "{{total}}의 슬라이드 {{current}}"
}
diff --git a/locales/lmo.json b/locales/lmo.json
new file mode 100644
index 00000000..9d2fe2a8
--- /dev/null
+++ b/locales/lmo.json
@@ -0,0 +1,232 @@
+{
+ "Add to playlist": "Giont a la playlist",
+ "generic_button_edit": "Modifega",
+ "generic_button_save": "Salva",
+ "LIVE": "EN DÌRETT",
+ "Shared `x` ago": "Compartiss `x` fa",
+ "View channel on YouTube": "Varda el canal sul YouTube",
+ "newest": "plù nöeuf",
+ "oldest": "plù végh",
+ "Search for videos": "Càuta dei video",
+ "The Popular feed has been disabled by the administrator.": "la seziùn Pupular la è stada disabilidada par l'amministratòr.",
+ "generic_channels_count": "{{count}} canal",
+ "generic_channels_count_plural": "{{count}} canai",
+ "popular": "pupular",
+ "generic_views_count": "{{count}} visualisazión",
+ "generic_views_count_plural": "{{count}} visualisazióni",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} video",
+ "generic_playlists_count": "{{count}} playlist",
+ "generic_playlists_count_plural": "{{count}} playlist",
+ "generic_subscriptions_count": "{{count}} inscrizion",
+ "generic_subscriptions_count_plural": "{{count}} inscrizioni",
+ "generic_button_cancel": "Cançéla",
+ "generic_button_delete": "Scassa via",
+ "Unsubscribe": "Disinscriviti",
+ "Next page": "Pagina siguènt",
+ "Previous page": "Pagina indrèe",
+ "Clear watch history?": "Cançélar la istoria dei video vardàa?",
+ "New password": "Nöeva password",
+ "Import and Export Data": "Importazion ed esportazion dei dat",
+ "Import": "Importa",
+ "Import Invidious data": "Importa i dat de l'Invidious en el formàt JSON",
+ "Import YouTube subscriptions": "Importa le inscrizioni dal YouTube/OPML",
+ "Import YouTube playlist (.csv)": "Importa le playlist dal YouTube (.csv)",
+ "Import YouTube watch history (.json)": "Importa la istoria de visualizazzion dal YouTube (.json)",
+ "Import FreeTube subscriptions (.db)": "Importa le inscrizioni dal FreeTube (.db)",
+ "Import NewPipe data (.zip)": "importa i dat del NewPipe (.zip)",
+ "Export": "Esporta",
+ "Export subscriptions as OPML": "Esporta inscrizioni com OPML",
+ "Export data as JSON": "Esporta i dat de l'Invidious com JSON",
+ "Delete account?": "Eliminà 'l profil?",
+ "History": "Istoria",
+ "An alternative front-end to YouTube": "Una interfacia alternatif al YouTube",
+ "JavaScript license information": "Informaziòn su la licensa JavaScript",
+ "source": "font",
+ "Log in": "Và dent",
+ "Text CAPTCHA": "Tèst del CAPTCHA",
+ "Image CAPTCHA": "Imàgen del CAPTCHA",
+ "Sign In": "Ven denter",
+ "Register": "Registres",
+ "E-mail": "E-mail",
+ "Preferences": "Priferenze",
+ "preferences_category_player": "Priferenze del riprodutòr",
+ "preferences_quality_option_dash": "DASH (qualità adatif)",
+ "preferences_quality_option_hd720": "HD720",
+ "preferences_quality_option_medium": "Media",
+ "preferences_quality_option_small": "Picinina",
+ "preferences_quality_dash_option_auto": "Auto",
+ "preferences_quality_dash_option_best": "Meglior",
+ "preferences_quality_dash_option_worst": "Peggior",
+ "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",
+ "reddit": "Reddit",
+ "invidious": "Invidious",
+ "light": "ciar",
+ "dark": "scur",
+ "preferences_category_misc": "Priferenze varie",
+ "preferences_category_subscription": "Priferenze de le inscrizioni",
+ "published": "data de publicazion",
+ "published - reverse": "data de publicazion - invertì",
+ "alphabetically": "orden alfabetegh",
+ "channel name": "nòm del canal",
+ "channel name - reverse": "nòm del canal - invertì",
+ "Enable web notifications": "Empisa le notifeghe da la red",
+ "`x` uploaded a video": "`x` la ghàa cargà un video",
+ "`x` is live": "`x` l'è 'n dirétt adés",
+ "preferences_category_data": "Priferenze dei dat",
+ "Import/export data": "Importa/esporta i dat",
+ "Change password": "Cambia la parola ciav",
+ "Manage subscriptions": "Organisa le inscrizioni",
+ "Manage tokens": "Organisa i tokens",
+ "Watch history": "Istoria dei video vardà",
+ "Delete account": "Cançéla 'l profil",
+ "Save preferences": "Salva priferenze",
+ "Subscription manager": "Manegia le inscrizioni",
+ "Token": "Token",
+ "tokens_count": "{{count}} token",
+ "tokens_count_plural": "{{count}} token",
+ "Import/export": "Importa/esporta",
+ "unsubscribe": "disinscriviti",
+ "subscriptions_unseen_notifs_count": "{{count}} notifega mia visualisada",
+ "subscriptions_unseen_notifs_count_plural": "{{count}} notifeghe mia visualisade",
+ "Log out": "Sortiss",
+ "Released under the AGPLv3 on Github.": "Publicà en el GitHub suta licenza AGPLv3.",
+ "Source available here.": "Codegh de la font disponivel chì.",
+ "View privacy policy.": "Varda la pulitega de la privacy.",
+ "Trending": "De moda",
+ "Public": "Publico",
+ "Unlisted": "Non en lista",
+ "Private": "Privàt",
+ "View all playlists": "Varda tute le playlist",
+ "Updated `x` ago": "Giurnà `x` fa",
+ "Delete playlist `x`?": "Cançéla la playlist `x`?",
+ "Delete playlist": "Cançéla playlist",
+ "Create playlist": "Crea playlist",
+ "Title": "Titel",
+ "Playlist privacy": "Privacy de la playlist",
+ "Editing playlist `x`": "Modifega playlist `x`",
+ "playlist_button_add_items": "Gionta video",
+ "Show more": "Varda plù",
+ "Show less": "Varda mèn",
+ "Watch on YouTube": "Varda sul YouTube",
+ "Switch Invidious Instance": "Cambia la instanza del Invidious",
+ "search_message_no_results": "Non è stat truvà nigun resultat.",
+ "Cebuano": "Cebuano",
+ "Chinese (Traditional)": "Cines (Tradizional)",
+ "Corsican": "Còrso",
+ "Croatian": "Cruat",
+ "Georgian": "Georgian",
+ "Gujarati": "Gujarati",
+ "Hawaiian": "Hawaiian",
+ "Kurdish": "Curd",
+ "Latin": "Latin",
+ "Latvian": "Letton",
+ "Lithuanian": "Lituan",
+ "Malay": "Males",
+ "Maltese": "Maltes",
+ "Mongolian": "móngol",
+ "Persian": "Persian",
+ "Polish": "Polacch",
+ "Portuguese": "Portoghes",
+ "Romanian": "Romen",
+ "Scottish Gaelic": "Gaelich Scusses",
+ "Spanish (Latin America)": "Spagnöl (America do Sùd)",
+ "Thai": "Thai",
+ "Western Frisian": "Frisian do ponente",
+ "Basque": "Basco",
+ "Chinese (Simplified)": "Cines (Semplificà)",
+ "Haitian Creole": "Creolo de Haiti",
+ "Galician": "Galiçian",
+ "Hebrew": "Ebraich",
+ "Korean": "Corean",
+ "View playlist on YouTube": "Varda la playlist sul YouTube",
+ "Southern Sotho": "Sotho do Sùd",
+ "generic_button_rss": "RSS",
+ "Welsh": "Galés",
+ "Answer": "Resposta",
+ "New passwords must match": "Le nöeve password la deven esere uguai",
+ "Authorize token?": "Autorisà 'l token?",
+ "Authorize token for `x`?": "Autorisà 'l token par `x`?",
+ "Yes": "Sì",
+ "No": "No",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esporta inscrizioni com OPML (par 'l NewPipe e 'l FreeTube)",
+ "Log in/register": "Va dent/Registres",
+ "User ID": "ID utent",
+ "Password": "Parola ciav",
+ "Time (h:mm:ss):": "Temp (h:mm:ss):",
+ "Import NewPipe subscriptions (.json)": "importa le inscrizioni dal NewPipe (.json)",
+ "youtube": "YouTube",
+ "alphabetically - reverse": "orden alfabetegh - invertì",
+ "preferences_category_visual": "Priferenze grafeghe",
+ "Clear watch history": "Scompartiss la istoria dei video vardà",
+ "preferences_category_admin": "Priferenze de l'amministratòr",
+ "Token manager": "Manegia i token",
+ "Subscriptions": "Inscrizioni",
+ "search": "cerca",
+ "View JavaScript license information.": "Varda le informazion su la licenza JavaScript.",
+ "search_message_change_filters_or_query": "Ti pödi pruà a slargà la reçerca e/or a cangià i filter.",
+ "generic_subscribers_count": "{{count}} inscritt",
+ "generic_subscribers_count_plural": "{{count}} inscriti",
+ "Subscribe": "Inscriviti",
+ "last": "ùltim",
+ "Add to playlist: ": "Giont a la playlist: ",
+ "preferences_autoplay_label": "Reproduzion automatega: ",
+ "preferences_continue_label": "Reproduzion seguént preimpostà: ",
+ "preferences_continue_autoplay_label": "Fa partì en automatico el video seguént: ",
+ "preferences_listen_label": "Modalità de sól audio preimpostà: ",
+ "preferences_local_label": "Proxy par i video: ",
+ "preferences_watch_history_label": "Ativà la istoria de reproduzion: ",
+ "preferences_speed_label": "Velocità preimpostà: ",
+ "preferences_volume_label": "Volume del reprodutòr: ",
+ "preferences_region_label": "Nazion del contenut: ",
+ "Dark mode: ": "Tema scur ",
+ "preferences_dark_mode_label": "Tema: ",
+ "preferences_thin_mode_label": "Modalità legera: ",
+ "preferences_automatic_instance_redirect_label": "Reindirizazzion automatega de la instansa (rivèrt a redirect.invidious.io): ",
+ "Hide annotations": "Piaca le notazioni",
+ "Show annotations": "Mostra le notazioni",
+ "Family friendly? ": "Adàt a tüti? ",
+ "Whitelisted regions: ": "Regioni en lista bianca: ",
+ "Blacklisted regions: ": "Regioni en lista negher ",
+ "Artist: ": "Artista: ",
+ "Song: ": "Cansòn ",
+ "Album: ": "Album: ",
+ "View YouTube comments": "Varda i comment dal YouTube",
+ "Password cannot be empty": "La parola ciav la no po miga esser voeut",
+ "channel:`x`": "Canal:`x`",
+ "Bangla": "Bengales",
+ "Hausa": "Hausa",
+ "Hindi": "Hindi",
+ "Hmong": "Hmong",
+ "Igbo": "Igbo",
+ "Javanese": "Javanese",
+ "Kannada": "Kannada",
+ "Kazakh": "Kazach",
+ "Khmer": "Khmer",
+ "Kyrgyz": "Kirghiz",
+ "Lao": "Lao",
+ "Luxembourgish": "Lussemburghes",
+ "Macedonian": "Macedon",
+ "Malagasy": "Malagascio",
+ "Malayalam": "Malayalam",
+ "Maori": "Maori",
+ "Marathi": "Marati",
+ "Nepali": "Nepales",
+ "Nyanja": "Nyanja",
+ "Pashto": "Pashtu",
+ "Punjabi": "Punjabi",
+ "Samoan": "Samoan",
+ "Standard YouTube license": "licensa predefinida de Youtube",
+ "License: ": "Licensa: ",
+ "Music in this video": "Musica en sto video",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ué! Sembra che ti la g'hà desabilitàa el JavaScript. Schisa chì para vardà i comment, ma cunsidera che peul vörse 'n po plu de temp a cargà.",
+ "preferences_video_loop_label": "Reproduci sèmper: "
+}
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index 216b559f..17d64baf 100644
--- a/locales/nb-NO.json
+++ b/locales/nb-NO.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Importer- og eksporter data",
"Import": "Importer",
"Import Invidious data": "Importer Invidious-JSON-data",
- "Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer",
+ "Import YouTube subscriptions": "Importer YouTube CSV eller OPML-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
@@ -322,13 +322,13 @@
"channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "relevans",
"search_filters_sort_option_rating": "vurdering",
- "search_filters_sort_option_date": "dato",
+ "search_filters_sort_option_date": "Opplastingsdato",
"search_filters_sort_option_views": "visninger",
"search_filters_type_label": "innholdstype",
"search_filters_duration_label": "varighet",
"search_filters_features_label": "funksjoner",
"search_filters_sort_label": "sorter",
- "search_filters_date_option_hour": "time",
+ "search_filters_date_option_hour": "Siste time",
"search_filters_date_option_today": "i dag",
"search_filters_date_option_week": "uke",
"search_filters_date_option_month": "måned",
@@ -459,7 +459,7 @@
"search_message_no_results": "Resultatløst.",
"search_filters_type_option_all": "Alle typer",
"search_filters_duration_option_none": "Enhver varighet",
- "search_message_use_another_instance": " Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
+ "search_message_use_another_instance": "Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
"search_filters_date_label": "Opplastningsdato",
"search_filters_apply_button": "Bruk valgte filtre",
"search_filters_date_option_none": "Siden begynnelsen",
@@ -484,5 +484,17 @@
"generic_button_save": "Lagre",
"generic_button_cancel": "Avbryt",
"generic_button_rss": "RSS",
- "playlist_button_add_items": "Legg til videoer"
+ "playlist_button_add_items": "Legg til videoer",
+ "generic_channels_count": "{{count}} kanal",
+ "generic_channels_count_plural": "{{count}} kanaler",
+ "Import YouTube watch history (.json)": "Importere YouTube visningshistorikk (.json)",
+ "carousel_go_to": "Gå til lysark `x`",
+ "Search for videos": "Søk i videoer",
+ "Answer": "Svar",
+ "carousel_slide": "Lysark {{current}} av {{total}}",
+ "carousel_skip": "Hopp over karusellen",
+ "Add to playlist": "Legg til i spilleliste",
+ "Add to playlist: ": "Legg til i spilleliste: ",
+ "The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
+ "toggle_theme": "Endre utseende"
}
diff --git a/locales/nl.json b/locales/nl.json
index aa5da731..f10b3593 100644
--- a/locales/nl.json
+++ b/locales/nl.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Gegevens im- en exporteren",
"Import": "Importeren",
"Import Invidious data": "JSON-gegevens Invidious importeren",
- "Import YouTube subscriptions": "YouTube-/OPML-abonnementen importeren",
+ "Import YouTube subscriptions": "YouTube CVS of OPML-abonnementen importeren",
"Import FreeTube subscriptions (.db)": "FreeTube-abonnementen importeren (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe-abonnementen importeren (.json)",
"Import NewPipe data (.zip)": "NewPipe-gegevens importeren (.zip)",
@@ -86,7 +86,7 @@
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
"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",
+ "Enable web notifications": "Systeemmeldingen inschakelen",
"`x` uploaded a video": "`x` heeft een video geüpload",
"`x` is live": "`x` zendt nu live uit",
"preferences_category_data": "Gegevensinstellingen",
@@ -107,10 +107,10 @@
"Report statistics: ": "Statistieken bijhouden? ",
"Save preferences": "Instellingen opslaan",
"Subscription manager": "Abonnementen beheren",
- "Token manager": "Toegangssleutels beheren",
+ "Token manager": "Toegangssleutelbeheerder",
"Token": "Toegangssleutel",
"Import/export": "Importeren/Exporteren",
- "unsubscribe": "Deabonneren",
+ "unsubscribe": "deabonneren",
"revoke": "Intrekken",
"Subscriptions": "Abonnementen",
"search": "zoeken",
@@ -192,15 +192,15 @@
"Arabic": "Arabisch",
"Armenian": "Armeens",
"Azerbaijani": "Azerbeidzjaans",
- "Bangla": "Bangla",
+ "Bangla": "Bengaals",
"Basque": "Baskisch",
- "Belarusian": "Wit-Rrussisch",
+ "Belarusian": "Wit-Russisch",
"Bosnian": "Bosnisch",
"Bulgarian": "Bulgaars",
"Burmese": "Birmaans",
"Catalan": "Catalaans",
- "Cebuano": "Cebuano",
- "Chinese (Simplified)": "Chinees (Veereenvoudigd)",
+ "Cebuano": "Cebuaans",
+ "Chinese (Simplified)": "Chinees (Vereenvoudigd)",
"Chinese (Traditional)": "Chinees (Traditioneel)",
"Corsican": "Corsicaans",
"Croatian": "Kroatisch",
@@ -217,23 +217,23 @@
"German": "Duits",
"Greek": "Grieks",
"Gujarati": "Gujarati",
- "Haitian Creole": "Creools",
+ "Haitian Creole": "Haïtiaans Creools",
"Hausa": "Hausa",
"Hawaiian": "Hawaïaans",
- "Hebrew": "Heebreeuws",
+ "Hebrew": "Hebreeuws",
"Hindi": "Hindi",
"Hmong": "Hmong",
"Hungarian": "Hongaars",
"Icelandic": "IJslands",
- "Igbo": "Igbo",
+ "Igbo": "Ikbo",
"Indonesian": "Indonesisch",
"Irish": "Iers",
"Italian": "Italiaans",
"Japanese": "Japans",
"Javanese": "Javaans",
- "Kannada": "Kannada",
+ "Kannada": "Kannada-taal",
"Kazakh": "Kazachs",
- "Khmer": "Khmer",
+ "Khmer": "Khmer-taal",
"Korean": "Koreaans",
"Kurdish": "Koerdisch",
"Kyrgyz": "Kirgizisch",
@@ -245,10 +245,10 @@
"Macedonian": "Macedonisch",
"Malagasy": "Malagassisch",
"Malay": "Maleisisch",
- "Malayalam": "Malayalam",
+ "Malayalam": "Malayalam-taal",
"Maltese": "Maltees",
"Maori": "Maorisch",
- "Marathi": "Marathi",
+ "Marathi": "Marathi-taal",
"Mongolian": "Mongools",
"Nepali": "Nepalees",
"Norwegian Bokmål": "Noors (Bokmål)",
@@ -309,7 +309,7 @@
"(edited)": "(bewerkt)",
"YouTube comment permalink": "Link naar YouTube-reactie",
"permalink": "permalink",
- "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
+ "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met een ❤",
"Audio mode": "Audiomodus",
"Video mode": "Videomodus",
"channel_tab_videos_label": "Video's",
@@ -317,13 +317,13 @@
"channel_tab_community_label": "Gemeenschap",
"search_filters_sort_option_relevance": "relevantie",
"search_filters_sort_option_rating": "beoordeling",
- "search_filters_sort_option_date": "datum",
+ "search_filters_sort_option_date": "Upload datum",
"search_filters_sort_option_views": "keren bekeken",
"search_filters_type_label": "Type inhoud",
"search_filters_duration_label": "duur",
"search_filters_features_label": "eigenschappen",
"search_filters_sort_label": "sorteren",
- "search_filters_date_option_hour": "uur",
+ "search_filters_date_option_hour": "Laatste uur",
"search_filters_date_option_today": "vandaag",
"search_filters_date_option_week": "week",
"search_filters_date_option_month": "maand",
@@ -357,7 +357,7 @@
"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",
- "next_steps_error_message": "Waarna u moet proberen om: ",
+ "next_steps_error_message": "Waarna u zou kunnen proberen om: ",
"footer_source_code": "Bron-code",
"search_filters_duration_option_long": "Lang (> 20 minuten)",
"preferences_quality_option_dash": "DASH (adaptieve kwaliteit)",
@@ -396,7 +396,7 @@
"Dutch (auto-generated)": "Nederlands (automatisch gegenereerd)",
"tokens_count": "{{count}} token",
"tokens_count_plural": "{{count}} tokens",
- "generic_count_seconds": "{{count}} second",
+ "generic_count_seconds": "{{count}} seconde",
"generic_count_seconds_plural": "{{count}} seconden",
"generic_count_weeks": "{{count}} week",
"generic_count_weeks_plural": "{{count}} weken",
@@ -449,8 +449,8 @@
"generic_playlists_count_plural": "{{count}} afspeellijsten",
"Chinese (Hong Kong)": "Chinees (Hongkong)",
"Korean (auto-generated)": "Koreaans (automatisch gegenereerd)",
- "search_filters_apply_button": "Geselecteerd filters toepassen",
- "search_message_use_another_instance": " Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
+ "search_filters_apply_button": "Geselecteerde filters toepassen",
+ "search_message_use_another_instance": "Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
"Cantonese (Hong Kong)": "Kantonees (Hongkong)",
"Chinese (China)": "Chinees (China)",
"crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt",
@@ -462,5 +462,39 @@
"Spanish (auto-generated)": "Spaans (automatisch gegenereerd)",
"crash_page_you_found_a_bug": "Je lijkt een bug in Invidious tegengekomen te zijn!",
"search_filters_duration_option_medium": "Gemiddeld (4 - 20 minuten)",
- "crash_page_report_issue": "Indien het bovenstaande niet hielp, gelieve dan <a href=\"`x`\">een nieuw ticket op GitHub</a> te openen (liefst in het Engels) en neem de volgende tekst op in je bericht (gelieve deze NIET te vertalen):"
+ "crash_page_report_issue": "Indien het bovenstaande niet hielp, gelieve dan <a href=\"`x`\">een nieuw ticket op GitHub</a> te openen (liefst in het Engels) en neem de volgende tekst op in je bericht (gelieve deze NIET te vertalen):",
+ "channel_tab_podcasts_label": "Podcasts",
+ "Download is disabled": "Downloaden is uitgeschakeld",
+ "Channel Sponsor": "Kanaalsponsor",
+ "channel_tab_streams_label": "Livestreams",
+ "playlist_button_add_items": "Video's toevoegen",
+ "Artist: ": "Artiest: ",
+ "generic_button_save": "Opslaan",
+ "generic_button_cancel": "Annuleren",
+ "Album: ": "Album: ",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_releases_label": "Uitgaves",
+ "Song: ": "Lied: ",
+ "generic_channels_count": "{{count}} kanaal",
+ "generic_channels_count_plural": "{{count}} kanalen",
+ "Popular enabled: ": "Populair ingeschakeld: ",
+ "channel_tab_playlists_label": "Afspeellijsten",
+ "generic_button_edit": "Bewerken",
+ "Music in this video": "Muziek in deze video",
+ "generic_button_rss": "RSS",
+ "channel_tab_channels_label": "Kanalen",
+ "error_video_not_in_playlist": "De gevraagde video bestaat niet in deze afspeellijst. <a href=\"`x`\">Klik hier voor de startpagina van de afspeellijst.</a>",
+ "generic_button_delete": "Verwijderen",
+ "Import YouTube playlist (.csv)": "YouTube-afspeellijst importeren (.csv)",
+ "Standard YouTube license": "Standaard YouTube-licentie",
+ "Import YouTube watch history (.json)": "YouTube-kijkgeschiedenis importeren (.json)",
+ "Add to playlist": "Aan afspeellijst toevoegen",
+ "The Popular feed has been disabled by the administrator.": "De Populaire feed werd uitgeschakeld door een beheerder.",
+ "carousel_slide": "Dia {{current}} van {{total}}",
+ "carousel_go_to": "Naar dia `x` gaan",
+ "Add to playlist: ": "Aan afspeellijst toevoegen: ",
+ "Answer": "Antwoorden",
+ "Search for videos": "Naar video's zoeken",
+ "carousel_skip": "Carousel overslaan",
+ "toggle_theme": "Thema omschakelen"
}
diff --git a/locales/pl.json b/locales/pl.json
index f1924c8a..73d65647 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -21,13 +21,13 @@
"Import and Export Data": "Import i eksport danych",
"Import": "Import",
"Import Invidious data": "Importuj dane JSON Invidious",
- "Import YouTube subscriptions": "Importuj subskrybcje z YouTube/OPML",
- "Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)",
- "Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)",
+ "Import YouTube subscriptions": "Importuj subskrypcje YouTube w formacie CSV lub OPML",
+ "Import FreeTube subscriptions (.db)": "Importuj subskrypcje FreeTube (.db)",
+ "Import NewPipe subscriptions (.json)": "Importuj subskrypcje NewPipe (.json)",
"Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)",
"Export": "Eksport",
- "Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrybcje jako OPML (dla NewPipe i FreeTube)",
+ "Export subscriptions as OPML": "Eksportuj subskrypcje jako OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrypcje jako OPML (dla NewPipe i FreeTube)",
"Export data as JSON": "Eksportuj dane Invidious jako JSON",
"Delete account?": "Usunąć konto?",
"History": "Historia",
@@ -73,7 +73,7 @@
"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_category_subscription": "Preferencje subskrypcji",
"preferences_annotations_subscribed_label": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
"preferences_max_results_label": "Liczba filmów widoczna na stronie subskrybcji: ",
@@ -95,7 +95,7 @@
"Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych",
"Change password": "Zmień hasło",
- "Manage subscriptions": "Organizuj subskrybcje",
+ "Manage subscriptions": "Organizuj subskrypcje",
"Manage tokens": "Zarządzaj tokenami",
"Watch history": "Historia",
"Delete account": "Usuń konto",
@@ -115,7 +115,7 @@
"Import/export": "Import/Eksport",
"unsubscribe": "odsubskrybuj",
"revoke": "cofnij",
- "Subscriptions": "Subskrybcje",
+ "Subscriptions": "Subskrypcje",
"search": "szukaj",
"Log out": "Wyloguj",
"Source available here.": "Kod źródłowy dostępny tutaj.",
@@ -478,7 +478,7 @@
"search_filters_date_label": "Data przesłania",
"search_filters_features_option_vr180": "VR180",
"search_filters_date_option_none": "Dowolna data",
- "search_message_use_another_instance": " Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.",
+ "search_message_use_another_instance": "Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.",
"search_filters_type_option_all": "Dowolny typ",
"search_filters_duration_option_none": "Dowolna długość",
"search_filters_duration_option_medium": "Średnia (4-20 minut)",
@@ -492,7 +492,7 @@
"Song: ": "Piosenka: ",
"Channel Sponsor": "Sponsor kanału",
"Standard YouTube license": "Standardowa licencja YouTube",
- "Import YouTube playlist (.csv)": "Importuj playlistę YouTube (.csv)",
+ "Import YouTube playlist (.csv)": "Importuj playlistę z YouTube (.csv)",
"generic_button_edit": "Edytuj",
"generic_button_cancel": "Anuluj",
"generic_button_rss": "RSS",
@@ -500,5 +500,18 @@
"channel_tab_releases_label": "Wydania",
"generic_button_delete": "Usuń",
"generic_button_save": "Zapisz",
- "playlist_button_add_items": "Dodaj filmy"
+ "playlist_button_add_items": "Dodaj filmy",
+ "generic_channels_count_0": "{{count}} kanał",
+ "generic_channels_count_1": "{{count}} kanały",
+ "generic_channels_count_2": "{{count}} kanałów",
+ "Import YouTube watch history (.json)": "Importuj historię oglądania z YouTube (.json)",
+ "toggle_theme": "Przełącz motyw",
+ "The Popular feed has been disabled by the administrator.": "Kanał Popularne został wyłączony przez administratora.",
+ "Answer": "Odpowiedź",
+ "Search for videos": "Wyszukaj filmy",
+ "Add to playlist": "Dodaj do playlisty",
+ "Add to playlist: ": "Dodaj do playlisty: ",
+ "carousel_slide": "Slajd {{current}} z {{total}}",
+ "carousel_skip": "Pomiń karuzelę",
+ "carousel_go_to": "Przejdź do slajdu `x`"
}
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index 68a6e3ab..1d29d2fe 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -1,27 +1,27 @@
{
"LIVE": "AO VIVO",
- "Shared `x` ago": "Compartilhado `x` atrás",
+ "Shared `x` ago": "Publicado há `x`",
"Unsubscribe": "Cancelar inscrição",
"Subscribe": "Inscrever-se",
"View channel on YouTube": "Ver canal no YouTube",
- "View playlist on YouTube": "Ver lista de reprodução no YouTube",
+ "View playlist on YouTube": "Ver playlist no YouTube",
"newest": "mais recentes",
"oldest": "mais antigos",
"popular": "populares",
- "last": "último",
+ "last": "últimos",
"Next page": "Próxima página",
"Previous page": "Página anterior",
- "Clear watch history?": "Limpar histórico de reprodução?",
+ "Clear watch history?": "Limpar histórico de exibição?",
"New password": "Nova senha",
- "New passwords must match": "Nova senha deve ser igual",
- "Authorize token?": "Autorizar o token?",
- "Authorize token for `x`?": "Autorizar o token para `x`?",
+ "New passwords must match": "As senhas devem ser iguais",
+ "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/exportar dados",
"Import": "Importar",
- "Import Invidious data": "Importar dados em JSON do Invidious",
- "Import YouTube subscriptions": "Importar inscrições do YouTube/OPML",
+ "Import Invidious data": "Importar dados JSON do Invidious",
+ "Import YouTube subscriptions": "Importar inscrições no formato CSV ou OPML do YouTube",
"Import FreeTube subscriptions (.db)": "Importar inscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar inscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
@@ -32,49 +32,49 @@
"Delete account?": "Excluir conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa para o YouTube",
- "JavaScript license information": "Informação de licença do JavaScript",
- "source": "código-fonte",
- "Log in": "Entrar",
- "Log in/register": "Entrar/Registrar",
+ "JavaScript license information": "Informações sobre a licença do JavaScript",
+ "source": "fonte",
+ "Log in": "Fazer login",
+ "Log in/register": "Fazer login/criar conta",
"User ID": "Usuário",
"Password": "Senha",
"Time (h:mm:ss):": "Hora (h:mm:ss):",
- "Text CAPTCHA": "CAPTCHA em texto",
- "Image CAPTCHA": "CAPTCHA em imagem",
- "Sign In": "Entrar",
- "Register": "Registrar",
+ "Text CAPTCHA": "Mudar para um desafio de texto",
+ "Image CAPTCHA": "Mudar para um desafio visual",
+ "Sign In": "Fazer login",
+ "Register": "Criar conta",
"E-mail": "E-mail",
"Preferences": "Preferências",
- "preferences_category_player": "Preferências do reprodutor",
+ "preferences_category_player": "Preferências de reprodução",
"preferences_video_loop_label": "Repetir sempre: ",
"preferences_autoplay_label": "Reprodução automática: ",
- "preferences_continue_label": "Sempre reproduzir próximo: ",
+ "preferences_continue_label": "Reproduzir a seguir, por padrão: ",
"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: ",
+ "preferences_comments_label": "Comentários padrão: ",
"youtube": "YouTube",
"reddit": "Reddit",
- "preferences_captions_label": "Preferência de legendas: ",
+ "preferences_captions_label": "Legendas padrão: ",
"Fallback captions: ": "Legendas alternativas: ",
"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_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ",
"preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ",
"preferences_category_visual": "Preferências visuais",
- "preferences_player_style_label": "Estilo do tocador: ",
+ "preferences_player_style_label": "Estilo de reprodução: ",
"Dark mode: ": "Modo escuro: ",
"preferences_dark_mode_label": "Tema: ",
"dark": "escuro",
"light": "claro",
"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_automatic_instance_redirect_label": "Redirecionamento automático de instâncias (alternativa 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: ",
+ "preferences_annotations_subscribed_label": "Mostrar anotações por padrão para canais inscritos? ",
"Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ",
"preferences_max_results_label": "Número de vídeos no feed: ",
"preferences_sort_label": "Ordenar vídeos por: ",
@@ -84,54 +84,55 @@
"alphabetically - reverse": "alfabética - ordem inversa",
"channel name": "nome do canal",
"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: ",
- "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",
+ "Only show latest video from channel: ": "Mostrar apenas vídeos mais recentes do canal: ",
+ "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não assistido do canal: ",
+ "preferences_unseen_only_label": "Mostrar apenas vídeos não assistido: ",
+ "preferences_notifications_only_label": "Mostrar apenas notificações (se houver): ",
+ "Enable web notifications": "Ativar notificações da Web",
+ "`x` uploaded a video": "`x` publicou um vídeo",
"`x` is live": "`x` está ao vivo",
"preferences_category_data": "Preferências de dados",
- "Clear watch history": "Limpar histórico de reprodução",
- "Import/export data": "Importar/Exportar dados",
+ "Clear watch history": "Limpar histórico de exibição",
+ "Import/export data": "Importar/exportar dados",
"Change password": "Alterar senha",
"Manage subscriptions": "Gerenciar inscrições",
"Manage tokens": "Gerenciar tokens",
- "Watch history": "Histórico de reprodução",
- "Delete account": "Apagar sua conta",
+ "Watch history": "Histórico de exibição",
+ "Delete account": "Excluir conta",
"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: ",
- "Registration enabled: ": "Habilitar registro: ",
- "Report statistics: ": "Habilitar estatísticas: ",
+ "preferences_default_home_label": "Página inicial padrão: ",
+ "preferences_feed_menu_label": "Guias de feed preferidos: ",
+ "preferences_show_nick_label": "Mostrar nome de usuário na parte superior: ",
+ "Top enabled: ": "Destaques ativados: ",
+ "CAPTCHA enabled: ": "CAPTCHA ativado: ",
+ "Login enabled: ": "Fazer login ativado: ",
+ "Registration enabled: ": "Criar conta ativado: ",
+ "Report statistics: ": "Relatório de estatísticas: ",
"Save preferences": "Salvar preferências",
"Subscription manager": "Gerenciador de inscrições",
"Token manager": "Gerenciador de tokens",
"Token": "Token",
- "tokens_count": "{{count}} token",
- "tokens_count_plural": "{{count}} tokens",
- "Import/export": "Importar/Exportar",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokens",
+ "tokens_count_2": "{{count}} tokens",
+ "Import/export": "Importar/exportar",
"unsubscribe": "cancelar inscrição",
"revoke": "revogar",
"Subscriptions": "Inscrições",
- "search": "Pesquisar",
+ "search": "pesquisar",
"Log out": "Sair",
"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.",
- "Trending": "Tendências",
+ "View JavaScript license information.": "Informações de licença JavaScript.",
+ "View privacy policy.": "Política de privacidade.",
+ "Trending": "Em alta",
"Public": "Público",
"Unlisted": "Não listado",
"Private": "Privado",
- "View all playlists": "Mostrar todas listas de reprodução",
+ "View all playlists": "Ver todas as playlists",
"Updated `x` ago": "Atualizado `x` atrás",
- "Delete playlist `x`?": "Apagar a playlist `x`?",
- "Delete playlist": "Apagar playlist",
+ "Delete playlist `x`?": "Excluir playlist `x`?",
+ "Delete playlist": "Excluir playlist",
"Create playlist": "Criar playlist",
"Title": "Título",
"Playlist privacy": "Privacidade da playlist",
@@ -139,24 +140,24 @@
"Show more": "Mostrar mais",
"Show less": "Mostrar menos",
"Watch on YouTube": "Assistir no YouTube",
- "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Switch Invidious Instance": "Alterar instância Invidious",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Gênero: ",
"License: ": "Licença: ",
"Family friendly? ": "Filtrar conteúdo impróprio: ",
"Wilson score: ": "Pontuação de Wilson: ",
- "Engagement: ": "Empenho: ",
+ "Engagement: ": "Engajamento: ",
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
- "Shared `x`": "Compartilhado `x`",
+ "Shared `x`": "Publicado em `x`",
"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.",
+ "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 você está com o JavaScript desativado. Clique aqui para ver os comentários, mas lembre-se de que eles podem demorar um pouco mais para carregar.",
"View YouTube comments": "Ver comentários no YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário",
"": "Ver `x` comentários"
},
"View Reddit comments": "Ver comentários no Reddit",
@@ -165,7 +166,7 @@
"Incorrect password": "Senha incorreta",
"Wrong answer": "Resposta incorreta",
"Erroneous CAPTCHA": "CAPTCHA inválido",
- "CAPTCHA is a required field": "O CAPTCHA é um campo obrigatório",
+ "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
"User ID is a required field": "O nome de usuário é um campo obrigatório",
"Password is a required field": "A senha é um campo obrigatório",
"Wrong username or password": "Nome de usuário ou senha inválidos",
@@ -174,17 +175,17 @@
"Please log in": "Por favor, inicie sua sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal: `x`",
- "Deleted or invalid channel": "Este canal foi apagado ou é inválido",
+ "Deleted or invalid channel": "Canal excluído 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",
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
"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.",
- "Playlist does not exist.": "A lista de reprodução não existe.",
- "Could not pull trending pages.": "Não foi possível obter as páginas dos vídeos em alta.",
+ "Empty playlist": "Playlist vazia",
+ "Not a playlist.": "Não é uma playlist.",
+ "Playlist does not exist.": "A playlist não existe.",
+ "Could not pull trending pages.": "Não foi possível obter as páginas de vídeos em alta.",
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é obrigatório",
"Erroneous challenge": "Desafio inválido",
@@ -297,117 +298,132 @@
"Yiddish": "Iídiche",
"Yoruba": "Iorubá",
"Zulu": "Zulu",
- "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: ",
+ "generic_count_years_0": "{{count}} ano",
+ "generic_count_years_1": "{{count}} anos",
+ "generic_count_years_2": "{{count}} anos",
+ "generic_count_months_0": "{{count}} mês",
+ "generic_count_months_1": "{{count}} meses",
+ "generic_count_months_2": "{{count}} meses",
+ "generic_count_weeks_0": "{{count}} semana",
+ "generic_count_weeks_1": "{{count}} semanas",
+ "generic_count_weeks_2": "{{count}} semanas",
+ "generic_count_days_0": "{{count}} dia",
+ "generic_count_days_1": "{{count}} dias",
+ "generic_count_days_2": "{{count}} dias",
+ "generic_count_hours_0": "{{count}} hora",
+ "generic_count_hours_1": "{{count}} horas",
+ "generic_count_hours_2": "{{count}} horas",
+ "generic_count_minutes_0": "{{count}} minuto",
+ "generic_count_minutes_1": "{{count}} minutos",
+ "generic_count_minutes_2": "{{count}} minutos",
+ "generic_count_seconds_0": "{{count}} segundo",
+ "generic_count_seconds_1": "{{count}} segundos",
+ "generic_count_seconds_2": "{{count}} segundos",
+ "Fallback comments: ": "Alternativa para comentários: ",
"Popular": "Populares",
- "Search": "Procurar",
- "Top": "No topo",
+ "Search": "Pesquisar",
+ "Top": "Destaques",
"About": "Sobre",
"Rating: ": "Avaliação: ",
"preferences_locale_label": "Idioma: ",
- "View as playlist": "Ver como lista de reprodução",
+ "View as playlist": "Ver como playlist",
"Default": "Padrão",
"Music": "Músicas",
"Gaming": "Jogos",
"News": "Notícias",
"Movies": "Filmes",
- "Download": "Baixar",
+ "Download": "Download",
"Download as: ": "Baixar como: ",
"%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Link permanente do comentário no YouTube",
"permalink": "Link permanente",
- "`x` marked it with a ❤": "`x` foi marcado como ❤",
+ "`x` marked it with a ❤": "`x` foi marcado com um ❤",
"Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo",
"channel_tab_videos_label": "Vídeos",
- "Playlists": "Listas de reprodução",
+ "Playlists": "Playlists",
"channel_tab_community_label": "Comunidade",
- "search_filters_sort_option_relevance": "relevância",
- "search_filters_sort_option_rating": "avaliação",
- "search_filters_sort_option_date": "data",
- "search_filters_sort_option_views": "visualizações",
- "search_filters_type_label": "content_type",
- "search_filters_duration_label": "duração",
- "search_filters_features_label": "recursos",
- "search_filters_sort_label": "ordenar",
- "search_filters_date_option_hour": "hora",
- "search_filters_date_option_today": "hoje",
- "search_filters_date_option_week": "semana",
- "search_filters_date_option_month": "mês",
- "search_filters_date_option_year": "ano",
- "search_filters_type_option_video": "vídeo",
+ "search_filters_sort_option_relevance": "Relevância",
+ "search_filters_sort_option_rating": "Avaliação",
+ "search_filters_sort_option_date": "Data de publicação",
+ "search_filters_sort_option_views": "Visualizações",
+ "search_filters_type_label": "Tipo",
+ "search_filters_duration_label": "Duração",
+ "search_filters_features_label": "Características",
+ "search_filters_sort_label": "Ordenar por",
+ "search_filters_date_option_hour": "Últimas horas",
+ "search_filters_date_option_today": "Hoje",
+ "search_filters_date_option_week": "Esta semana",
+ "search_filters_date_option_month": "Este mês",
+ "search_filters_date_option_year": "Este ano",
+ "search_filters_type_option_video": "Vídeo",
"search_filters_type_option_channel": "Canal",
- "search_filters_type_option_playlist": "playlist",
- "search_filters_type_option_movie": "filme",
- "search_filters_type_option_show": "show",
- "search_filters_features_option_hd": "hd",
- "search_filters_features_option_subtitles": "legendas",
- "search_filters_features_option_c_commons": "creative_commons",
- "search_filters_features_option_three_d": "3d",
- "search_filters_features_option_live": "ao vivo",
- "search_filters_features_option_four_k": "4k",
- "search_filters_features_option_location": "localização",
- "search_filters_features_option_hdr": "hdr",
+ "search_filters_type_option_playlist": "Playlist",
+ "search_filters_type_option_movie": "Filme",
+ "search_filters_type_option_show": "Séries",
+ "search_filters_features_option_hd": "HD",
+ "search_filters_features_option_subtitles": "Legendas",
+ "search_filters_features_option_c_commons": "Creative Commons",
+ "search_filters_features_option_three_d": "3D",
+ "search_filters_features_option_live": "AO VIVO",
+ "search_filters_features_option_four_k": "4K",
+ "search_filters_features_option_location": "Localização",
+ "search_filters_features_option_hdr": "HDR",
"Current version: ": "Versão atual: ",
"next_steps_error_message": "Depois disso, você deve tentar: ",
- "next_steps_error_message_refresh": "Atualizar",
+ "next_steps_error_message_refresh": "Recarregar",
"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",
+ "footer_donate_page": "Doar",
+ "adminprefs_modified_source_code_url_label": "URL para o repositório do código-fonte modificado",
"search_filters_duration_option_long": "Longo (> 20 minutos)",
"search_filters_duration_option_short": "Curto (< 4 minutos)",
"footer_documentation": "Documentação",
- "footer_source_code": "Código fonte",
- "footer_original_source_code": "Código fonte original",
+ "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_quality_dash_label": "Qualidade de vídeo DASH 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",
+ "generic_videos_count_0": "{{count}} vídeo",
+ "generic_videos_count_1": "{{count}} vídeos",
+ "generic_videos_count_2": "{{count}} vídeos",
+ "generic_playlists_count_0": "{{count}} playlist",
+ "generic_playlists_count_1": "{{count}} playlists",
+ "generic_playlists_count_2": "{{count}} playlists",
+ "generic_subscribers_count_0": "{{count}} inscrito",
+ "generic_subscribers_count_1": "{{count}} inscritos",
+ "generic_subscribers_count_2": "{{count}} inscritos",
+ "generic_subscriptions_count_0": "{{count}} inscrição",
+ "generic_subscriptions_count_1": "{{count}} inscrições",
+ "generic_subscriptions_count_2": "{{count}} inscrições",
+ "subscriptions_unseen_notifs_count_0": "{{count}} notificação não visualizada",
+ "subscriptions_unseen_notifs_count_1": "{{count}} notificações não visualizadas",
+ "subscriptions_unseen_notifs_count_2": "{{count}} notificações não visualizadas",
+ "comments_view_x_replies_0": "Ver {{count}} resposta",
+ "comments_view_x_replies_1": "Ver {{count}} respostas",
+ "comments_view_x_replies_2": "Ver {{count}} respostas",
+ "comments_points_count_0": "{{count}} ponto",
+ "comments_points_count_1": "{{count}} pontos",
+ "comments_points_count_2": "{{count}} pontos",
"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: ",
+ "crash_page_before_reporting": "Antes de informar um erro, verifique se você:",
+ "preferences_save_player_pos_label": "Salvar posição de reprodução: ",
"search_filters_features_option_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": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
- "generic_views_count": "{{count}} visualização",
- "generic_views_count_plural": "{{count}} visualizações",
+ "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
+ "generic_views_count_0": "{{count}} visualização",
+ "generic_views_count_1": "{{count}} visualizações",
+ "generic_views_count_2": "{{count}} visualizações",
"preferences_quality_option_dash": "DASH (qualidade adaptável)",
"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_best": "Melhor qualidade",
+ "preferences_quality_dash_option_worst": "Pior qualidade",
"preferences_quality_dash_option_2160p": "2160p",
"preferences_quality_dash_option_1440p": "1440p",
"preferences_quality_dash_option_1080p": "1080p",
@@ -419,17 +435,17 @@
"invidious": "Invidious",
"preferences_quality_option_medium": "Médio",
"search_filters_features_option_three_sixty": "360°",
- "none": "none",
+ "none": "nenhum",
"videoinfo_watch_on_youTube": "Assistir no YouTube",
- "videoinfo_youTube_embed_link": "Embutir",
- "videoinfo_invidious_embed_link": "Link Embutido",
+ "videoinfo_youTube_embed_link": "Embed",
+ "videoinfo_invidious_embed_link": "Embed link",
"download_subtitles": "Legendas - `x` (.vtt)",
- "user_created_playlists": "`x` listas de reprodução criadas",
- "user_saved_playlists": "`x` listas de reprodução salvas",
+ "user_created_playlists": "`x` playlists criadas",
+ "user_saved_playlists": "`x` playlists salvas",
"Video unavailable": "Vídeo indisponível",
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão a `x`",
"search_filters_title": "Filtro",
- "preferences_watch_history_label": "Ative o histórico de exibição: ",
+ "preferences_watch_history_label": "Ativar histórico de exibição: ",
"search_message_no_results": "Nenhum resultado encontrado.",
"search_message_change_filters_or_query": "Tente ampliar sua consulta de pesquisa e/ou alterar os filtros.",
"English (United Kingdom)": "Inglês (Reino Unido)",
@@ -449,7 +465,7 @@
"Portuguese (Brazil)": "Português (Brasil)",
"Russian (auto-generated)": "Russo (gerado automaticamente)",
"Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)",
- "search_filters_date_label": "Data de upload",
+ "search_filters_date_label": "Data de publicação",
"search_filters_date_option_none": "Qualquer data",
"Dutch (auto-generated)": "Holandês (gerado automaticamente)",
"French (auto-generated)": "Francês (gerado automaticamente)",
@@ -458,31 +474,44 @@
"Spanish (auto-generated)": "Espanhol (gerado automaticamente)",
"Spanish (Mexico)": "Espanhol (México)",
"search_filters_duration_option_none": "Qualquer duração",
- "search_message_use_another_instance": " Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.",
+ "search_message_use_another_instance": "Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.",
"Spanish (Spain)": "Espanhol (Espanha)",
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
"search_filters_features_option_vr180": "VR180",
- "Popular enabled: ": "Popular habilitado: ",
+ "Popular enabled: ": "Página \"Populares\" ativada: ",
"error_video_not_in_playlist": "O vídeo solicitado não existe nesta playlist. <a href=\"`x`\">Clique aqui para acessar a página inicial da playlist.</a>",
"channel_tab_channels_label": "Canais",
- "channel_tab_playlists_label": "Listas de reprodução",
- "channel_tab_shorts_label": "Curtos",
- "channel_tab_streams_label": "Ao Vivo",
+ "channel_tab_playlists_label": "Playlists",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_streams_label": "Transmissão ao vivo",
"Music in this video": "Música neste vídeo",
"Artist: ": "Artista: ",
"Album: ": "Álbum: ",
"Standard YouTube license": "Licença padrão do YouTube",
"Song: ": "Música: ",
- "Channel Sponsor": "Patrocinador do Canal",
- "Download is disabled": "Download está desabilitado",
- "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)",
- "generic_button_delete": "Apagar",
+ "Channel Sponsor": "Patrocinador do canal",
+ "Download is disabled": "Download indisponível",
+ "Import YouTube playlist (.csv)": "Importar playlist do YouTube (.csv)",
+ "generic_button_delete": "Excluir",
"generic_button_save": "Salvar",
"generic_button_edit": "Editar",
"playlist_button_add_items": "Adicionar vídeos",
"channel_tab_releases_label": "Lançamentos",
"channel_tab_podcasts_label": "Podcasts",
"generic_button_cancel": "Cancelar",
- "generic_button_rss": "RSS"
+ "generic_button_rss": "RSS",
+ "generic_channels_count_0": "{{count}} canal",
+ "generic_channels_count_1": "{{count}} canais",
+ "generic_channels_count_2": "{{count}} canais",
+ "Import YouTube watch history (.json)": "Importar histórico de exibição do YouTube (.json)",
+ "toggle_theme": "Alternar tema",
+ "Add to playlist": "Adicionar à playlist",
+ "Add to playlist: ": "Adicionar à playlist: ",
+ "Search for videos": "Pesquisar vídeos",
+ "The Popular feed has been disabled by the administrator.": "O feed \"Populares\" foi desativado pelo administrador.",
+ "Answer": "Resposta",
+ "carousel_slide": "Slide {{current}} de {{total}}",
+ "carousel_skip": "Ignorar carrossel",
+ "carousel_go_to": "Ir ao slide `x`"
}
diff --git a/locales/pt-PT.json b/locales/pt-PT.json
index 3834c9e2..f83a80a9 100644
--- a/locales/pt-PT.json
+++ b/locales/pt-PT.json
@@ -130,12 +130,12 @@
"Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x` atrás",
- "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
+ "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'",
+ "Editing playlist `x`": "A editar lista de reprodução `x`",
"Show more": "Mostrar mais",
"Show less": "Mostrar menos",
"Watch on YouTube": "Ver no YouTube",
@@ -150,8 +150,8 @@
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Partilhado `x`",
- "Premieres in `x`": "Estreias em 'x'",
- "Premieres `x`": "Estreias 'x'",
+ "Premieres in `x`": "Estreias em `x`",
+ "Premieres `x`": "Estreias `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
@@ -173,7 +173,7 @@
"Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
"Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
- "channel:`x`": "canal:'x'",
+ "channel:`x`": "canal:`x`",
"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.",
diff --git a/locales/pt.json b/locales/pt.json
index e7cc4810..0bb1be66 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -1,25 +1,25 @@
{
- "search_filters_type_option_show": "Espetáculo",
+ "search_filters_type_option_show": "Séries",
"search_filters_sort_option_views": "Visualizações",
- "search_filters_sort_option_date": "Data de envio",
+ "search_filters_sort_option_date": "Data de carregamento",
"search_filters_sort_option_rating": "Avaliação",
"search_filters_sort_option_relevance": "Relevância",
- "Switch Invidious Instance": "Mudar a instância do Invidious",
+ "Switch Invidious Instance": "Alterar instância Invidious",
"Show less": "Mostrar menos",
"Show more": "Mostrar mais",
- "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.",
+ "Released under the AGPLv3 on Github.": "Disponibilizada 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 (necessita de WebGL): ",
- "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ",
- "next_steps_error_message_go_to_youtube": "Ir ao YouTube",
+ "preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ",
+ "preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ",
+ "next_steps_error_message_go_to_youtube": "Ir para o YouTube",
"next_steps_error_message": "Pode tentar as seguintes opções: ",
- "next_steps_error_message_refresh": "Atualizar",
+ "next_steps_error_message_refresh": "Recarregar",
"search_filters_features_option_hdr": "HDR",
"search_filters_features_option_location": "Localização",
"search_filters_features_option_four_k": "4K",
- "search_filters_features_option_live": "Ao Vivo",
+ "search_filters_features_option_live": "Direto",
"search_filters_features_option_three_d": "3D",
"search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_subtitles": "Legendas",
@@ -37,45 +37,52 @@
"search_filters_features_label": "Funcionalidades",
"search_filters_duration_label": "Duração",
"search_filters_type_label": "Tipo",
- "permalink": "hiperligação permanente",
- "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube",
+ "permalink": "ligação permanente",
+ "YouTube comment permalink": "Ligação permanente do comentário no YouTube",
"Download as: ": "Descarregar como: ",
"Download": "Descarregar",
- "Default": "Predefinido",
+ "Default": "Padrão",
"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",
+ "generic_count_years_0": "{{count}} ano",
+ "generic_count_years_1": "{{count}} anos",
+ "generic_count_years_2": "{{count}} anos",
+ "generic_count_months_0": "{{count}} mês",
+ "generic_count_months_1": "{{count}} meses",
+ "generic_count_months_2": "{{count}} meses",
+ "generic_count_weeks_0": "{{count}} semana",
+ "generic_count_weeks_1": "{{count}} semanas",
+ "generic_count_weeks_2": "{{count}} semanas",
+ "generic_count_days_0": "{{count}} dia",
+ "generic_count_days_1": "{{count}} dias",
+ "generic_count_days_2": "{{count}} dias",
+ "generic_count_hours_0": "{{count}} hora",
+ "generic_count_hours_1": "{{count}} horas",
+ "generic_count_hours_2": "{{count}} horas",
+ "generic_count_minutes_0": "{{count}} minuto",
+ "generic_count_minutes_1": "{{count}} minutos",
+ "generic_count_minutes_2": "{{count}} minutos",
+ "generic_count_seconds_0": "{{count}} segundo",
+ "generic_count_seconds_1": "{{count}} segundos",
+ "generic_count_seconds_2": "{{count}} segundos",
"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.",
+ "Could not pull trending pages.": "Não foi possível obter a página de tendências.",
+ "Could not create mix.": "Não foi possível criar o mix.",
"Deleted or invalid channel": "Canal eliminado ou inválido",
- "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.",
+ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, mas tenha e conta que podem levar mais tempo para carregar.",
"Delete playlist": "Eliminar lista de reprodução",
- "Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
+ "Delete playlist `x`?": "Eliminar lista de reprodução `x`?",
"search": "pesquisar",
"unsubscribe": "anular subscrição",
- "Import/export": "Importar / exportar",
+ "Import/export": "Importar/exportar",
"Save preferences": "Guardar preferências",
"Top enabled: ": "Destaques ativados: ",
"Delete account": "Eliminar conta",
- "Import/export data": "Importar / exportar dados",
+ "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",
+ "preferences_continue_label": "Reproduzir sempre o seguinte: ",
+ "Sign In": "Entrar",
"Log in/register": "Iniciar sessão/registar",
"Delete account?": "Eliminar conta?",
"Import and Export Data": "Importar e exportar dados",
@@ -86,7 +93,7 @@
"Danish": "Dinamarquês",
"Czech": "Checo",
"Croatian": "Croata",
- "Corsican": "Corso",
+ "Corsican": "Córsego",
"Cebuano": "Cebuano",
"Catalan": "Catalão",
"Burmese": "Birmanês",
@@ -100,10 +107,10 @@
"Arabic": "Árabe",
"Amharic": "Amárico",
"Albanian": "Albanês",
- "Afrikaans": "Africano",
+ "Afrikaans": "Africânder",
"English (auto-generated)": "Inglês (auto-gerado)",
"English": "Inglês",
- "Token is expired, please try again": "Token expirou, tente novamente",
+ "Token is expired, please try again": "Token caducado, tente novamente",
"No such user": "Utilizador inválido",
"Erroneous token": "Token inválido",
"Erroneous challenge": "Desafio inválido",
@@ -117,29 +124,29 @@
"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'",
+ "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",
- "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
- "Password is a required field": "Palavra-chave é um campo obrigatório",
+ "Password cannot be longer than 55 characters": "A palavra-passe não pode ter mais do que 55 caracteres",
+ "Password cannot be empty": "A palavra-passe não pode estar vazia",
+ "Wrong username or password": "Nome de utilizador ou palavra-passe incorreta",
+ "Password is a required field": "Palavra-passe é 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",
- "Incorrect password": "Palavra-chave incorreta",
+ "Incorrect password": "Palavra-passe 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"
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário"
},
"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'",
+ "Premieres `x`": "Estreia `x`",
+ "Premieres in `x`": "Estreia a `x`",
"Shared `x`": "Partilhado `x`",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Whitelisted regions: ": "Regiões permitidas: ",
@@ -151,43 +158,44 @@
"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'",
+ "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",
+ "Updated `x` ago": "Atualizado há `x`",
"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.",
+ "View privacy policy.": "Ver política de privacidade.",
+ "View JavaScript license information.": "Ver informações da licença 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",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokens",
+ "tokens_count_2": "{{count}} tokens",
"Token": "Token",
- "Token manager": "Gerir tokens",
- "Subscription manager": "Gerir subscrições",
+ "Token manager": "Gestor de tokens",
+ "Subscription manager": "Gestor de 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_default_home_label": "Página inicial padrão: ",
"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",
+ "Manage subscriptions": "Gerir subscrições",
+ "Change password": "Alterar palavra-passe",
"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",
+ "`x` uploaded a video": "`x` publicou um vídeo",
+ "Enable web notifications": "Ativar notificações 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: ",
@@ -199,9 +207,9 @@
"published - reverse": "publicado - inverso",
"published": "publicado",
"preferences_sort_label": "Ordenar vídeos por: ",
- "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ",
+ "preferences_max_results_label": "Número 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_annotations_subscribed_label": "Mostrar sempre anotações nos canais subscritos: ",
"preferences_category_subscription": "Preferências de subscrições",
"preferences_thin_mode_label": "Modo compacto: ",
"light": "claro",
@@ -212,11 +220,11 @@
"preferences_category_visual": "Preferências visuais",
"preferences_related_videos_label": "Mostrar vídeos relacionados: ",
"Fallback captions: ": "Legendas alternativas: ",
- "preferences_captions_label": "Legendas predefinidas: ",
+ "preferences_captions_label": "Legendas padrão: ",
"reddit": "Reddit",
"youtube": "YouTube",
- "preferences_comments_label": "Preferência dos comentários: ",
- "preferences_volume_label": "Volume da reprodução: ",
+ "preferences_comments_label": "Comentários padrão: ",
+ "preferences_volume_label": "Volume de reprodução: ",
"preferences_quality_label": "Qualidade de vídeo preferida: ",
"preferences_speed_label": "Velocidade preferida: ",
"preferences_local_label": "Usar proxy nos vídeos: ",
@@ -231,11 +239,11 @@
"Image CAPTCHA": "Imagem CAPTCHA",
"Text CAPTCHA": "Texto CAPTCHA",
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
- "Password": "Palavra-chave",
+ "Password": "Palavra-passe",
"User ID": "Utilizador",
"Log in": "Iniciar sessão",
- "source": "código-fonte",
- "JavaScript license information": "Informação de licença do JavaScript",
+ "source": "fonte",
+ "JavaScript license information": "Informação da licença JavaScript",
"An alternative front-end to YouTube": "Uma interface alternativa ao YouTube",
"History": "Histórico",
"Export data as JSON": "Exportar dados Invidious como JSON",
@@ -245,18 +253,18 @@
"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/OPML",
+ "Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML",
"Import Invidious data": "Importar dados JSON 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",
+ "Authorize token for `x`?": "Autorizar 'token' para `x`?",
+ "Authorize token?": "Autorizar 'token'?",
+ "New passwords must match": "As novas palavras-passe devem ser iguais",
+ "New password": "Nova palavra-passe",
"Clear watch history?": "Limpar histórico de reprodução?",
"Previous page": "Página anterior",
- "Next page": "Próxima página",
+ "Next page": "Página seguinte",
"last": "últimos",
"Current version: ": "Versão atual: ",
"channel_tab_community_label": "Comunidade",
@@ -264,19 +272,19 @@
"channel_tab_videos_label": "Vídeos",
"Video mode": "Modo de vídeo",
"Audio mode": "Modo de áudio",
- "`x` marked it with a ❤": "`x` foi marcado como ❤",
+ "`x` marked it with a ❤": "`x` foi marcado com um ❤",
"(edited)": "(editado)",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"Movies": "Filmes",
"News": "Notícias",
"Gaming": "Jogos",
- "Music": "Música",
+ "Music": "Músicas",
"View as playlist": "Ver como lista de reprodução",
"preferences_locale_label": "Idioma: ",
"Rating: ": "Avaliação: ",
- "About": "Sobre",
+ "About": "Acerca",
"Popular": "Popular",
- "Fallback comments: ": "Comentários alternativos: ",
+ "Fallback comments: ": "Alternativa para comentários: ",
"Zulu": "Zulu",
"Yoruba": "Ioruba",
"Yiddish": "Iídiche",
@@ -321,7 +329,7 @@
"Marathi": "Marathi",
"Maori": "Maori",
"Maltese": "Maltês",
- "Malayalam": "Malaiala",
+ "Malayalam": "Malaialaio",
"Malay": "Malaio",
"Malagasy": "Malgaxe",
"Macedonian": "Macedónio",
@@ -357,15 +365,15 @@
"Galician": "Galego",
"French": "Francês",
"Finnish": "Finlandês",
- "popular": "popular",
- "oldest": "mais antigos",
- "newest": "mais recentes",
+ "popular": "populares",
+ "oldest": "antigos",
+ "newest": "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": "AO VIVO",
+ "LIVE": "Direto",
"search_filters_duration_option_short": "Curto (< 4 minutos)",
"search_filters_duration_option_long": "Longo (> 20 minutos)",
"footer_source_code": "Código-fonte",
@@ -378,7 +386,7 @@
"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": "Automático",
+ "preferences_quality_dash_option_auto": "Automática",
"preferences_quality_dash_option_best": "Melhor",
"preferences_quality_dash_option_4320p": "4320p",
"preferences_quality_dash_option_2160p": "2160p",
@@ -389,7 +397,7 @@
"preferences_quality_dash_option_144p": "144p",
"search_filters_features_option_purchased": "Comprado",
"search_filters_features_option_three_sixty": "360°",
- "videoinfo_invidious_embed_link": "Incorporar hiperligação",
+ "videoinfo_invidious_embed_link": "Incorporar ligação",
"Video unavailable": "Vídeo não disponível",
"invidious": "Invidious",
"preferences_quality_option_medium": "Média",
@@ -400,39 +408,47 @@
"preferences_quality_dash_option_worst": "Pior",
"none": "nenhum",
"videoinfo_youTube_embed_link": "Incorporar",
- "preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ",
+ "preferences_save_player_pos_label": "Guardar posição de reprodução: ",
"download_subtitles": "Legendas - `x` (.vtt)",
- "generic_views_count": "{{count}} visualização",
- "generic_views_count_plural": "{{count}} visualizações",
+ "generic_views_count_0": "{{count}} visualização",
+ "generic_views_count_1": "{{count}} visualizações",
+ "generic_views_count_2": "{{count}} visualizações",
"videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`",
"user_saved_playlists": "`x` listas de reprodução guardadas",
- "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",
- "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",
- "generic_subscribers_count": "{{count}} inscrito",
- "generic_subscribers_count_plural": "{{count}} inscritos",
- "generic_subscriptions_count": "{{count}} inscrição",
- "generic_subscriptions_count_plural": "{{count}} inscrições",
- "comments_points_count": "{{count}} ponto",
- "comments_points_count_plural": "{{count}} pontos",
+ "generic_videos_count_0": "{{count}} vídeo",
+ "generic_videos_count_1": "{{count}} vídeos",
+ "generic_videos_count_2": "{{count}} vídeos",
+ "generic_playlists_count_0": "{{count}} lista de reprodução",
+ "generic_playlists_count_1": "{{count}} listas de reprodução",
+ "generic_playlists_count_2": "{{count}} listas de reprodução",
+ "subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista",
+ "subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas",
+ "subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas",
+ "comments_view_x_replies_0": "Ver {{count}} resposta",
+ "comments_view_x_replies_1": "Ver {{count}} respostas",
+ "comments_view_x_replies_2": "Ver {{count}} respostas",
+ "generic_subscribers_count_0": "{{count}} subscritor",
+ "generic_subscribers_count_1": "{{count}} subscritores",
+ "generic_subscribers_count_2": "{{count}} subscritores",
+ "generic_subscriptions_count_0": "{{count}} subscrição",
+ "generic_subscriptions_count_1": "{{count}} subscrições",
+ "generic_subscriptions_count_2": "{{count}} subscrições",
+ "comments_points_count_0": "{{count}} ponto",
+ "comments_points_count_1": "{{count}} pontos",
+ "comments_points_count_2": "{{count}} pontos",
"crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!",
"crash_page_before_reporting": "Antes de reportar um erro, verifique se:",
"crash_page_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_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
+ "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>",
"crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>",
- "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):",
+ "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO o traduza):",
"user_created_playlists": "`x` listas de reprodução criadas",
"search_filters_title": "Filtro",
"Chinese (Taiwan)": "Chinês (Taiwan)",
"search_message_no_results": "Nenhum resultado encontrado.",
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
- "search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
+ "search_message_use_another_instance": "Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
"English (United Kingdom)": "Inglês (Reino Unido)",
"English (United States)": "Inglês (Estados Unidos)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
@@ -464,11 +480,11 @@
"search_filters_type_option_all": "Qualquer tipo",
"search_filters_duration_option_none": "Qualquer duração",
"Popular enabled: ": "Página \"popular\" ativada: ",
- "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>",
+ "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para voltar à página inicial da lista de reprodução.</a>",
"channel_tab_playlists_label": "Listas de reprodução",
"channel_tab_channels_label": "Canais",
"channel_tab_shorts_label": "Curtos",
- "channel_tab_streams_label": "Diretos",
+ "channel_tab_streams_label": "Emissões em direto",
"Music in this video": "Música neste vídeo",
"Artist: ": "Artista: ",
"Album: ": "Álbum: ",
@@ -477,12 +493,25 @@
"Standard YouTube license": "Licença padrão do YouTube",
"Download is disabled": "A descarga está desativada",
"Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)",
- "generic_button_delete": "Deletar",
+ "generic_button_delete": "Eliminar",
"generic_button_edit": "Editar",
"generic_button_rss": "RSS",
"channel_tab_podcasts_label": "Podcasts",
"channel_tab_releases_label": "Lançamentos",
- "generic_button_save": "Salvar",
+ "generic_button_save": "Guardar",
"generic_button_cancel": "Cancelar",
- "playlist_button_add_items": "Adicionar vídeos"
+ "playlist_button_add_items": "Adicionar vídeos",
+ "generic_channels_count_0": "{{count}} canal",
+ "generic_channels_count_1": "{{count}} canais",
+ "generic_channels_count_2": "{{count}} canais",
+ "Import YouTube watch history (.json)": "Importar histórico de reprodução do YouTube (.json)",
+ "toggle_theme": "Trocar tema",
+ "Add to playlist": "Adicionar à lista de reprodução",
+ "Add to playlist: ": "Adicionar à lista de reprodução: ",
+ "Answer": "Responder",
+ "Search for videos": "Procurar vídeos",
+ "carousel_slide": "Diapositivo {{current}} de{{total}}",
+ "carousel_skip": "Ignorar carrossel",
+ "carousel_go_to": "Ir para o diapositivo`x`",
+ "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
}
diff --git a/locales/ro.json b/locales/ro.json
index 85bf746f..ccbeef63 100644
--- a/locales/ro.json
+++ b/locales/ro.json
@@ -478,5 +478,6 @@
"search_filters_type_option_all": "orice tip",
"preferences_quality_dash_option_240p": "240p",
"preferences_quality_dash_option_144p": "144p",
- "Show less": "Afișați mai puțin"
+ "Show less": "Afișați mai puțin",
+ "Add to playlist": "Adaugă la playlist"
}
diff --git a/locales/ru.json b/locales/ru.json
index 82bf4299..31ef1a33 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -8,7 +8,7 @@
"newest": "сначала новые",
"oldest": "сначала старые",
"popular": "популярные",
- "last": "недавние",
+ "last": "последние",
"Next page": "Следующая страница",
"Previous page": "Предыдущая страница",
"First page": "Первая страница",
@@ -16,13 +16,13 @@
"New password": "Новый пароль",
"New passwords must match": "Новые пароли не совпадают",
"Authorize token?": "Авторизовать токен?",
- "Authorize token for `x`?": "Авторизовать токен для `x`?",
+ "Authorize token for `x`?": "Токен авторизации для `x`?",
"Yes": "Да",
"No": "Нет",
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать JSON с данными Invidious",
- "Import YouTube subscriptions": "Импортировать подписки из YouTube/OPML",
+ "Import YouTube subscriptions": "Импортировать подписки из CSV или OPML",
"Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)",
"Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)",
@@ -30,7 +30,7 @@
"Export subscriptions as OPML": "Экспортировать подписки в формате OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)",
"Export data as JSON": "Экспортировать данные Invidious в формате JSON",
- "Delete account?": "Удалить учётку?",
+ "Delete account?": "Удалить учётную запись?",
"History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
"JavaScript license information": "Информация о лицензиях JavaScript",
@@ -43,7 +43,7 @@
"Text CAPTCHA": "Текстовая капча (англ.)",
"Image CAPTCHA": "Капча-картинка",
"Sign In": "Войти",
- "Register": "Зарегистрироваться",
+ "Register": "Регистрация",
"E-mail": "Эл. почта",
"Preferences": "Настройки",
"preferences_category_player": "Настройки проигрывателя",
@@ -62,7 +62,7 @@
"preferences_captions_label": "Основной язык субтитров: ",
"Fallback captions: ": "Дополнительный язык субтитров: ",
"preferences_related_videos_label": "Показывать похожие видео? ",
- "preferences_annotations_label": "Всегда показывать аннотации? ",
+ "preferences_annotations_label": "Показывать аннотации по умолчанию: ",
"preferences_extend_desc_label": "Автоматически раскрывать описание видео: ",
"preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ",
"preferences_category_visual": "Настройки сайта",
@@ -78,13 +78,13 @@
"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": "по названию канала в обратном порядке",
+ "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": "Показывать только непросмотренные видео: ",
@@ -135,8 +135,8 @@
"Title": "Заголовок",
"Playlist privacy": "Видимость плейлиста",
"Editing playlist `x`": "Редактирование плейлиста `x`",
- "Show more": "Развернуть",
- "Show less": "Свернуть",
+ "Show more": "Показать больше",
+ "Show less": "Показать меньше",
"Watch on YouTube": "Смотреть на YouTube",
"Switch Invidious Instance": "Сменить зеркало Invidious",
"Hide annotations": "Скрыть аннотации",
@@ -415,7 +415,7 @@
"generic_count_days_0": "{{count}} день",
"generic_count_days_1": "{{count}} дня",
"generic_count_days_2": "{{count}} дней",
- "preferences_quality_dash_option_auto": "Автоматическое",
+ "preferences_quality_dash_option_auto": "Авто",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"generic_subscriptions_count_0": "{{count}} подписка",
@@ -467,7 +467,7 @@
"search_filters_features_option_three_sixty": "360°",
"Video unavailable": "Видео недоступно",
"preferences_save_player_pos_label": "Запоминать позицию: ",
- "preferences_region_label": "Страна: ",
+ "preferences_region_label": "Страна источник ",
"preferences_watch_history_label": "Включить историю просмотров: ",
"search_filters_title": "Фильтр",
"search_filters_duration_option_none": "Любой длины",
@@ -477,7 +477,7 @@
"search_message_no_results": "Ничего не найдено.",
"search_message_use_another_instance": " Дополнительно вы можете <a href=\"`x`\">поискать на других зеркалах</a>.",
"search_filters_features_option_vr180": "VR180",
- "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.",
+ "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос и/или изменить фильтры.",
"search_filters_duration_option_medium": "Средние (4 - 20 минут)",
"search_filters_apply_button": "Применить фильтры",
"Popular enabled: ": "Популярное включено: ",
@@ -501,5 +501,18 @@
"generic_button_cancel": "Отменить",
"generic_button_rss": "RSS",
"playlist_button_add_items": "Добавить видео",
- "channel_tab_podcasts_label": "Подкасты"
+ "channel_tab_podcasts_label": "Подкасты",
+ "generic_channels_count_0": "{{count}} канал",
+ "generic_channels_count_1": "{{count}} канала",
+ "generic_channels_count_2": "{{count}} каналов",
+ "Import YouTube watch history (.json)": "Импортировать историю просмотра из YouTube (.json)",
+ "Add to playlist": "Добавить в плейлист",
+ "Add to playlist: ": "Добавить в плейлист: ",
+ "Answer": "Ответить",
+ "Search for videos": "Поиск видео",
+ "The Popular feed has been disabled by the administrator.": "Лента популярного была отключена администратором.",
+ "toggle_theme": "Переключатель тем",
+ "carousel_slide": "Пролистано {{current}} из {{total}}",
+ "carousel_skip": "Пропустить всё",
+ "carousel_go_to": "Перейти к странице `x`"
}
diff --git a/locales/sl.json b/locales/sl.json
index fec1cb62..3803d09c 100644
--- a/locales/sl.json
+++ b/locales/sl.json
@@ -516,5 +516,10 @@
"generic_button_rss": "RSS",
"playlist_button_add_items": "Dodaj videoposnetke",
"channel_tab_podcasts_label": "Poddaje",
- "channel_tab_releases_label": "Izdaje"
+ "channel_tab_releases_label": "Izdaje",
+ "generic_channels_count_0": "{{count}} kanal",
+ "generic_channels_count_1": "{{count}} kanala",
+ "generic_channels_count_2": "{{count}} kanali",
+ "generic_channels_count_3": "{{count}} kanalov",
+ "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
}
diff --git a/locales/sq.json b/locales/sq.json
index d28eb784..ea20ce56 100644
--- a/locales/sq.json
+++ b/locales/sq.json
@@ -79,7 +79,7 @@
"invidious": "Invidious",
"preferences_captions_label": "Titra parazgjedhje: ",
"preferences_extend_desc_label": "Zgjero automatikisht përshkrimin e videos: ",
- "preferences_player_style_label": "Silt lojtësi: ",
+ "preferences_player_style_label": "Stil lojtësi: ",
"Dark mode: ": "Mënyra e errët: ",
"preferences_dark_mode_label": "Temë: ",
"dark": "e errët",
@@ -263,7 +263,7 @@
"search_filters_duration_label": "Kohëzgjatje",
"search_filters_features_label": "Veçori",
"search_filters_sort_label": "Renditi Sipas",
- "search_filters_date_option_hour": "Orën e Fundit",
+ "search_filters_date_option_hour": "Orën e fundit",
"search_filters_date_option_today": "Sot",
"search_filters_duration_option_long": "E gjatë (> 20 minuta)",
"search_filters_features_option_hd": "HD",
@@ -345,7 +345,7 @@
"View YouTube comments": "Shihni komente Youtube",
"View more comments on Reddit": "Shihni më tepër komente në Reddit",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` komente",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` koment",
"": "Shihni `x` komente"
},
"View Reddit comments": "Shihni komente Reddit",
@@ -435,14 +435,14 @@
"tokens_count_plural": "{{count}} tokenë",
"preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ",
"Import Invidious data": "Importoni të dhëna JSON Invidious",
- "Import YouTube subscriptions": "Importoni pajtime YouTube/OPML",
+ "Import YouTube subscriptions": "Importoni pajtime YouTube CSV ose OPML",
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
"Shared `x`": "Ndarë me të tjerë më `x`",
"search_filters_title": "Filtra",
"Popular enabled: ": "Me populloret të aktivizuara: ",
"error_video_not_in_playlist": "Videoja e kërkuar s’ekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
- "search_message_use_another_instance": " Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
+ "search_message_use_another_instance": "Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
"search_filters_date_label": "Datë ngarkimi",
"preferences_watch_history_label": "Aktivizo historik parjesh: ",
"Top enabled: ": "Me kryesueset të aktivizuara: ",
@@ -462,5 +462,35 @@
"channel_tab_channels_label": "Kanale",
"Music in this video": "Muzikë në këtë video",
"channel_tab_shorts_label": "Të shkurtra",
- "channel_tab_streams_label": "Transmetime të drejtpërdrejta"
+ "channel_tab_streams_label": "Transmetime të drejtpërdrejta",
+ "generic_button_cancel": "Anuloje",
+ "generic_channels_count": "{{count}} kanal",
+ "generic_channels_count_plural": "{{count}} kanale",
+ "generic_button_rss": "RSS",
+ "generic_button_delete": "Fshije",
+ "generic_button_save": "Ruaje",
+ "generic_button_edit": "Përpunoni",
+ "playlist_button_add_items": "Shtoni video",
+ "Report statistics: ": "Statistika raportimesh: ",
+ "Download is disabled": "Shkarkimi është i çaktivizuar",
+ "Channel Sponsor": "Sponsor Kanali",
+ "channel_tab_releases_label": "Hedhje në qarkullim",
+ "Song: ": "Pjesë: ",
+ "Import YouTube playlist (.csv)": "Importoni luajlistë YouTube (.csv)",
+ "Standard YouTube license": "Licencë YouTube standarde",
+ "published - reverse": "publikuar më - së prapthi",
+ "channel_tab_podcasts_label": "Podcast-e",
+ "channel name - reverse": "emër kanali - së prapthi",
+ "Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)",
+ "preferences_local_label": "Video përmes ndërmjetësi: ",
+ "Fallback captions: ": "Titra nga halli: ",
+ "Erroneous challenge": "Zgjidhje e gabuar",
+ "Add to playlist: ": "Shtoje te luajlistë: ",
+ "Add to playlist": "Shtoje te luajlistë",
+ "Answer": "Përgjigje",
+ "Search for videos": "Kërko për video",
+ "The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
+ "carousel_skip": "Anashkaloje Rrotullamen",
+ "carousel_slide": "Diapozitiv {{current}} nga {{total}}",
+ "carousel_go_to": "Kalo te diapozitivi `x`"
}
diff --git a/locales/sr.json b/locales/sr.json
index a2853b68..d28b2459 100644
--- a/locales/sr.json
+++ b/locales/sr.json
@@ -1,90 +1,90 @@
{
"LIVE": "UŽIVO",
- "Shared `x` ago": "Podeljeno pre `x`",
+ "Shared `x` ago": "Deljeno pre `x`",
"Unsubscribe": "Prekini praćenje",
- "Subscribe": "Prati",
+ "Subscribe": "Zaprati",
"View channel on YouTube": "Pogledaj kanal na YouTube-u",
- "View playlist on YouTube": "Pogledaj spisak izvođenja na YouTube-u",
+ "View playlist on YouTube": "Pogledaj plejlistu 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?",
+ "Clear watch history?": "Očistiti istoriju gledanja?",
"New password": "Nova lozinka",
- "New passwords must match": "Nove lozinke moraju biti istovetne",
- "Authorize token?": "Ovlasti žeton?",
- "Authorize token for `x`?": "Ovlasti žeton za `x`?",
+ "New passwords must match": "Nove lozinke moraju da se podudaraju",
+ "Authorize token?": "Autorizovati token?",
+ "Authorize token for `x`?": "Autorizovati token za `x`?",
"Yes": "Da",
"No": "Ne",
- "Import and Export Data": "Uvoz i Izvoz Podataka",
+ "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)",
+ "Import Invidious data": "Uvezi Invidious JSON podatke",
+ "Import YouTube subscriptions": "Uvezi YouTube CSV ili OPML praćenja",
+ "Import FreeTube subscriptions (.db)": "Uvezi FreeTube praćenja (.db)",
+ "Import NewPipe subscriptions (.json)": "Uvezi NewPipe praćenja (.json)",
+ "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.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?",
+ "Export subscriptions as OPML": "Izvezi praćenja kao OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi praćenja kao OPML (za NewPipe i FreeTube)",
+ "Export data as JSON": "Izvezi Invidious podatke kao JSON",
+ "Delete account?": "Izbrisati nalog?",
"History": "Istorija",
- "An alternative front-end to YouTube": "Zamenski korisnički sloj za YouTube",
- "JavaScript license information": "Izveštaj o JavaScript odobrenju",
+ "An alternative front-end to YouTube": "Alternativni front-end za YouTube",
+ "JavaScript license information": "Informacije o JavaScript licenci",
"source": "izvor",
- "Log in": "Prijavi se",
- "Log in/register": "Prijavi se/Otvori nalog",
- "User ID": "Korisnički ID",
+ "Log in": "Prijava",
+ "Log in/register": "Prijava/registracija",
+ "User ID": "ID korisnika",
"Password": "Lozinka",
"Time (h:mm:ss):": "Vreme (č:mm:ss):",
- "Text CAPTCHA": "Znakovni CAPTCHA",
- "Image CAPTCHA": "Slikovni CAPTCHA",
+ "Text CAPTCHA": "Tekst CAPTCHA",
+ "Image CAPTCHA": "Slika CAPTCHA",
"Sign In": "Prijava",
- "Register": "Otvori nalog",
- "E-mail": "E-pošta",
+ "Register": "Registracija",
+ "E-mail": "Imejl",
"Preferences": "Podešavanja",
- "preferences_category_player": "Podešavanja reproduktora",
+ "preferences_category_player": "Podešavanja plejera",
"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`",
- "Playlist does not exist.": "Nepostojeća plej lista.",
+ "preferences_autoplay_label": "Automatski pusti: ",
+ "preferences_continue_label": "Podrazumevano pusti sledeće: ",
+ "preferences_continue_autoplay_label": "Automatski pusti sledeći video snimak: ",
+ "preferences_listen_label": "Podrazumevano uključi samo zvuk: ",
+ "preferences_local_label": "Proksi video snimci: ",
+ "Playlist privacy": "Privatnost plejliste",
+ "Editing playlist `x`": "Izmenjivanje plejliste `x`",
+ "Playlist does not exist.": "Plejlista ne postoji.",
"Erroneous challenge": "Pogrešan izazov",
"Maltese": "Malteški",
"Download": "Preuzmi",
- "Download as: ": "Preuzmi kao: ",
- "Bangla": "Bangla/Bengalski",
- "preferences_quality_dash_label": "Preferirani kvalitet DASH video formata: ",
- "Token manager": "Upravljanje žetonima",
- "Token": "Žeton",
- "Import/export": "Uvezi/Izvezi",
+ "Download as: ": "Preuzeti kao: ",
+ "Bangla": "Bengalski",
+ "preferences_quality_dash_label": "Preferirani DASH kvalitet video snimka: ",
+ "Token manager": "Upravljanje tokenima",
+ "Token": "Token",
+ "Import/export": "Uvoz/izvoz",
"revoke": "opozovi",
"search": "pretraga",
"Log out": "Odjava",
- "Source available here.": "Izvorna koda je ovde dostupna.",
+ "Source available here.": "Izvorni kôd je dostupan ovde.",
"Trending": "U trendu",
"Updated `x` ago": "Ažurirano pre `x`",
- "Delete playlist `x`?": "Obriši plej listu `x`?",
- "Create playlist": "Napravi plej listu",
+ "Delete playlist `x`?": "Izbrisati plejlistu `x`?",
+ "Create playlist": "Napravi plejlistu",
"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",
+ "User ID is a required field": "ID korisnika je obavezno polje",
"Wrong username or password": "Pogrešno korisničko ime ili lozinka",
- "Please log in": "Molimo vas da se prijavite",
+ "Please log in": "Molimo, prijavite se",
"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",
+ "Could not fetch comments": "Nije moguće prikupiti komentare",
+ "Could not create mix.": "Nije moguće napraviti miks.",
+ "Empty playlist": "Prazna plejlista",
+ "Not a playlist.": "Nije plejlista.",
+ "Could not pull trending pages.": "Nije moguće povući stranice „U trendu“.",
+ "Token is expired, please try again": "Token je istekao, pokušajte ponovo",
"English (auto-generated)": "Engleski (automatski generisano)",
"Afrikaans": "Afrikans",
"Albanian": "Albanski",
@@ -95,19 +95,19 @@
"Bulgarian": "Bugarski",
"Burmese": "Burmanski",
"Catalan": "Katalonski",
- "Cebuano": "Sebuano",
+ "Cebuano": "Cebuanski",
"Chinese (Traditional)": "Kineski (Tradicionalni)",
"Corsican": "Korzikanski",
"Danish": "Danski",
- "Kannada": "Kanada (Jezik)",
+ "Kannada": "Kanada",
"Kazakh": "Kazaški",
"Russian": "Ruski",
"Scottish Gaelic": "Škotski Gelski",
- "Sinhala": "Sinhaleški",
+ "Sinhala": "Sinhalski",
"Slovak": "Slovački",
"Spanish": "Španski",
- "Spanish (Latin America)": "Španski (Južna Amerika)",
- "Sundanese": "Sundski",
+ "Spanish (Latin America)": "Španski (Latinska Amerika)",
+ "Sundanese": "Sundanski",
"Swedish": "Švedski",
"Tajik": "Tadžički",
"Telugu": "Telugu",
@@ -116,77 +116,77 @@
"Urdu": "Urdu",
"Uzbek": "Uzbečki",
"Vietnamese": "Vijetnamski",
- "Rating: ": "Ocena/e: ",
- "View as playlist": "Pogledaj kao plej listu",
- "Default": "Podrazumevan/o",
- "Gaming": "Igrice",
+ "Rating: ": "Ocena: ",
+ "View as playlist": "Pogledaj kao plejlistu",
+ "Default": "Podrazumevano",
+ "Gaming": "Video igre",
"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",
+ "YouTube comment permalink": "Trajni link YouTube komentara",
+ "Audio mode": "Režim audio snimka",
+ "Playlists": "Plejliste",
"search_filters_sort_option_relevance": "Relevantnost",
- "search_filters_sort_option_rating": "Ocene",
+ "search_filters_sort_option_rating": "Ocena",
"search_filters_sort_option_date": "Datum otpremanja",
"search_filters_sort_option_views": "Broj pregleda",
- "`x` marked it with a ❤": "`x` je označio/la ovo sa ❤",
+ "`x` marked it with a ❤": "`x` je označio/la sa ❤",
"search_filters_duration_label": "Trajanje",
"search_filters_features_label": "Karakteristike",
"search_filters_date_option_hour": "Poslednji sat",
- "search_filters_date_option_week": "Ove sedmice",
- "search_filters_date_option_month": "Ovaj mesec",
+ "search_filters_date_option_week": "Ove nedelje",
+ "search_filters_date_option_month": "Ovog meseca",
"search_filters_date_option_year": "Ove godine",
- "search_filters_type_option_video": "Video",
- "search_filters_type_option_playlist": "Plej lista",
+ "search_filters_type_option_video": "Video snimak",
+ "search_filters_type_option_playlist": "Plejlista",
"search_filters_type_option_movie": "Film",
"search_filters_duration_option_long": "Dugo (> 20 minuta)",
"search_filters_features_option_hd": "HD",
- "search_filters_features_option_c_commons": "Creative Commons (Licenca)",
+ "search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_three_d": "3D",
- "search_filters_features_option_hdr": "Video Visoke Rezolucije",
- "next_steps_error_message": "Nakon čega bi trebali probati: ",
- "next_steps_error_message_go_to_youtube": "Idi na YouTube",
+ "search_filters_features_option_hdr": "HDR",
+ "next_steps_error_message": "Nakon toga treba da pokušate da: ",
+ "next_steps_error_message_go_to_youtube": "Odete na YouTube",
"footer_documentation": "Dokumentacija",
- "preferences_region_label": "Država porekla sadržaja: ",
+ "preferences_region_label": "Država sadržaja: ",
"preferences_player_style_label": "Stil plejera: ",
- "preferences_dark_mode_label": "Izgled/Tema: ",
- "light": "svetlo",
+ "preferences_dark_mode_label": "Tema: ",
+ "light": "svetla",
"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",
+ "preferences_automatic_instance_redirect_label": "Automatsko preusmeravanje instance (povratak na redirect.invidious.io): ",
+ "alphabetically - reverse": "abecedno - obrnuto",
+ "Enable web notifications": "Omogući veb obaveštenja",
+ "`x` is live": "`x` je uživo",
+ "Manage tokens": "Upravljaj tokenima",
"Watch history": "Istorija gledanja",
- "preferences_feed_menu_label": "Dovodna stranica: ",
+ "preferences_feed_menu_label": "Fid meni: ",
"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: ",
+ "Wilson score: ": "Vilsonova 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.",
+ "Whitelisted regions: ": "Dostupni regioni: ",
+ "Shared `x`": "Deljeno `x`",
+ "Premieres in `x`": "Premijera u `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.": "Hej! Izgleda da ste isključili JavaScript. Kliknite ovde da biste videli komentare, imajte na umu da će možda potrajati malo duže da se učitaju.",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentar",
- "": "Prikaži `x` komentara"
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Pogledaj `x` komentar",
+ "": "Pogledaj`x` komentara"
},
- "View Reddit comments": "Prikaži Reddit komentare",
+ "View Reddit comments": "Pogledaj Reddit komentare",
"CAPTCHA is a required field": "CAPTCHA je obavezno polje",
"Croatian": "Hrvatski",
"Estonian": "Estonski",
- "Filipino": "Filipino",
+ "Filipino": "Filipinski",
"French": "Francuski",
"Galician": "Galicijski",
"German": "Nemački",
"Greek": "Grčki",
"Hausa": "Hausa",
- "Italian": "Talijanski",
+ "Italian": "Italijanski",
"Khmer": "Kmerski",
"Kurdish": "Kurdski",
"Kyrgyz": "Kirgiski",
@@ -195,68 +195,68 @@
"Macedonian": "Makedonski",
"Malagasy": "Malgaški",
"Malay": "Malajski",
- "Marathi": "Marathi",
+ "Marathi": "Maratski",
"Mongolian": "Mongolski",
"Norwegian Bokmål": "Norveški Bokmal",
- "Nyanja": "Čeva",
+ "Nyanja": "Nijandža",
"Pashto": "Paštunski",
"Persian": "Persijski",
- "Punjabi": "Pundžabi",
+ "Punjabi": "Pandžapski",
"Romanian": "Rumunski",
"Welsh": "Velški",
"Western Frisian": "Zapadnofrizijski",
- "Fallback comments: ": "Komentari u slučaju otkazivanja: ",
+ "Fallback comments: ": "Rezervni komentari: ",
"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: ",
+ "About": "O sajtu",
+ "footer_source_code": "Izvorni kôd",
+ "footer_original_source_code": "Originalni izvorni kôd",
+ "preferences_related_videos_label": "Prikaži srodne video snimke: ",
+ "preferences_annotations_label": "Podrazumevano prikaži napomene: ",
+ "preferences_extend_desc_label": "Automatski proširi opis video snimka: ",
+ "preferences_vr_mode_label": "Interaktivni video snimci od 360 stepeni (zahteva WebGl): ",
+ "preferences_category_visual": "Vizuelna podešavanja",
+ "preferences_captions_label": "Podrazumevani titlovi: ",
"Music": "Muzika",
- "search_filters_type_label": "Tip",
+ "search_filters_type_label": "Vrsta",
"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)",
+ "Only show latest unwatched video from channel: ": "Prikaži samo najnoviji neodgledani video snimak sa kanala: ",
+ "Xhosa": "Kosa (Khosa)",
"search_filters_type_option_channel": "Kanal",
"Hungarian": "Mađarski",
- "Maori": "Maori (Jezik)",
- "Manage subscriptions": "Upravljaj zapisima",
+ "Maori": "Maorski",
+ "Manage subscriptions": "Upravljaj praćenjima",
"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",
+ "`x` uploaded a video": "`x` je otpremio/la video snimak",
+ "Delete account": "Izbriši nalog",
"preferences_default_home_label": "Podrazumevana početna stranica: ",
"Serbian": "Srpski",
"License: ": "Licenca: ",
"search_filters_features_option_live": "Uživo",
- "Report statistics: ": "Izveštavaj o statistici: ",
- "Only show latest video from channel: ": "Prikazuj poslednje video klipove samo sa kanala: ",
+ "Report statistics: ": "Izveštavaj statistike: ",
+ "Only show latest video from channel: ": "Prikaži samo najnoviji video snimak 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.",
+ "Could not get channel info.": "Nije moguće prikupiti informacije o kanalu.",
+ "View privacy policy.": "Pogledaj politiku privatnosti.",
"Change password": "Promeni lozinku",
- "Malayalam": "Malajalam",
- "View more comments on Reddit": "Prikaži više komentara na Reddit-u",
+ "Malayalam": "Malajalamski",
+ "View more comments on Reddit": "Pogledaj više komentara na Reddit-u",
"Portuguese": "Portugalski",
- "View YouTube comments": "Prikaži YouTube komentare",
+ "View YouTube comments": "Pogledaj YouTube komentare",
"published - reverse": "objavljeno - obrnuto",
"Dutch": "Holandski",
- "preferences_volume_label": "Jačina zvuka: ",
+ "preferences_volume_label": "Jačina zvuka plejera: ",
"preferences_locale_label": "Jezik: ",
- "adminprefs_modified_source_code_url_label": "URL veza do skladišta sa Izmenjenom Izvornom Kodom",
+ "adminprefs_modified_source_code_url_label": "URL adresa do repozitorijuma izmenjenog izvornog koda",
"channel_tab_community_label": "Zajednica",
- "Video mode": "Video mod",
- "Fallback captions: ": "Titl u slučaju da glavni nije dostupan: ",
+ "Video mode": "Režim video snimka",
+ "Fallback captions: ": "Rezervni titlovi: ",
"Private": "Privatno",
- "alphabetically": "po alfabetu",
- "No such user": "Nepostojeći korisnik",
+ "alphabetically": "abecedno",
+ "No such user": "Ne postoji korisnik",
"Subscriptions": "Praćenja",
"search_filters_date_option_today": "Danas",
"Finnish": "Finski",
@@ -265,30 +265,30 @@
"Shona": "Šona",
"search_filters_features_option_location": "Lokacija",
"Load more": "Učitaj više",
- "Released under the AGPLv3 on Github.": "Izbačeno pod licencom AGPLv3 na GitHub-u.",
+ "Released under the AGPLv3 on Github.": "Objavljeno pod licencom AGPLv3 na GitHub-u.",
"Slovenian": "Slovenački",
- "View JavaScript license information.": "Pogledaj informacije licence vezane za JavaScript.",
+ "View JavaScript license information.": "Pogledaj informacije o JavaScript licenci.",
"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`",
+ "Invidious Private Feed for `x`": "Invidious privatni fid za `x`",
"Watch on YouTube": "Gledaj na YouTube-u",
"Wrong answer": "Pogrešan odgovor",
- "preferences_quality_label": "Preferirani video kvalitet: ",
+ "preferences_quality_label": "Preferirani kvalitet video snimka: ",
"Hide replies": "Sakrij odgovore",
"Erroneous CAPTCHA": "Pogrešna CAPTCHA",
- "Erroneous token": "Pogrešan žeton",
+ "Erroneous token": "Pogrešan token",
"Czech": "Češki",
"Latin": "Latinski",
- "channel_tab_videos_label": "Video klipovi",
+ "channel_tab_videos_label": "Video snimci",
"search_filters_features_option_four_k": "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",
+ "Unlisted": "Po pozivu",
+ "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",
"Georgian": "Gruzijski",
"Hawaiian": "Havajski",
"Hebrew": "Hebrejski",
@@ -297,68 +297,221 @@
"Japanese": "Japanski",
"Javanese": "Javanski",
"Sindhi": "Sindi",
- "Swahili": "Svahili",
+ "Swahili": "Suvali",
"Yiddish": "Jidiš",
"Zulu": "Zulu",
- "search_filters_features_option_subtitles": "Titl/Prevod",
- "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 karaktera",
+ "search_filters_features_option_subtitles": "Titlovi/Skriveni titlovi",
+ "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova",
"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",
+ "Top": "Top",
+ "footer_modfied_source_code": "Izmenjeni izvorni kôd",
"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_max_results_label": "Broj video snimaka prikazanih u fidu: ",
+ "preferences_sort_label": "Sortiraj video snimke po: ",
+ "preferences_unseen_only_label": "Prikaži samo neodgledano: ",
+ "preferences_notifications_only_label": "Prikaži samo obaveštenja (ako ih ima): ",
"preferences_category_data": "Podešavanja podataka",
- "Clear watch history": "Obriši istoriju gledanja",
- "preferences_category_admin": "Administratorska podešavanja",
+ "Clear watch history": "Očisti istoriju gledanja",
+ "preferences_category_admin": "Podešavanja administratora",
"published": "objavljeno",
- "search_filters_sort_label": "Poredaj prema",
+ "search_filters_sort_label": "Sortiranje po",
"search_filters_type_option_show": "Emisija",
- "search_filters_duration_option_short": "Kratko (< 4 minute)",
+ "search_filters_duration_option_short": "Kratko (< 4 minuta)",
"Current version: ": "Trenutna verzija: ",
- "Top enabled: ": "Vrh omogućen: ",
+ "Top enabled: ": "Top omogućeno: ",
"Public": "Javno",
- "Delete playlist": "Obriši plej listu",
+ "Delete playlist": "Izbriši plejlistu",
"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",
+ "Deleted or invalid channel": "Izbrisan ili nevažeći kanal",
"Esperanto": "Esperanto",
"Hmong": "Hmong",
"Luxembourgish": "Luksemburški",
"Nepali": "Nepalski",
"Samoan": "Samoanski",
"News": "Vesti",
- "permalink": "trajna veza",
+ "permalink": "trajni link",
"Password is a required field": "Lozinka je obavezno polje",
"Amharic": "Amharski",
- "Indonesian": "Indonežanski",
+ "Indonesian": "Indonezijski",
"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: ",
+ "dark": "tamna",
+ "Redirect homepage to feed: ": "Preusmeri početnu stranicu na fid: ",
"channel name": "ime kanala",
- "View all playlists": "Pregledaj sve plej liste",
+ "View all playlists": "Pogledaj sve plejliste",
"Show more": "Prikaži više",
"Genre: ": "Žanr: ",
"Family friendly? ": "Pogodno za porodicu? ",
- "next_steps_error_message_refresh": "Osveži stranicu",
+ "next_steps_error_message_refresh": "Osvežite",
"youtube": "YouTube",
"reddit": "Reddit",
- "unsubscribe": "prekini sa praćenjem",
- "Blacklisted regions: ": "Zabranjene oblasti: ",
+ "unsubscribe": "prekini praćenje",
+ "Blacklisted regions: ": "Nedostupni regioni: ",
"Polish": "Poljski",
"Yoruba": "Joruba",
- "search_filters_title": "Filter"
+ "search_filters_title": "Filteri",
+ "Korean (auto-generated)": "Korejski (automatski generisano)",
+ "search_filters_features_option_three_sixty": "360°",
+ "preferences_quality_dash_option_worst": "Najgore",
+ "channel_tab_podcasts_label": "Podkasti",
+ "preferences_save_player_pos_label": "Sačuvaj poziciju reprodukcije: ",
+ "Spanish (Mexico)": "Španski (Meksiko)",
+ "generic_subscriptions_count_0": "{{count}} praćenje",
+ "generic_subscriptions_count_1": "{{count}} praćenja",
+ "generic_subscriptions_count_2": "{{count}} praćenja",
+ "search_filters_apply_button": "Primeni izabrane filtere",
+ "Download is disabled": "Preuzimanje je onemogućeno",
+ "comments_points_count_0": "{{count}} poen",
+ "comments_points_count_1": "{{count}} poena",
+ "comments_points_count_2": "{{count}} poena",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "German (auto-generated)": "Nemački (automatski generisano)",
+ "Japanese (auto-generated)": "Japanski (automatski generisano)",
+ "preferences_quality_option_medium": "Srednje",
+ "search_message_change_filters_or_query": "Pokušajte da proširite upit za pretragu i/ili promenite filtere.",
+ "crash_page_before_reporting": "Pre nego što prijavite grešku, uverite se da ste:",
+ "preferences_quality_dash_option_best": "Najbolje",
+ "Channel Sponsor": "Sponzor kanala",
+ "generic_videos_count_0": "{{count}} video snimak",
+ "generic_videos_count_1": "{{count}} video snimka",
+ "generic_videos_count_2": "{{count}} video snimaka",
+ "videoinfo_started_streaming_x_ago": "Započeto strimovanje pre `x`",
+ "videoinfo_youTube_embed_link": "Ugrađeno",
+ "channel_tab_streams_label": "Strimovi uživo",
+ "playlist_button_add_items": "Dodaj video snimke",
+ "generic_count_minutes_0": "{{count}} minut",
+ "generic_count_minutes_1": "{{count}} minuta",
+ "generic_count_minutes_2": "{{count}} minuta",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_watch_history_label": "Omogući istoriju gledanja: ",
+ "user_saved_playlists": "Sačuvanih plejlista: `x`",
+ "Spanish (Spain)": "Španski (Španija)",
+ "invidious": "Invidious",
+ "crash_page_refresh": "pokušali da <a href=\"`x`\">osvežite stranicu</a>",
+ "Chinese (Hong Kong)": "Kineski (Hong Kong)",
+ "Artist: ": "Izvođač: ",
+ "generic_count_months_0": "{{count}} mesec",
+ "generic_count_months_1": "{{count}} meseca",
+ "generic_count_months_2": "{{count}} meseci",
+ "search_message_use_another_instance": "Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.",
+ "generic_subscribers_count_0": "{{count}} pratilac",
+ "generic_subscribers_count_1": "{{count}} pratioca",
+ "generic_subscribers_count_2": "{{count}} pratilaca",
+ "download_subtitles": "Titlovi - `x` (.vtt)",
+ "generic_button_save": "Sačuvaj",
+ "crash_page_search_issue": "pretražili <a href=\"`x`\">postojeće izveštaje o problemima na GitHub-u</a>",
+ "generic_button_cancel": "Otkaži",
+ "none": "nijedno",
+ "English (United States)": "Engleski (Sjedinjene Američke Države)",
+ "subscriptions_unseen_notifs_count_0": "{{count}} neviđeno obaveštenje",
+ "subscriptions_unseen_notifs_count_1": "{{count}} neviđena obaveštenja",
+ "subscriptions_unseen_notifs_count_2": "{{count}} neviđenih obaveštenja",
+ "Album: ": "Album: ",
+ "preferences_quality_option_dash": "DASH (adaptivni kvalitet)",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "Video unavailable": "Video snimak nedostupan",
+ "tokens_count_0": "{{count}} token",
+ "tokens_count_1": "{{count}} tokena",
+ "tokens_count_2": "{{count}} tokena",
+ "Chinese (China)": "Kineski (Kina)",
+ "Italian (auto-generated)": "Italijanski (automatski generisano)",
+ "channel_tab_shorts_label": "Shorts",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_360p": "360p",
+ "search_message_no_results": "Nisu pronađeni rezultati.",
+ "channel_tab_releases_label": "Izdanja",
+ "preferences_quality_dash_option_144p": "144p",
+ "Interlingue": "Interlingva",
+ "Song: ": "Pesma: ",
+ "generic_channels_count_0": "{{count}} kanal",
+ "generic_channels_count_1": "{{count}} kanala",
+ "generic_channels_count_2": "{{count}} kanala",
+ "Chinese (Taiwan)": "Kineski (Tajvan)",
+ "Turkish (auto-generated)": "Turski (automatski generisano)",
+ "Indonesian (auto-generated)": "Indonezijski (automatski generisano)",
+ "Portuguese (auto-generated)": "Portugalski (automatski generisano)",
+ "generic_count_years_0": "{{count}} godina",
+ "generic_count_years_1": "{{count}} godine",
+ "generic_count_years_2": "{{count}} godina",
+ "videoinfo_invidious_embed_link": "Ugrađeni link",
+ "Popular enabled: ": "Popularno omogućeno: ",
+ "Spanish (auto-generated)": "Španski (automatski generisano)",
+ "preferences_quality_option_small": "Malo",
+ "English (United Kingdom)": "Engleski (Ujedinjeno Kraljevstvo)",
+ "channel_tab_playlists_label": "Plejliste",
+ "generic_button_edit": "Izmeni",
+ "generic_playlists_count_0": "{{count}} plejlista",
+ "generic_playlists_count_1": "{{count}} plejliste",
+ "generic_playlists_count_2": "{{count}} plejlista",
+ "preferences_quality_option_hd720": "HD720",
+ "search_filters_features_option_purchased": "Kupljeno",
+ "search_filters_date_option_none": "Bilo koji datum",
+ "preferences_quality_dash_option_auto": "Automatski",
+ "Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
+ "crash_page_report_issue": "Ako ništa od gorenavedenog nije pomoglo, <a href=\"`x`\">otvorite novi izveštaj o problemu na GitHub-u</a> (po mogućnosti na engleskom) i uključite sledeći tekst u svoju poruku (NE prevodite taj tekst):",
+ "crash_page_switch_instance": "pokušali da <a href=\"`x`\">koristite drugu instancu</a>",
+ "generic_count_weeks_0": "{{count}} nedelja",
+ "generic_count_weeks_1": "{{count}} nedelje",
+ "generic_count_weeks_2": "{{count}} nedelja",
+ "videoinfo_watch_on_youTube": "Gledaj na YouTube-u",
+ "Music in this video": "Muzika u ovom video snimku",
+ "generic_button_rss": "RSS",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "generic_count_hours_0": "{{count}} sat",
+ "generic_count_hours_1": "{{count}} sata",
+ "generic_count_hours_2": "{{count}} sati",
+ "French (auto-generated)": "Francuski (automatski generisano)",
+ "crash_page_read_the_faq": "pročitali <a href=\"`x`\">Često Postavljana Pitanja (ČPP)</a>",
+ "user_created_playlists": "Napravljenih plejlista: `x`",
+ "channel_tab_channels_label": "Kanali",
+ "search_filters_type_option_all": "Bilo koja vrsta",
+ "Russian (auto-generated)": "Ruski (automatski generisano)",
+ "preferences_quality_dash_option_480p": "480p",
+ "comments_view_x_replies_0": "Pogledaj {{count}} odgovor",
+ "comments_view_x_replies_1": "Pogledaj {{count}} odgovora",
+ "comments_view_x_replies_2": "Pogledaj {{count}} odgovora",
+ "Portuguese (Brazil)": "Portugalski (Brazil)",
+ "search_filters_features_option_vr180": "VR180",
+ "error_video_not_in_playlist": "Traženi video snimak ne postoji na ovoj plejlisti. <a href=\"`x`\">Kliknite ovde za početnu stranicu plejliste.</a>",
+ "Dutch (auto-generated)": "Holandski (automatski generisano)",
+ "generic_count_days_0": "{{count}} dan",
+ "generic_count_days_1": "{{count}} dana",
+ "generic_count_days_2": "{{count}} dana",
+ "Vietnamese (auto-generated)": "Vijetnamski (automatski generisano)",
+ "search_filters_duration_option_none": "Bilo koje trajanje",
+ "preferences_quality_dash_option_240p": "240p",
+ "Chinese": "Kineski",
+ "generic_button_delete": "Izbriši",
+ "Import YouTube playlist (.csv)": "Uvezi YouTube plejlistu (.csv)",
+ "Standard YouTube license": "Standardna YouTube licenca",
+ "search_filters_duration_option_medium": "Srednje (4 - 20 minuta)",
+ "generic_count_seconds_0": "{{count}} sekunda",
+ "generic_count_seconds_1": "{{count}} sekunde",
+ "generic_count_seconds_2": "{{count}} sekundi",
+ "search_filters_date_label": "Datum otpremanja",
+ "crash_page_you_found_a_bug": "Izgleda da ste pronašli grešku u Invidious-u!",
+ "generic_views_count_0": "{{count}} pregled",
+ "generic_views_count_1": "{{count}} pregleda",
+ "generic_views_count_2": "{{count}} pregleda",
+ "Import YouTube watch history (.json)": "Uvezi YouTube istoriju gledanja (.json)",
+ "The Popular feed has been disabled by the administrator.": "Administrator je onemogućio fid „Popularno“.",
+ "Add to playlist: ": "Dodajte na plejlistu: ",
+ "Add to playlist": "Dodaj na plejlistu",
+ "carousel_slide": "Slajd {{current}} od {{total}}",
+ "carousel_go_to": "Idi na slajd `x`",
+ "Answer": "Odgovor",
+ "Search for videos": "Pretražite video snimke",
+ "carousel_skip": "Preskoči karusel",
+ "toggle_theme": "Подеси тему"
}
diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json
index 218f31c9..483e7fc4 100644
--- a/locales/sr_Cyrl.json
+++ b/locales/sr_Cyrl.json
@@ -1,166 +1,166 @@
{
"LIVE": "УЖИВО",
- "Shared `x` ago": "Подељено пре `x`",
+ "Shared `x` ago": "Дељено пре `x`",
"Unsubscribe": "Прекини праћење",
- "Subscribe": "Прати",
+ "Subscribe": "Запрати",
"View channel on YouTube": "Погледај канал на YouTube-у",
- "View playlist on YouTube": "Погледај списак извођења на YоуТубе-у",
+ "View playlist on YouTube": "Погледај плејлисту на YouTube-у",
"newest": "најновије",
"oldest": "најстарије",
"popular": "популарно",
"last": "последње",
- "Next page": "Следећа страна",
- "Previous page": "Претходна страна",
- "Clear watch history?": "Избрисати повест прегледања?",
+ "Next page": "Следећа страница",
+ "Previous page": "Претходна страница",
+ "Clear watch history?": "Очистити историју гледања?",
"New password": "Нова лозинка",
- "New passwords must match": "Нове лозинке морају бити истоветне",
- "Authorize token?": "Овласти жетон?",
- "Authorize token for `x`?": "Овласти жетон за `x`?",
+ "New passwords must match": "Нове лозинке морају да се подударају",
+ "Authorize token?": "Ауторизовати токен?",
+ "Authorize token for `x`?": "Ауторизовати токен за `x`?",
"Yes": "Да",
"No": "Не",
"Import and Export Data": "Увоз и извоз података",
"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)",
+ "Import Invidious data": "Увези Invidious JSON податке",
+ "Import YouTube subscriptions": "Увези YouTube CSV или OPML праћења",
+ "Import FreeTube subscriptions (.db)": "Увези FreeTube праћења (.db)",
+ "Import NewPipe subscriptions (.json)": "Увези NewPipe праћења (.json)",
+ "Import NewPipe data (.zip)": "Увези NewPipe податке (.zip)",
"Export": "Извези",
- "Export subscriptions as OPML": "Извези праћења као ОПМЛ датотеку",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као ОПМЛ датотеку (за NewPipe и FreeTube)",
- "Export data as JSON": "Извези податке као JSON датотеку",
- "Delete account?": "Избришите налог?",
+ "Export subscriptions as OPML": "Извези праћења као OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као OPML (за NewPipe и FreeTube)",
+ "Export data as JSON": "Извези Invidious податке као 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": "Пријави се/Отворите налог",
- "User ID": "Кориснички ИД",
+ "Log in": "Пријава",
+ "Log in/register": "Пријава/регистрација",
+ "User ID": "ID корисника",
"Password": "Лозинка",
"Time (h:mm:ss):": "Време (ч:мм:сс):",
- "Text CAPTCHA": "Знаковни ЦАПТЧА",
- "Image CAPTCHA": "Сликовни CAPTCHA",
+ "Text CAPTCHA": "Текст CAPTCHA",
+ "Image CAPTCHA": "Слика CAPTCHA",
"Sign In": "Пријава",
- "Register": "Отвори налог",
- "E-mail": "Е-пошта",
+ "Register": "Регистрација",
+ "E-mail": "Имејл",
"Preferences": "Подешавања",
- "preferences_category_player": "Подешавања репродуктора",
+ "preferences_category_player": "Подешавања плејера",
"preferences_video_loop_label": "Увек понављај: ",
- "preferences_autoplay_label": "Самопуштање: ",
- "preferences_continue_label": "Увек подразумевано пуштај следеће: ",
- "preferences_continue_autoplay_label": "Самопуштање следећег видео записа: ",
- "preferences_listen_label": "Увек подразумевано укључен само звук: ",
- "preferences_local_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_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_captions_label": "Подразумевани титлови: ",
+ "Fallback captions: ": "Резервни титлови: ",
+ "preferences_related_videos_label": "Прикажи сродне видео снимке: ",
+ "preferences_annotations_label": "Подразумевано прикажи напомене: ",
+ "preferences_category_visual": "Визуелна подешавања",
"preferences_player_style_label": "Стил плејера: ",
"Dark mode: ": "Тамни режим: ",
- "preferences_dark_mode_label": "Изглед/Тема: ",
- "dark": "тамно",
- "light": "светло",
+ "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": "Сортирај видео клипове по: ",
+ "Redirect homepage to feed: ": "Преусмери почетну страницу на фид: ",
+ "preferences_max_results_label": "Број видео снимака приказаних у фиду: ",
+ "preferences_sort_label": "Сортирај видео снимке по: ",
"published": "објављено",
"published - reverse": "објављено - обрнуто",
- "alphabetically": "по алфабету",
- "alphabetically - 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` преноси уживо",
+ "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": "Обриши историју гледања",
+ "Clear watch history": "Очисти историју гледања",
"Import/export data": "Увези/Извези податке",
"Change password": "Промени лозинку",
- "Manage subscriptions": "Управљај записима",
- "Manage tokens": "Управљај жетонима",
+ "Manage subscriptions": "Управљај праћењима",
+ "Manage tokens": "Управљај токенима",
"Watch history": "Историја гледања",
- "Delete account": "Обриши налог",
- "preferences_category_admin": "Администраторска подешавања",
+ "Delete account": "Избриши налог",
+ "preferences_category_admin": "Подешавања администратора",
"preferences_default_home_label": "Подразумевана почетна страница: ",
- "preferences_feed_menu_label": "Доводна страница: ",
+ "preferences_feed_menu_label": "Фид мени: ",
"CAPTCHA enabled: ": "CAPTCHA омогућена: ",
"Login enabled: ": "Пријава омогућена: ",
"Registration enabled: ": "Регистрација омогућена: ",
"Save preferences": "Сачувај подешавања",
"Subscription manager": "Управљање праћењима",
- "Token manager": "Управљање жетонима",
- "Token": "Жетон",
- "Import/export": "Увези/Извези",
- "unsubscribe": "прекини са праћењем",
+ "Token manager": "Управљање токенима",
+ "Token": "Токен",
+ "Import/export": "Увоз/извоз",
+ "unsubscribe": "прекини праћење",
"revoke": "опозови",
"Subscriptions": "Праћења",
"search": "претрага",
"Log out": "Одјава",
- "Source available here.": "Изворна кода је овде доступна.",
- "View JavaScript license information.": "Погледај информације лиценце везане за JavaScript.",
- "View privacy policy.": "Погледај извештај о приватности.",
+ "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`",
+ "Playlist privacy": "Приватност плејлисте",
+ "Editing playlist `x`": "Измењивање плејлисте `x`",
"Watch on YouTube": "Гледај на YouTube-у",
"Hide annotations": "Сакриј напомене",
"Show annotations": "Прикажи напомене",
"Genre: ": "Жанр: ",
"License: ": "Лиценца: ",
"Engagement: ": "Ангажовање: ",
- "Whitelisted regions: ": "Дозвољене области: ",
- "Blacklisted regions: ": "Забрањене области: ",
- "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 коментаре",
+ "Whitelisted regions: ": "Доступни региони: ",
+ "Blacklisted regions: ": "Недоступни региони: ",
+ "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": "Нетачна лозинка",
"Current version: ": "Тренутна верзија: ",
- "Wilson score: ": "Wилсонова оцена: ",
+ "Wilson score: ": "Вилсонова оцена: ",
"Burmese": "Бурмански",
- "preferences_quality_dash_label": "Преферирани квалитет DASH видео формата: ",
- "Erroneous token": "Погрешан жетон",
+ "preferences_quality_dash_label": "Преферирани DASH квалитет видео снимка: ",
+ "Erroneous token": "Погрешан токен",
"CAPTCHA is a required field": "CAPTCHA је обавезно поље",
- "No such user": "Непостојећи корисник",
+ "No such user": "Не постоји корисник",
"Chinese (Traditional)": "Кинески (Традиционални)",
- "adminprefs_modified_source_code_url_label": "УРЛ веза до складишта са Измењеном Изворном Кодом",
+ "adminprefs_modified_source_code_url_label": "URL адреса до репозиторијума измењеног изворног кода",
"Lao": "Лаоски",
"Czech": "Чешки",
- "Kannada": "Канада (Језик)",
+ "Kannada": "Канада",
"Polish": "Пољски",
- "Cebuano": "Себуано",
+ "Cebuano": "Цебуански",
"preferences_show_nick_label": "Прикажи надимке на врху: ",
- "Report statistics: ": "Извештавај о статистици: ",
+ "Report statistics: ": "Извештавај статистике: ",
"Show more": "Прикажи више",
"Wrong answer": "Погрешан одговор",
- "Hidden field \"token\" is a required field": "Сакривено \"token\" поље је обавезно",
+ "Hidden field \"token\" is a required field": "Скривено поље „токен“ је обавезно поље",
"English": "Енглески",
"Albanian": "Албански",
"Amharic": "Амхарски",
@@ -176,38 +176,38 @@
"Georgian": "Грузијски",
"Greek": "Грчки",
"Hausa": "Хауса",
- "search_filters_type_option_video": "Видео",
- "search_filters_type_option_playlist": "Плеј листа",
+ "search_filters_type_option_video": "Видео снимак",
+ "search_filters_type_option_playlist": "Плејлиста",
"search_filters_type_option_movie": "Филм",
"search_filters_duration_option_long": "Дуго (> 20 минута)",
- "search_filters_features_option_c_commons": "Creative Commons (Лиценца)",
+ "search_filters_features_option_c_commons": "Creative Commons",
"search_filters_features_option_live": "Уживо",
"search_filters_features_option_location": "Локација",
- "next_steps_error_message": "Након чега би требали пробати: ",
+ "next_steps_error_message": "Након тога би требало да покушате да: ",
"footer_donate_page": "Донирај",
"footer_documentation": "Документација",
- "footer_modfied_source_code": "Измењена Изворна Кода",
- "preferences_region_label": "Држава порекла садржаја: ",
+ "footer_modfied_source_code": "Измењени изворни кôд",
+ "preferences_region_label": "Држава садржаја: ",
"preferences_category_misc": "Остала подешавања",
- "User ID is a required field": "Кориснички ИД је обавезно поље",
+ "User ID is a required field": "ID корисника је обавезно поље",
"Password is a required field": "Лозинка је обавезно поље",
"Wrong username or password": "Погрешно корисничко име или лозинка",
"Password cannot be empty": "Лозинка не може бити празна",
- "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 карактера",
- "Invidious Private Feed for `x`": "Инвидиоус Приватни Довод за `x`",
- "Deleted or invalid channel": "Обрисан или непостојећи канал",
+ "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 знакова",
+ "Invidious Private Feed for `x`": "Invidious приватни фид за `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\" поље је обавезно",
+ "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": "Скривено поље „изазов“ је обавезно поље",
"Telugu": "Телугу",
"Turkish": "Турски",
"Urdu": "Урду",
- "Western Frisian": "Западнофрисијски",
- "Xhosa": "Коса (Језик)",
+ "Western Frisian": "Западнофризијски",
+ "Xhosa": "Коса (Кхоса)",
"Yiddish": "Јидиш",
"Hawaiian": "Хавајски",
"Hmong": "Хмонг",
@@ -217,58 +217,58 @@
"Khmer": "Кмерски",
"Kyrgyz": "Киргиски",
"Macedonian": "Македонски",
- "Maori": "Маори (Језик)",
- "Marathi": "Маратхи",
+ "Maori": "Маорски",
+ "Marathi": "Маратски",
"Nepali": "Непалски",
"Norwegian Bokmål": "Норвешки Бокмал",
- "Nyanja": "Чева",
+ "Nyanja": "Нијанџа",
"Russian": "Руски",
"Scottish Gaelic": "Шкотски Гелски",
"Shona": "Шона",
"Slovak": "Словачки",
- "Spanish (Latin America)": "Шпански (Јужна Америка)",
- "Sundanese": "Сундски",
- "Swahili": "Свахили",
+ "Spanish (Latin America)": "Шпански (Латинска Америка)",
+ "Sundanese": "Сундански",
+ "Swahili": "Сували",
"Tajik": "Таџички",
"Search": "Претрага",
- "Rating: ": "Ocena/e: ",
- "Default": "Подразумеван/о",
+ "Rating: ": "Оцена: ",
+ "Default": "Подразумевано",
"News": "Вести",
"Download": "Преузми",
"(edited)": "(измењено)",
- "`x` marked it with a ❤": "`x` је означио/ла ово са ❤",
- "Audio mode": "Аудио мод",
- "channel_tab_videos_label": "Видео клипови",
+ "`x` marked it with a ❤": "`x` је означио/ла са ❤",
+ "Audio mode": "Режим аудио снимка",
+ "channel_tab_videos_label": "Видео снимци",
"search_filters_sort_option_views": "Број прегледа",
"search_filters_features_label": "Карактеристике",
"search_filters_date_option_today": "Данас",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"preferences_locale_label": "Језик: ",
- "Persian": "Перзијски",
+ "Persian": "Персијски",
"View `x` comments": {
- "": "Прикажи `x` коментара",
- "([^.,0-9]|^)1([^.,0-9]|$)": "Прикажи `x` коментар"
+ "": "Погледај `x` коментара",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Погледај `x` коментар"
},
"search_filters_type_option_channel": "Канал",
"Haitian Creole": "Хаићански Креолски",
"Armenian": "Јерменски",
- "next_steps_error_message_go_to_youtube": "Иди на YouTube",
- "Indonesian": "Индонежански",
- "preferences_vr_mode_label": "Интерактивни видео клипови у 360 степени: ",
+ "next_steps_error_message_go_to_youtube": "Одете на YouTube",
+ "Indonesian": "Индонезијски",
+ "preferences_vr_mode_label": "Интерактивни видео снимци од 360 степени (захтева WebGL): ",
"Switch Invidious Instance": "Промени Invidious инстанцу",
"Portuguese": "Португалски",
- "search_filters_date_option_week": "Ове седмице",
+ "search_filters_date_option_week": "Ове недеље",
"search_filters_type_option_show": "Емисија",
- "Fallback comments: ": "Коментари у случају отказивања: ",
- "search_filters_features_option_hdr": "Видео Високе Резолуције",
- "About": "О програму",
+ "Fallback comments: ": "Резервни коментари: ",
+ "search_filters_features_option_hdr": "HDR",
+ "About": "О сајту",
"Kazakh": "Казашки",
- "Shared `x`": "Подељено `x`",
- "Playlists": "Плеј листе",
+ "Shared `x`": "Дељено `x`",
+ "Playlists": "Плејлисте",
"Yoruba": "Јоруба",
"Erroneous challenge": "Погрешан изазов",
"Danish": "Дански",
- "Could not get channel info.": "Узимање података о каналу није успело.",
+ "Could not get channel info.": "Није могуће прикупити информације о каналу.",
"search_filters_features_option_hd": "HD",
"Slovenian": "Словеначки",
"Load more": "Учитај више",
@@ -276,53 +276,53 @@
"Luxembourgish": "Луксембуршки",
"Mongolian": "Монголски",
"Latvian": "Летонски",
- "channel:`x`": "kanal:`x`",
+ "channel:`x`": "канал:`x`",
"Southern Sotho": "Јужни Сото",
"Popular": "Популарно",
"Gujarati": "Гуџарати",
"search_filters_date_option_year": "Ове године",
"Irish": "Ирски",
- "YouTube comment permalink": "YouTube коментар трајна веза",
+ "YouTube comment permalink": "Трајни линк YouTube коментара",
"Malagasy": "Малгашки",
- "Token is expired, please try again": "Жетон је истекао, молимо вас да покушате поново",
- "search_filters_duration_option_short": "Кратко (< 4 минуте)",
+ "Token is expired, please try again": "Токен је истекао, покушајте поново",
+ "search_filters_duration_option_short": "Кратко (< 4 минута)",
"Samoan": "Самоански",
"Tamil": "Тамилски",
"Ukrainian": "Украјински",
- "permalink": "трајна веза",
+ "permalink": "трајни линк",
"Pashto": "Паштунски",
"channel_tab_community_label": "Заједница",
"Sindhi": "Синди",
- "Could not fetch comments": "Узимање коментара није успело",
- "Bangla": "Бангла/Бенгалски",
+ "Could not fetch comments": "Није могуће прикупити коментаре",
+ "Bangla": "Бенгалски",
"Uzbek": "Узбечки",
"Lithuanian": "Литвански",
"Icelandic": "Исландски",
"Thai": "Тајски",
- "search_filters_date_option_month": "Овај месец",
- "search_filters_type_label": "Тип",
+ "search_filters_date_option_month": "Овог месеца",
+ "search_filters_type_label": "Врста",
"search_filters_date_option_hour": "Последњи сат",
"Spanish": "Шпански",
"search_filters_sort_option_date": "Датум отпремања",
- "View as playlist": "Погледај као плеј листу",
+ "View as playlist": "Погледај као плејлисту",
"search_filters_sort_option_relevance": "Релевантност",
"Estonian": "Естонски",
- "Sinhala": "Синхалешки",
+ "Sinhala": "Синхалски",
"Corsican": "Корзикански",
- "Filipino": "Филипино",
- "Gaming": "Игрице",
+ "Filipino": "Филипински",
+ "Gaming": "Видео игре",
"Movies": "Филмови",
- "search_filters_sort_option_rating": "Оцене",
- "Top enabled: ": "Врх омогућен: ",
- "Released under the AGPLv3 on Github.": "Избачено под лиценцом AGPLv3 на GitHub-у.",
+ "search_filters_sort_option_rating": "Оцена",
+ "Top enabled: ": "Топ омогућено: ",
+ "Released under the AGPLv3 on Github.": "Објављено под лиценцом AGPLv3 на GitHub-у.",
"Afrikaans": "Африканс",
- "preferences_automatic_instance_redirect_label": "Аутоматско пребацивање на другу инстанцу у случају отказивања (пречи ће назад на редирецт.инвидиоус.ио): ",
- "Please log in": "Молимо вас да се пријавите",
+ "preferences_automatic_instance_redirect_label": "Аутоматско преусмеравање инстанце (повратак на redirect.invidious.io): ",
+ "Please log in": "Молимо, пријавите се",
"English (auto-generated)": "Енглески (аутоматски генерисано)",
"Hindi": "Хинди",
- "Italian": "Талијански",
- "Malayalam": "Малајалам",
- "Punjabi": "Пунџаби",
+ "Italian": "Италијански",
+ "Malayalam": "Малајаламски",
+ "Punjabi": "Панџапски",
"Somali": "Сомалијски",
"Vietnamese": "Вијетнамски",
"Welsh": "Велшки",
@@ -330,25 +330,25 @@
"Maltese": "Малтешки",
"Swedish": "Шведски",
"Music": "Музика",
- "Download as: ": "Преузми као: ",
+ "Download as: ": "Преузети као: ",
"search_filters_duration_label": "Трајање",
- "search_filters_sort_label": "Поредај према",
- "search_filters_features_option_subtitles": "Титл/Превод",
- "preferences_extend_desc_label": "Аутоматски прикажи цео опис видеа: ",
+ "search_filters_sort_label": "Сортирање по",
+ "search_filters_features_option_subtitles": "Титлови/Скривени титлови",
+ "preferences_extend_desc_label": "Аутоматски прошири опис видео снимка: ",
"Show less": "Прикажи мање",
"Family friendly? ": "Погодно за породицу? ",
- "Premieres `x`": "Премерe у `x`",
+ "Premieres `x`": "Премијера `x`",
"Bosnian": "Босански",
"Catalan": "Каталонски",
"Japanese": "Јапански",
"Latin": "Латински",
- "next_steps_error_message_refresh": "Освежи страницу",
- "footer_original_source_code": "Оригинална Изворна Кода",
+ "next_steps_error_message_refresh": "Освежите",
+ "footer_original_source_code": "Оригинални изворни кôд",
"Romanian": "Румунски",
"Serbian": "Српски",
- "Top": "Врх",
- "Video mode": "Видео мод",
- "footer_source_code": "Изворна Кода",
+ "Top": "Топ",
+ "Video mode": "Режим видео снимка",
+ "footer_source_code": "Изворни кôд",
"search_filters_features_option_three_d": "3D",
"search_filters_features_option_four_k": "4K",
"Erroneous CAPTCHA": "Погрешна CAPTCHA",
@@ -360,5 +360,158 @@
"Korean": "Корејски",
"Kurdish": "Курдски",
"Malay": "Малајски",
- "search_filters_title": "Филтер"
+ "search_filters_title": "Филтери",
+ "Korean (auto-generated)": "Корејски (аутоматски генерисано)",
+ "search_filters_features_option_three_sixty": "360°",
+ "preferences_quality_dash_option_worst": "Најгоре",
+ "channel_tab_podcasts_label": "Подкасти",
+ "preferences_save_player_pos_label": "Сачувај позицију репродукције: ",
+ "Spanish (Mexico)": "Шпански (Мексико)",
+ "generic_subscriptions_count_0": "{{count}} праћење",
+ "generic_subscriptions_count_1": "{{count}} праћења",
+ "generic_subscriptions_count_2": "{{count}} праћења",
+ "search_filters_apply_button": "Примени изабране филтере",
+ "Download is disabled": "Преузимање је онемогућено",
+ "comments_points_count_0": "{{count}} поен",
+ "comments_points_count_1": "{{count}} поена",
+ "comments_points_count_2": "{{count}} поена",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "German (auto-generated)": "Немачки (аутоматски генерисано)",
+ "Japanese (auto-generated)": "Јапански (аутоматски генерисано)",
+ "preferences_quality_option_medium": "Средње",
+ "search_message_change_filters_or_query": "Покушајте да проширите упит за претрагу и/или промените филтере.",
+ "crash_page_before_reporting": "Пре него што пријавите грешку, уверите се да сте:",
+ "preferences_quality_dash_option_best": "Најбоље",
+ "Channel Sponsor": "Спонзор канала",
+ "generic_videos_count_0": "{{count}} видео снимак",
+ "generic_videos_count_1": "{{count}} видео снимка",
+ "generic_videos_count_2": "{{count}} видео снимака",
+ "videoinfo_started_streaming_x_ago": "Започето стримовање пре `x`",
+ "videoinfo_youTube_embed_link": "Уграђено",
+ "channel_tab_streams_label": "Стримови уживо",
+ "playlist_button_add_items": "Додај видео снимке",
+ "generic_count_minutes_0": "{{count}} минут",
+ "generic_count_minutes_1": "{{count}} минута",
+ "generic_count_minutes_2": "{{count}} минута",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_watch_history_label": "Омогући историју гледања: ",
+ "user_saved_playlists": "Сачуваних плејлиста: `x`",
+ "Spanish (Spain)": "Шпански (Шпанија)",
+ "invidious": "Invidious",
+ "crash_page_refresh": "покушали да <a href=\"`x`\">освежите страницу</a>",
+ "Chinese (Hong Kong)": "Кинески (Хонг Конг)",
+ "Artist: ": "Извођач: ",
+ "generic_count_months_0": "{{count}} месец",
+ "generic_count_months_1": "{{count}} месеца",
+ "generic_count_months_2": "{{count}} месеци",
+ "search_message_use_another_instance": "Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
+ "generic_subscribers_count_0": "{{count}} пратилац",
+ "generic_subscribers_count_1": "{{count}} пратиоца",
+ "generic_subscribers_count_2": "{{count}} пратилаца",
+ "download_subtitles": "Титлови - `x` (.vtt)",
+ "generic_button_save": "Сачувај",
+ "crash_page_search_issue": "претражили <a href=\"`x`\">постојеће извештаје о проблемима на GitHub-у</a>",
+ "generic_button_cancel": "Откажи",
+ "none": "ниједно",
+ "English (United States)": "Енглески (Сједињене Америчке Државе)",
+ "subscriptions_unseen_notifs_count_0": "{{count}} невиђено обавештење",
+ "subscriptions_unseen_notifs_count_1": "{{count}} невиђена обавештења",
+ "subscriptions_unseen_notifs_count_2": "{{count}} невиђених обавештења",
+ "Album: ": "Албум: ",
+ "preferences_quality_option_dash": "DASH (адаптивни квалитет)",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "Video unavailable": "Видео снимак недоступан",
+ "tokens_count_0": "{{count}} токен",
+ "tokens_count_1": "{{count}} токена",
+ "tokens_count_2": "{{count}} токена",
+ "Chinese (China)": "Кинески (Кина)",
+ "Italian (auto-generated)": "Италијански (аутоматски генерисано)",
+ "channel_tab_shorts_label": "Shorts",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_360p": "360p",
+ "search_message_no_results": "Нису пронађени резултати.",
+ "channel_tab_releases_label": "Издања",
+ "preferences_quality_dash_option_144p": "144p",
+ "Interlingue": "Интерлингва",
+ "Song: ": "Песма: ",
+ "generic_channels_count_0": "{{count}} канал",
+ "generic_channels_count_1": "{{count}} канала",
+ "generic_channels_count_2": "{{count}} канала",
+ "Chinese (Taiwan)": "Кинески (Тајван)",
+ "Turkish (auto-generated)": "Турски (аутоматски генерисано)",
+ "Indonesian (auto-generated)": "Индонезијски (аутоматски генерисано)",
+ "Portuguese (auto-generated)": "Португалски (аутоматски генерисано)",
+ "generic_count_years_0": "{{count}} година",
+ "generic_count_years_1": "{{count}} године",
+ "generic_count_years_2": "{{count}} година",
+ "videoinfo_invidious_embed_link": "Уграђени линк",
+ "Popular enabled: ": "Популарно омогућено: ",
+ "Spanish (auto-generated)": "Шпански (аутоматски генерисано)",
+ "preferences_quality_option_small": "Мало",
+ "English (United Kingdom)": "Енглески (Уједињено Краљевство)",
+ "channel_tab_playlists_label": "Плејлисте",
+ "generic_button_edit": "Измени",
+ "generic_playlists_count_0": "{{count}} плејлиста",
+ "generic_playlists_count_1": "{{count}} плејлисте",
+ "generic_playlists_count_2": "{{count}} плејлиста",
+ "preferences_quality_option_hd720": "HD720",
+ "search_filters_features_option_purchased": "Купљено",
+ "search_filters_date_option_none": "Било који датум",
+ "preferences_quality_dash_option_auto": "Аутоматски",
+ "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)",
+ "crash_page_report_issue": "Ако ништа од горенаведеног није помогло, <a href=\"`x`\">отворите нови извештај о проблему на GitHub-у</a> (по могућности на енглеском) и укључите следећи текст у своју поруку (НЕ преводите тај текст):",
+ "crash_page_switch_instance": "покушали да <a href=\"`x`\">користите другу инстанцу</a>",
+ "generic_count_weeks_0": "{{count}} недеља",
+ "generic_count_weeks_1": "{{count}} недеље",
+ "generic_count_weeks_2": "{{count}} недеља",
+ "videoinfo_watch_on_youTube": "Гледај на YouTube-у",
+ "Music in this video": "Музика у овом видео снимку",
+ "generic_button_rss": "RSS",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "generic_count_hours_0": "{{count}} сат",
+ "generic_count_hours_1": "{{count}} сата",
+ "generic_count_hours_2": "{{count}} сати",
+ "French (auto-generated)": "Француски (аутоматски генерисано)",
+ "crash_page_read_the_faq": "прочитали <a href=\"`x`\">Често Постављана Питања (ЧПП)</a>",
+ "user_created_playlists": "Направљених плејлиста: `x`",
+ "channel_tab_channels_label": "Канали",
+ "search_filters_type_option_all": "Било која врста",
+ "Russian (auto-generated)": "Руски (аутоматски генерисано)",
+ "preferences_quality_dash_option_480p": "480p",
+ "comments_view_x_replies_0": "Погледај {{count}} одговор",
+ "comments_view_x_replies_1": "Погледај {{count}} одговора",
+ "comments_view_x_replies_2": "Погледај {{count}} одговора",
+ "Portuguese (Brazil)": "Португалски (Бразил)",
+ "search_filters_features_option_vr180": "VR180",
+ "error_video_not_in_playlist": "Тражени видео снимак не постоји на овој плејлисти. <a href=\"`x`\">Кликните овде за почетну страницу плејлисте.</a>",
+ "Dutch (auto-generated)": "Холандски (аутоматски генерисано)",
+ "generic_count_days_0": "{{count}} дан",
+ "generic_count_days_1": "{{count}} дана",
+ "generic_count_days_2": "{{count}} дана",
+ "Vietnamese (auto-generated)": "Вијетнамски (аутоматски генерисано)",
+ "search_filters_duration_option_none": "Било које трајање",
+ "preferences_quality_dash_option_240p": "240p",
+ "Chinese": "Кинески",
+ "generic_button_delete": "Избриши",
+ "Import YouTube playlist (.csv)": "Увези YouTube плејлисту (.csv)",
+ "Standard YouTube license": "Стандардна YouTube лиценца",
+ "search_filters_duration_option_medium": "Средње (4 - 20 минута)",
+ "generic_count_seconds_0": "{{count}} секунда",
+ "generic_count_seconds_1": "{{count}} секунде",
+ "generic_count_seconds_2": "{{count}} секунди",
+ "search_filters_date_label": "Датум отпремања",
+ "crash_page_you_found_a_bug": "Изгледа да сте пронашли грешку у Invidious-у!",
+ "generic_views_count_0": "{{count}} преглед",
+ "generic_views_count_1": "{{count}} прегледа",
+ "generic_views_count_2": "{{count}} прегледа",
+ "Import YouTube watch history (.json)": "Увези YouTube историју гледањa (.json)",
+ "toggle_theme": "Укључи тему",
+ "Add to playlist": "Додај на плејлисту",
+ "Answer": "Одговор",
+ "Search for videos": "Претражите видео снимке",
+ "carousel_go_to": "Иди на слајд `x`",
+ "Add to playlist: ": "Додајте на плејлисту: ",
+ "carousel_skip": "Прескочи карусел",
+ "The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
+ "carousel_slide": "Слајд {{current}} од {{total}}"
}
diff --git a/locales/sv-SE.json b/locales/sv-SE.json
index a319fffd..f1313a4d 100644
--- a/locales/sv-SE.json
+++ b/locales/sv-SE.json
@@ -20,15 +20,15 @@
"No": "Nej",
"Import and Export Data": "Importera och exportera data",
"Import": "Importera",
- "Import Invidious data": "Importera Invidious-data",
- "Import YouTube subscriptions": "Importera YouTube-prenumerationer",
+ "Import Invidious data": "Importera Invidious JSON data",
+ "Import YouTube subscriptions": "Importera YouTube CSV eller OPML prenumerationer",
"Import FreeTube subscriptions (.db)": "Importera FreeTube-prenumerationer (.db)",
"Import NewPipe subscriptions (.json)": "Importera NewPipe-prenumerationer (.json)",
"Import NewPipe data (.zip)": "Importera NewPipe-data (.zip)",
"Export": "Exportera",
"Export subscriptions as OPML": "Exportera prenumerationer som OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportera prenumerationer som OPML (för NewPipe och FreeTube)",
- "Export data as JSON": "Exportera data som JSON",
+ "Export data as JSON": "Exportera Invidious data som JSON",
"Delete account?": "Radera konto?",
"History": "Historik",
"An alternative front-end to YouTube": "Ett alternativt gränssnitt till YouTube",
@@ -63,7 +63,7 @@
"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_vr_mode_label": "Interaktiva 360-gradervideos (kräver WebGL): ",
"preferences_category_visual": "Visuella inställningar",
"preferences_player_style_label": "Spelarstil: ",
"Dark mode: ": "Mörkt läge: ",
@@ -152,7 +152,7 @@
"View YouTube comments": "Visa YouTube-kommentarer",
"View more comments on Reddit": "Visa flera kommentarer på Reddit",
"View `x` comments": {
- "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` kommentarer",
+ "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` kommentar",
"": "Visa `x` kommentarer"
},
"View Reddit comments": "Visa Reddit-kommentarer",
@@ -167,7 +167,7 @@
"Wrong username or password": "Ogiltigt användarnamn eller lösenord",
"Password cannot be empty": "Lösenordet kan inte vara tomt",
"Password cannot be longer than 55 characters": "Lösenordet kan inte vara längre än 55 tecken",
- "Please log in": "Logga in",
+ "Please log in": "Snälla logga in",
"Invidious Private Feed for `x`": "Ogiltig privat flöde för `x`",
"channel:`x`": "kanal `x`",
"Deleted or invalid channel": "Raderad eller ogiltig kanal",
@@ -311,8 +311,8 @@
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(redigerad)",
"YouTube comment permalink": "Permanent YouTube-länk till innehållet",
- "permalink": "permalänk",
- "`x` marked it with a ❤": "`x` lämnade ett ❤",
+ "permalink": "permanent länk",
+ "`x` marked it with a ❤": "`x` markerade det med ett ❤",
"Audio mode": "Ljudläge",
"Video mode": "Videoläge",
"channel_tab_videos_label": "Videor",
@@ -320,30 +320,30 @@
"channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "Relevans",
"search_filters_sort_option_rating": "Rankning",
- "search_filters_sort_option_date": "Datum",
+ "search_filters_sort_option_date": "Uppladdnings datum",
"search_filters_sort_option_views": "Visningar",
"search_filters_type_label": "Typ",
"search_filters_duration_label": "Varaktighet",
"search_filters_features_label": "Funktioner",
"search_filters_sort_label": "Sortera efter",
- "search_filters_date_option_hour": "timme",
- "search_filters_date_option_today": "idag",
- "search_filters_date_option_week": "vecka",
- "search_filters_date_option_month": "månad",
- "search_filters_date_option_year": "år",
- "search_filters_type_option_video": "video",
- "search_filters_type_option_channel": "kanal",
- "search_filters_type_option_playlist": "spellista",
- "search_filters_type_option_movie": "film",
- "search_filters_type_option_show": "tv-serie",
- "search_filters_features_option_hd": "hd",
- "search_filters_features_option_subtitles": "undertexter",
- "search_filters_features_option_c_commons": "creative_commons",
- "search_filters_features_option_three_d": "3d",
- "search_filters_features_option_live": "live",
- "search_filters_features_option_four_k": "4k",
- "search_filters_features_option_location": "plats",
- "search_filters_features_option_hdr": "hdr",
+ "search_filters_date_option_hour": "Senaste timmen",
+ "search_filters_date_option_today": "Idag",
+ "search_filters_date_option_week": "Denna vecka",
+ "search_filters_date_option_month": "Denna månad",
+ "search_filters_date_option_year": "Detta år",
+ "search_filters_type_option_video": "Video",
+ "search_filters_type_option_channel": "Kanal",
+ "search_filters_type_option_playlist": "Spellista",
+ "search_filters_type_option_movie": "Film",
+ "search_filters_type_option_show": "Serie",
+ "search_filters_features_option_hd": "HD",
+ "search_filters_features_option_subtitles": "Undertexter/CC",
+ "search_filters_features_option_c_commons": "Creative Commons",
+ "search_filters_features_option_three_d": "3D",
+ "search_filters_features_option_live": "Live",
+ "search_filters_features_option_four_k": "4K",
+ "search_filters_features_option_location": "Plats",
+ "search_filters_features_option_hdr": "HDR",
"Current version: ": "Nuvarande version: ",
"next_steps_error_message_refresh": "Uppdatera",
"next_steps_error_message_go_to_youtube": "Gå till Youtube",
@@ -352,5 +352,149 @@
"search_filters_duration_option_long": "Lång (> 20 minuter)",
"footer_documentation": "Dokumentation",
"search_filters_duration_option_short": "Kort (< 4 minuter)",
- "search_filters_title": "Filter"
+ "search_filters_title": "Filter",
+ "Korean (auto-generated)": "Koreanska (auto-genererad)",
+ "search_filters_features_option_three_sixty": "360°",
+ "preferences_quality_dash_option_worst": "Sämst",
+ "channel_tab_podcasts_label": "Podcaster",
+ "preferences_save_player_pos_label": "Spara uppspelningsposition: ",
+ "Spanish (Mexico)": "Spanska (Mexiko)",
+ "preferences_region_label": "Innehållsland: ",
+ "generic_subscriptions_count": "{{count}} prenumeration",
+ "generic_subscriptions_count_plural": "{{count}} prenumerationer",
+ "search_filters_apply_button": "Använd valda filter",
+ "Download is disabled": "Nedladdning är inaktiverad",
+ "comments_points_count": "{{count}} poäng",
+ "comments_points_count_plural": "{{count}} poäng",
+ "preferences_quality_dash_option_2160p": "2160p",
+ "German (auto-generated)": "Tyska (auto-genererad)",
+ "Japanese (auto-generated)": "Japanska (auto-genererad)",
+ "preferences_quality_option_medium": "Medium",
+ "footer_donate_page": "Donera",
+ "search_message_change_filters_or_query": "Prova att bredda din sökfråga och/eller ändra filtren.",
+ "crash_page_before_reporting": "Innan du rapporterar en bugg, se till att du har:",
+ "preferences_quality_dash_option_best": "Bäst",
+ "Channel Sponsor": "Kanal Sponsor",
+ "generic_videos_count": "{{count}} video",
+ "generic_videos_count_plural": "{{count}} videor",
+ "videoinfo_started_streaming_x_ago": "Började sända `x` sedan",
+ "videoinfo_youTube_embed_link": "Bädda in",
+ "channel_tab_streams_label": "Livesändningar",
+ "playlist_button_add_items": "Lägg till videor",
+ "generic_count_minutes": "{{count}}minut",
+ "generic_count_minutes_plural": "{{count}}minuter",
+ "preferences_quality_dash_option_720p": "720p",
+ "preferences_watch_history_label": "Aktivera visningshistorik: ",
+ "user_saved_playlists": "`x` sparade spellistor",
+ "Spanish (Spain)": "Spanska (Spanien)",
+ "invidious": "Invidious",
+ "crash_page_refresh": "försökte <a href=\"`x`\">uppdatera sidan</a>",
+ "Chinese (Hong Kong)": "Kinesiska (Hong Kong)",
+ "Artist: ": "Artist: ",
+ "generic_count_months": "{{count}}månad",
+ "generic_count_months_plural": "{{count}}månader",
+ "search_message_use_another_instance": "Du kan också <a href=\"`x`\">söka på en annan instans</a>.",
+ "generic_subscribers_count": "{{count}} prenumerant",
+ "generic_subscribers_count_plural": "{{count}} prenumeranter",
+ "download_subtitles": "Undertexter - `x` (.vtt)",
+ "generic_button_save": "Spara",
+ "crash_page_search_issue": "sökte efter <a href=\"`x`\">befintliga problem på GitHub</a>",
+ "generic_button_cancel": "Avbryt",
+ "none": "ingen",
+ "English (United States)": "English (Förenta staterna)",
+ "subscriptions_unseen_notifs_count": "{{count}}osedd notifikation",
+ "subscriptions_unseen_notifs_count_plural": "{{count}}osedda notifikationer",
+ "Album: ": "Album: ",
+ "preferences_quality_option_dash": "DASH (adaptiv kvalitet)",
+ "preferences_quality_dash_option_1080p": "1080p",
+ "Video unavailable": "Video inte tillgänglig",
+ "tokens_count": "{{count}}nyckel",
+ "tokens_count_plural": "{{count}}nycklar",
+ "Chinese (China)": "Kinesiska (Kina)",
+ "Italian (auto-generated)": "Italienska (auto-genererad)",
+ "channel_tab_shorts_label": "Shorts",
+ "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_360p": "360p",
+ "search_message_no_results": "Inga resultat hittades.",
+ "channel_tab_releases_label": "Releaser",
+ "preferences_quality_dash_option_144p": "144p",
+ "Interlingue": "Interlingue (auto-genererad)",
+ "Song: ": "Låt: ",
+ "generic_channels_count": "{{count}} kanal",
+ "generic_channels_count_plural": "{{count}} kanaler",
+ "Chinese (Taiwan)": "Kinesiska (Taiwan)",
+ "preferences_quality_dash_label": "Önskad DASH-videokvalitet: ",
+ "adminprefs_modified_source_code_url_label": "URL till modifierad källkodslager",
+ "Turkish (auto-generated)": "Turkiska (auto-genererad)",
+ "Indonesian (auto-generated)": "Indonesiska (auto-genererad)",
+ "Portuguese (auto-generated)": "Portugisiska (auto-genererad)",
+ "generic_count_years": "{{count}}år",
+ "generic_count_years_plural": "{{count}}år",
+ "videoinfo_invidious_embed_link": "Bädda in länk",
+ "Popular enabled: ": "Populär aktiverad: ",
+ "Spanish (auto-generated)": "Spanska (auto-genererad)",
+ "preferences_quality_option_small": "Liten",
+ "English (United Kingdom)": "Engelska (Storbritannien)",
+ "channel_tab_playlists_label": "Spellistor",
+ "generic_button_edit": "Redigera",
+ "generic_playlists_count": "{{count}} spellista",
+ "generic_playlists_count_plural": "{{count}} spellistor",
+ "preferences_quality_option_hd720": "HD720p",
+ "search_filters_features_option_purchased": "Köpt",
+ "search_filters_date_option_none": "Vilket datum som helst",
+ "preferences_quality_dash_option_auto": "Auto",
+ "Cantonese (Hong Kong)": "Katonesiska (Hong Kong)",
+ "crash_page_report_issue": "Om inget av ovanstående hjälpte, vänligen <a href=\"`x`\">öppna ett nytt nummer på GitHub</a> (helst på engelska) och inkludera följande text i ditt meddelande (översätt INTE den texten):",
+ "crash_page_switch_instance": "försökte <a href=\"`x`\">använda en annan instans</a>",
+ "generic_count_weeks": "{{count}}vecka",
+ "generic_count_weeks_plural": "{{count}}veckor",
+ "videoinfo_watch_on_youTube": "Titta på YouTube",
+ "Music in this video": "Musik i denna video",
+ "footer_modfied_source_code": "Modifierad källkod",
+ "generic_button_rss": "RSS",
+ "preferences_quality_dash_option_4320p": "4320p",
+ "generic_count_hours": "{{count}}timme",
+ "generic_count_hours_plural": "{{count}}timmar",
+ "French (auto-generated)": "Franska (auto-genererad)",
+ "crash_page_read_the_faq": "läs <a href=\"`x`\">Vanliga frågor (FAQ)</a>",
+ "user_created_playlists": "`x` skapade spellistor",
+ "channel_tab_channels_label": "Kanaler",
+ "search_filters_type_option_all": "Vilken typ som helst",
+ "Russian (auto-generated)": "Ryska (auto-genererad)",
+ "preferences_quality_dash_option_480p": "480p",
+ "comments_view_x_replies": "Se {{count}} svar",
+ "comments_view_x_replies_plural": "Se {{count}} svar",
+ "footer_original_source_code": "Ursprunglig källkod",
+ "Portuguese (Brazil)": "Portugisiska (Brasilien)",
+ "search_filters_features_option_vr180": "VR180",
+ "error_video_not_in_playlist": "Den begärda videon finns inte i den här spellistan. <a href=\"`x`\">Klicka här för startsidan för spellistan.</a>",
+ "Dutch (auto-generated)": "Nederländska (auto-genererad)",
+ "generic_count_days": "{{count}}dag",
+ "generic_count_days_plural": "{{count}}dagar",
+ "Vietnamese (auto-generated)": "Vietnamesiska (auto-genererad)",
+ "search_filters_duration_option_none": "Vilken varaktighet som helst",
+ "preferences_quality_dash_option_240p": "240p",
+ "Chinese": "Kinesiska",
+ "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (återgång till redirect.invidious.io): ",
+ "generic_button_delete": "Radera",
+ "Import YouTube playlist (.csv)": "Importera YouTube spellista (.csv)",
+ "next_steps_error_message": "Därefter bör du försöka: ",
+ "Standard YouTube license": "Standard YouTube licens",
+ "Import YouTube watch history (.json)": "Importera YouTube visningshistorik (.json)",
+ "search_filters_duration_option_medium": "Medium (4 - 20 minuter)",
+ "generic_count_seconds": "{{count}}sekund",
+ "generic_count_seconds_plural": "{{count}}sekunder",
+ "search_filters_date_label": "Uppladdningsdatum",
+ "crash_page_you_found_a_bug": "Det verkar som att du har hittat en bugg i Invidious!",
+ "generic_views_count": "{{count}} visning",
+ "generic_views_count_plural": "{{count}} visningar",
+ "toggle_theme": "Växla tema",
+ "Add to playlist": "Lägg till i spellista",
+ "Add to playlist: ": "Lägg till i spellista: ",
+ "Answer": "Svara",
+ "Search for videos": "Sök efter videor",
+ "The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
+ "carousel_slide": "Bildspel {{current}} av {{total}}",
+ "carousel_skip": "Hoppa över karusellen",
+ "carousel_go_to": "Gå till bildspel `x`"
}
diff --git a/locales/tk.json b/locales/tk.json
new file mode 100644
index 00000000..798ea6ce
--- /dev/null
+++ b/locales/tk.json
@@ -0,0 +1,7 @@
+{
+ "Add to playlist": "Aýdym sanawyna goş",
+ "Add to playlist: ": "Pleýliste goş: ",
+ "Answer": "Jogap",
+ "Search for videos": "Wideo gözläň",
+ "The Popular feed has been disabled by the administrator.": "Trende bolan administrator tarapyndan ýapyldy."
+}
diff --git a/locales/tr.json b/locales/tr.json
index 7f3f2de8..282cbf88 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Verileri İçe ve Dışa Aktar",
"Import": "İçe Aktar",
"Import Invidious data": "Invidious JSON Verilerini İçe Aktar",
- "Import YouTube subscriptions": "YouTube/OPML Aboneliklerini İçe Aktar",
+ "Import YouTube subscriptions": "YouTube CSV veya OPML Aboneliklerini İçe Aktar",
"Import FreeTube subscriptions (.db)": "FreeTube Aboneliklerini İçe Aktar (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe Aboneliklerini İçe Aktar (.json)",
"Import NewPipe data (.zip)": "NewPipe Verilerini İçe Aktar (.zip)",
@@ -322,13 +322,13 @@
"channel_tab_community_label": "Topluluk",
"search_filters_sort_option_relevance": "İlgi",
"search_filters_sort_option_rating": "Değerlendirme",
- "search_filters_sort_option_date": "Yükleme Tarihi",
+ "search_filters_sort_option_date": "Yükleme tarihi",
"search_filters_sort_option_views": "Görüntüleme Sayısı",
"search_filters_type_label": "Tür",
"search_filters_duration_label": "Süre",
"search_filters_features_label": "Özellikler",
"search_filters_sort_label": "Sıralama Ölçütü",
- "search_filters_date_option_hour": "Son Saat",
+ "search_filters_date_option_hour": "Son saat",
"search_filters_date_option_today": "Bugün",
"search_filters_date_option_week": "Bu Hafta",
"search_filters_date_option_month": "Bu Ay",
@@ -452,7 +452,7 @@
"Spanish (Spain)": "İspanyolca (İspanya)",
"Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)",
"preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ",
- "search_message_use_another_instance": " Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
+ "search_message_use_another_instance": "Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
"search_filters_type_option_all": "Herhangi Bir Tür",
"search_filters_duration_option_none": "Herhangi Bir Süre",
"search_message_no_results": "Sonuç bulunamadı.",
@@ -484,5 +484,17 @@
"generic_button_rss": "RSS",
"channel_tab_releases_label": "Yayınlar",
"playlist_button_add_items": "Video ekle",
- "channel_tab_podcasts_label": "Podcast'ler"
+ "channel_tab_podcasts_label": "Podcast'ler",
+ "generic_channels_count": "{{count}} kanal",
+ "generic_channels_count_plural": "{{count}} kanal",
+ "Import YouTube watch history (.json)": "YouTube İzleme Geçmişini İçe Aktar (.json)",
+ "toggle_theme": "Temayı Değiştir",
+ "Add to playlist": "Oynatma listesine ekle",
+ "Add to playlist: ": "Oynatma listesine ekle: ",
+ "Answer": "Yanıt",
+ "Search for videos": "Video ara",
+ "carousel_slide": "Sunum {{current}} / {{total}}",
+ "carousel_skip": "Kayar menüyü atla",
+ "carousel_go_to": "`x` sunumuna git",
+ "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
}
diff --git a/locales/uk.json b/locales/uk.json
index 4d8f06a5..64329032 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Імпорт і експорт даних",
"Import": "Імпорт",
"Import Invidious data": "Імпортувати JSON-дані Invidious",
- "Import YouTube subscriptions": "Імпортувати підписки з YouTube чи OPML",
+ "Import YouTube subscriptions": "Імпортувати підписки YouTube з CSV чи OPML",
"Import FreeTube subscriptions (.db)": "Імпортувати підписки з FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Імпортувати підписки з NewPipe (.json)",
"Import NewPipe data (.zip)": "Імпортувати дані з NewPipe (.zip)",
@@ -127,7 +127,7 @@
"Create playlist": "Створити список відтворення",
"Title": "Заголовок",
"Playlist privacy": "Конфіденційність списку відтворення",
- "Editing playlist `x`": "Редагування списку відтворення \"x\"",
+ "Editing playlist `x`": "Редагування списку відтворення `x`",
"Watch on YouTube": "Дивитися на YouTube",
"Hide annotations": "Приховати анотації",
"Show annotations": "Показати анотації",
@@ -455,7 +455,7 @@
"search_filters_date_option_week": "Цей тиждень",
"search_filters_type_label": "Тип",
"search_filters_type_option_channel": "Канал",
- "search_message_use_another_instance": " Можете також <a href=\"`x`\">пошукати іншим сервером</a>.",
+ "search_message_use_another_instance": "Можете також <a href=\"`x`\">пошукати на іншому сервері</a>.",
"search_filters_title": "Фільтри",
"search_filters_date_option_hour": "Остання година",
"search_filters_date_option_month": "Цей місяць",
@@ -472,7 +472,7 @@
"search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_hdr": "HDR",
"search_filters_sort_label": "Спершу",
- "search_filters_sort_option_date": "Нещодавні",
+ "search_filters_sort_option_date": "Дата вивантаження",
"search_filters_apply_button": "Застосувати фільтри",
"search_filters_features_option_vr180": "VR180",
"search_filters_features_option_purchased": "Придбано",
@@ -500,5 +500,18 @@
"channel_tab_releases_label": "Випуски",
"generic_button_delete": "Видалити",
"generic_button_edit": "Змінити",
- "generic_button_save": "Зберегти"
+ "generic_button_save": "Зберегти",
+ "generic_channels_count_0": "{{count}} канал",
+ "generic_channels_count_1": "{{count}} канали",
+ "generic_channels_count_2": "{{count}} каналів",
+ "Import YouTube watch history (.json)": "Імпортувати історію переглядів YouTube (.json)",
+ "toggle_theme": "Перемкнути тему",
+ "Add to playlist": "Додати до списку відтворення",
+ "Add to playlist: ": "Додати до списку відтворення: ",
+ "Answer": "Відповідь",
+ "Search for videos": "Шукати відео",
+ "The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
+ "carousel_slide": "Слайд {{current}} з {{total}}",
+ "carousel_skip": "Пропустити карусель",
+ "carousel_go_to": "Перейти до слайда `x`"
}
diff --git a/locales/vi.json b/locales/vi.json
index 9cb87d3e..229f8fa9 100644
--- a/locales/vi.json
+++ b/locales/vi.json
@@ -1,62 +1,62 @@
{
"generic_videos_count_0": "{{count}} video",
- "generic_subscribers_count_0": "{{count}} người theo dõi",
+ "generic_subscribers_count_0": "{{count}} người đăng ký",
"LIVE": "TRỰC TIẾP",
"Shared `x` ago": "Đã chia sẻ `x` trước",
- "Unsubscribe": "Hủy theo dõi",
- "Subscribe": "Theo dõi",
+ "Unsubscribe": "Hủy đăng ký",
+ "Subscribe": "Đăng ký",
"View channel on YouTube": "Xem kênh trên YouTube",
"View playlist on YouTube": "Xem danh sách phát trên YouTube",
- "newest": "mới nhất",
- "oldest": "lâu đời nhất",
- "popular": "phổ biến",
- "last": "Cuối cùng",
+ "newest": "Mới nhất",
+ "oldest": "Cũ nhất",
+ "popular": "Phổ biến",
+ "last": "cuối cùng",
"Next page": "Trang tiếp theo",
"Previous page": "Trang trước",
"Clear watch history?": "Xóa lịch sử xem?",
"New password": "Mật khẩu mới",
"New passwords must match": "Mật khẩu mới phải khớp",
"Authorize token?": "Cấp phép mã thông báo?",
- "Authorize token for `x`?": "Cấp phép mã thông báo cho` x`?",
- "Yes": "Đúng",
+ "Authorize token for `x`?": "Cấp phép mã thông báo cho `x`?",
+ "Yes": "Có",
"No": "Không",
"Import and Export Data": "Nhập và xuất dữ liệu",
"Import": "Nhập",
- "Import Invidious data": "Nhập dữ liệu Invidious JSON",
- "Import YouTube subscriptions": "Nhập dữ liệu thuê bao YouTube/OPML",
- "Import FreeTube subscriptions (.db)": "Nhập đăng ký FreeTube (.db)",
- "Import NewPipe subscriptions (.json)": "Nhập đăng ký NewPipe (.json)",
- "Import NewPipe data (.zip)": "Nhập dữ liệu NewPipe (.zip)",
+ "Import Invidious data": "Nhập dữ liệu Invidious dưới dạng JSON",
+ "Import YouTube subscriptions": "Nhập các kênh đã đăng ký từ YouTube/OPML",
+ "Import FreeTube subscriptions (.db)": "Nhập các kênh đã đăng ký từ FreeTube (.db)",
+ "Import NewPipe subscriptions (.json)": "Nhập các kênh đã đăng ký từ NewPipe (.json)",
+ "Import NewPipe data (.zip)": "Nhập dữ liệu từ NewPipe (.zip)",
"Export": "Xuất",
- "Export subscriptions as OPML": "Xuất đăng ký dưới dạng OPML",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Xuất đăng ký dưới dạng OPML (cho NewPipe & FreeTube)",
+ "Export subscriptions as OPML": "Xuất các kênh đã đăng ký dưới dạng OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Xuất các kênh đã đăng ký dưới dạng OPML (cho NewPipe & FreeTube)",
"Export data as JSON": "Xuất dữ liệu Invidious dưới dạng JSON",
"Delete account?": "Xóa tài khoản?",
"History": "Lịch sử",
- "An alternative front-end to YouTube": "Giao diện người dùng thay thế cho YouTube",
+ "An alternative front-end to YouTube": "Giao diện thay thế cho YouTube",
"JavaScript license information": "Thông tin giấy phép JavaScript",
"source": "nguồn",
"Log in": "Đăng nhập",
"Log in/register": "Đăng nhập / đăng ký",
- "User ID": "Tên người dùng",
+ "User ID": "Mã nhận dạng người dùng",
"Password": "Mật khẩu",
- "Time (h:mm:ss):": "Thời gian (h: mm: ss):",
- "Text CAPTCHA": "Nhắn tin tới CAPTCHA",
- "Image CAPTCHA": "Hình ảnh CAPTCHA",
+ "Time (h:mm:ss):": "Thời gian (h:mm:ss):",
+ "Text CAPTCHA": "CAPTCHA dạng chữ",
+ "Image CAPTCHA": "CAPTCHA dạng ảnh",
"Sign In": "Đăng nhập",
"Register": "Đăng ký",
"E-mail": "E-mail",
- "Preferences": "Sở thích",
+ "Preferences": "Cài đặt",
"preferences_category_player": "Tùy chọn trình phát video",
"preferences_video_loop_label": "Luôn lặp lại: ",
- "preferences_autoplay_label": "Tự chạy: ",
+ "preferences_autoplay_label": "Tự động phát: ",
"preferences_continue_label": "Phát kế tiếp 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_local_label": "Máy chủ sử lý video: ",
"preferences_speed_label": "Tốc độ mặc định: ",
- "preferences_quality_label": "Chất lượng video ưa thích: ",
- "preferences_volume_label": "Âm lượng trình phát video: ",
+ "preferences_quality_label": "Chất lượng video: ",
+ "preferences_volume_label": "Âm lượng video: ",
"preferences_comments_label": "Nhận xét mặc định: ",
"youtube": "YouTube",
"reddit": "Reddit",
@@ -64,7 +64,7 @@
"Fallback captions: ": "Phụ đề dự phòng: ",
"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_extend_desc_label": "Tự động mở rộng phần mô tả của video: ",
"preferences_vr_mode_label": "Video 360 độ tương tác (yêu cầu WebGL): ",
"preferences_category_visual": "Tùy chọn hình ảnh",
"preferences_player_style_label": "Phong cách trình phát: ",
@@ -82,24 +82,24 @@
"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",
- "alphabetically - reverse": "theo thứ tự bảng chữ cái - đảo ngược",
- "channel name": "Tên kênh",
- "channel name - reverse": "tên kênh - đảo ngược",
+ "alphabetically": "Thứ tự (A - Z)",
+ "alphabetically - reverse": "Thứ tự (Z - A)",
+ "channel name": "Tên kênh (A - Z)",
+ "channel name - reverse": "Tên kênh (Z - A)",
"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: ",
- "preferences_unseen_only_label": "Chỉ hiển thị chưa xem: ",
+ "preferences_unseen_only_label": "Chỉ hiển thị các video chưa từng 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",
+ "`x` uploaded a video": "`x` đã tải lên một video",
+ "`x` is live": "`x` đang phát trực tiếp",
"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",
"Manage subscriptions": "Quản lý các mục đăng kí",
"Manage tokens": "Quản lý mã thông báo",
- "Watch history": "Lịch sử xem",
+ "Watch history": "Xem lịch sử",
"Delete account": "Xóa tài khoản",
"preferences_category_admin": "Tùy chọn quản trị viên",
"preferences_default_home_label": "Trang chủ mặc định: ",
@@ -121,7 +121,7 @@
"View privacy policy.": "Xem chính sách bảo mật.",
"Trending": "Xu hướng",
"Public": "Công khai",
- "Unlisted": "Không hiển thị",
+ "Unlisted": "Không công khai",
"Private": "Riêng tư",
"View all playlists": "Xem tất cả danh sách phát",
"Updated `x` ago": "Đã cập nhật` x` trước",
@@ -131,24 +131,24 @@
"Title": "Tiêu đề",
"Playlist privacy": "Bảo mật danh sách phát",
"Editing playlist `x`": "Chỉnh sửa danh sách phát` x`",
- "Show more": "Cho xem nhiều hơn",
- "Show less": "Hiện ít hơn",
+ "Show more": "Hiển thị thêm",
+ "Show less": "Hiển thị ít hơn",
"Watch on YouTube": "Xem trên YouTube",
"Switch Invidious Instance": "Chuyển phiên bản Invidious",
"Hide annotations": "Ẩn chú thích",
"Show annotations": "Hiển thị chú thích",
"Genre: ": "Thể loại: ",
"License: ": "Giấy phép: ",
- "Family friendly? ": "Gia đình thân thiện? ",
+ "Family friendly? ": "Thân thiện với gia đình? ",
"Wilson score: ": "Điểm số Wilson: ",
"Engagement: ": "Hôn ước: ",
"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: ",
+ "Blacklisted regions: ": "Các vùng nằm trong danh sách đen: ",
"Shared `x`": "Chia sẻ` x`",
- "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",
- "Incorrect password": "Mật khẩu không đúng",
+ "View Reddit comments": "Xem bình luận trên Reddit",
+ "Hide replies": "Ẩn phản hồi",
+ "Show replies": "Hiển thị phản hồi",
+ "Incorrect password": "Mật khẩu không chính xác",
"Wrong answer": "Câu trả lời sai",
"Erroneous CAPTCHA": "CAPTCHA bị lỗi",
"CAPTCHA is a required field": "CAPTCHA là trường bắt buộc",
@@ -190,35 +190,35 @@
"Bulgarian": "Tiếng Bungari",
"Burmese": "Tiếng Miến Điện",
"Catalan": "Tiếng Catalan",
- "Cebuano": "Cebuano",
+ "Cebuano": "Tiếng Cebu",
"Chinese (Simplified)": "Tiếng Trung (Giản thể)",
"Chinese (Traditional)": "Tiếng Trung (Phồn thể)",
- "Corsican": "Corsican",
+ "Corsican": "Tiếng Corse",
"Croatian": "Tiếng Croatia",
"Czech": "Tiếng Séc",
- "Danish": "Người Đan Mạch",
+ "Danish": "Tiếng Đan Mạch",
"Dutch": "Tiếng Hà Lan",
"Esperanto": "Quốc tế ngữ",
"Estonian": "Tiếng Estonia",
- "Filipino": "Filipino",
+ "Filipino": "Tiếng Philippines",
"Finnish": "Tiếng Phần Lan",
- "French": "Người Pháp",
+ "French": "Tiếng Pháp",
"Galician": "Tiếng Galicia",
"Georgian": "Tiếng Georgia",
"German": "Tiếng Đức",
- "Greek": "Người Hy Lạp",
- "Gujarati": "Gujarati",
- "Haitian Creole": "Tiếng Creole của Haiti",
- "Hausa": "Hausa",
+ "Greek": "Tiếng Hy Lạp",
+ "Gujarati": "Tiếng Gujarat",
+ "Haitian Creole": "Tiếng Creole (Haiti)",
+ "Hausa": "Tiếng Hausa",
"Hawaiian": "Tiếng Hawaii",
"Hebrew": "Tiếng Do Thái",
"Hindi": "Tiếng Hindi",
- "Hmong": "Hmong",
- "Hungarian": "Người Hungary",
+ "Hmong": "Tiếng Hmong",
+ "Hungarian": "Tiếng Hungary",
"Icelandic": "Tiếng Iceland",
- "Igbo": "Igbo",
+ "Igbo": "Tiếng Igbo",
"Indonesian": "Tiếng Indonesia",
- "Irish": "Tiếng Ailen",
+ "Irish": "Tiếng Ireland",
"Italian": "Tiếng Ý",
"Japanese": "Tiếng Nhật",
"Javanese": "Tiếng Java",
@@ -237,37 +237,37 @@
"Malagasy": "Tiếng Malagasy",
"Malay": "Tiếng Mã Lai",
"Malayalam": "Tiếng Malayalam",
- "Maltese": "Cây nho",
+ "Maltese": "Tiếng Malta",
"Maori": "Tiếng Maori",
- "Marathi": "Marathi",
+ "Marathi": "Tiếng Marathi",
"Mongolian": "Tiếng Mông Cổ",
"Nepali": "Tiếng Nepal",
- "Norwegian Bokmål": "Tiếng Na Uy Bokmål",
- "Nyanja": "Nyanja",
- "Pashto": "Pashto",
+ "Norwegian Bokmål": "Tiếng Na Uy (Bokmål)",
+ "Nyanja": "Tiếng Chewa / Nyanja",
+ "Pashto": "Tiếng Pashtun",
"Persian": "Tiếng Ba Tư",
- "Polish": "Đánh bóng",
+ "Polish": "Tiếng Ba Lan",
"Portuguese": "Tiếng Bồ Đào Nha",
- "Punjabi": "Punjabi",
+ "Punjabi": "Tiếng Punjab",
"Romanian": "Tiếng Rumani",
"Russian": "Tiếng Nga",
- "Samoan": "Samoan",
- "Scottish Gaelic": "Tiếng Gaelic Scotland",
+ "Samoan": "Tiếng Samoa",
+ "Scottish Gaelic": "Tiếng Gaelic (Scotland)",
"Serbian": "Tiếng Serbia",
- "Shona": "Shona",
- "Sindhi": "Sindhi",
- "Sinhala": "Sinhala",
+ "Shona": "Tiếng Shona",
+ "Sindhi": "Tiếng Sindh",
+ "Sinhala": "Tiếng Sinhala",
"Slovak": "Tiếng Slovak",
"Slovenian": "Tiếng Slovenia",
"Somali": "Tiếng Somali",
"Southern Sotho": "Southern Sotho",
- "Spanish": "Người Tây Ban Nha",
+ "Spanish": "Tiếng Tây Ban Nha",
"Spanish (Latin America)": "Tiếng Tây Ban Nha (Mỹ Latinh)",
"Sundanese": "Tiếng Sundan",
"Swahili": "Tiếng Swahili",
"Swedish": "Tiếng Thụy Điển",
- "Tajik": "Tajik",
- "Tamil": "Tamil",
+ "Tajik": "Tiếng Tajik",
+ "Tamil": "Tiếng Tamil",
"Telugu": "Tiếng Telugu",
"Thai": "Tiếng Thái",
"Turkish": "Tiếng Thổ Nhĩ Kỳ",
@@ -275,17 +275,17 @@
"Urdu": "Tiếng Urdu",
"Uzbek": "Tiếng Uzbek",
"Vietnamese": "Tiếng Việt",
- "Welsh": "Người xứ Wales",
- "Western Frisian": "Western Frisian",
- "Xhosa": "Xhosa",
- "Yiddish": "Yiddish",
- "Yoruba": "Yoruba",
+ "Welsh": "Tiếng Wales",
+ "Western Frisian": "Tiếng Tây Frisia",
+ "Xhosa": "Tiếng Nam Phi",
+ "Yiddish": "Tiếng Yiddish",
+ "Yoruba": "Tiếng Yoruba",
"Zulu": "Tiếng Zulu",
"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",
+ "About": "Giới thiệu",
"Rating: ": "Xếp hạng: ",
"preferences_locale_label": "Ngôn ngữ: ",
"View as playlist": "Xem dưới dạng danh sách phát",
@@ -295,45 +295,45 @@
"News": "Tin tức",
"Movies": "Phim",
"Download": "Tải xuống",
- "Download as: ": "Tải tệp dưới dạng: ",
+ "Download as: ": "Tải xuống dưới dạng: ",
"%A %B %-d, %Y": "% A% B% -d,% Y",
"(edited)": "(đã chỉnh sửa)",
"YouTube comment permalink": "Liên kết cố định nhận xét trên YouTube",
"permalink": "liên kết cố định",
"`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤",
- "Audio mode": "Chế độ âm thanh",
- "Video mode": "Chế độ quay",
+ "Audio mode": "Chế độ audio",
+ "Video mode": "Chế độ video",
"channel_tab_videos_label": "Video",
"Playlists": "Danh sách phát",
"channel_tab_community_label": "Cộng đồng",
- "search_filters_sort_option_relevance": "liên quan",
+ "search_filters_sort_option_relevance": "Liên quan",
"search_filters_sort_option_rating": "Xếp hạng",
- "search_filters_sort_option_date": "ngày",
- "search_filters_sort_option_views": "lượt xem",
- "search_filters_type_label": "content_type",
- "search_filters_duration_label": "thời lượng",
- "search_filters_features_label": "đặc trưng",
- "search_filters_sort_label": "sắp xếp",
- "search_filters_date_option_hour": "giờ",
- "search_filters_date_option_today": "hôm nay",
- "search_filters_date_option_week": "tuần",
- "search_filters_date_option_month": "tháng",
- "search_filters_date_option_year": "năm",
+ "search_filters_sort_option_date": "Ngày tải lên",
+ "search_filters_sort_option_views": "Lượt xem",
+ "search_filters_type_label": "Thể loại",
+ "search_filters_duration_label": "Thời lượng",
+ "search_filters_features_label": "Đặc điểm",
+ "search_filters_sort_label": "Sắp xếp theo",
+ "search_filters_date_option_hour": "Một giờ qua",
+ "search_filters_date_option_today": "Hôm nay",
+ "search_filters_date_option_week": "Tuần này",
+ "search_filters_date_option_month": "Tháng này",
+ "search_filters_date_option_year": "Năm này",
"search_filters_type_option_video": "video",
- "search_filters_type_option_channel": "kênh",
- "search_filters_type_option_playlist": "danh sách phát",
- "search_filters_type_option_movie": "bộ phim",
- "search_filters_type_option_show": "chỉ",
- "search_filters_features_option_hd": "hd",
- "search_filters_features_option_subtitles": "phụ đề",
- "search_filters_features_option_c_commons": "Commons sáng tạo",
- "search_filters_features_option_three_d": "3d",
- "search_filters_features_option_live": "trực tiếp",
- "search_filters_features_option_four_k": "4k",
- "search_filters_features_option_location": "vị trí",
- "search_filters_features_option_hdr": "hdr",
+ "search_filters_type_option_channel": "Kênh",
+ "search_filters_type_option_playlist": "Danh sách phát",
+ "search_filters_type_option_movie": "Phim",
+ "search_filters_type_option_show": "Hiện",
+ "search_filters_features_option_hd": "HD",
+ "search_filters_features_option_subtitles": "Phụ đề",
+ "search_filters_features_option_c_commons": "Giấy phép Creative Commons",
+ "search_filters_features_option_three_d": "3D",
+ "search_filters_features_option_live": "Trực tiếp",
+ "search_filters_features_option_four_k": "4K",
+ "search_filters_features_option_location": "Vị trí",
+ "search_filters_features_option_hdr": "HDR",
"Current version: ": "Phiên bản hiện tại: ",
- "search_filters_title": "bộ lọc",
+ "search_filters_title": "Bộ lọc",
"generic_playlists_count": "{{count}} danh sách phát",
"generic_views_count": "{{count}} lượt xem",
"View `x` comments": {
@@ -341,40 +341,40 @@
"([^.,0-9]|^)1([^.,0-9]|$)": "Hiển thị `x`bình luận"
},
"Song: ": "Ca khúc: ",
- "Premieres in `x`": "Trình chiếu lần đầu vào `x`",
- "preferences_quality_dash_option_worst": "Thấp nhất",
+ "Premieres in `x`": "Trình chiếu ở `x`",
+ "preferences_quality_dash_option_worst": "Tệ nhất",
"preferences_watch_history_label": "Bật lịch sử video đã xem ",
"preferences_quality_option_hd720": "HD720",
"unsubscribe": "hủy đăng kí",
"revoke": "gỡ bỏ",
- "preferences_quality_dash_label": "Chất lượng video DASH ưa thích ",
+ "preferences_quality_dash_label": "Chất lượng video DASH ",
"preferences_quality_dash_option_auto": "Tự động",
"Subscriptions": "Thuê bao",
- "View YouTube comments": "Hiển thị bình luận trên YouTube",
+ "View YouTube comments": "Hiển thị bình luận từ YouTube",
"View more comments on Reddit": "Hiển thị thêm bình luận từ Reddit",
"Music in this video": "Nhạc trong video này",
"Artist: ": "Nghệ sĩ: ",
"Premieres `x`": "Phát lần đầu `x`",
"preferences_region_label": "Nội dung theo quốc gia ",
"search_message_change_filters_or_query": "Thử mở rộng nội dung tìm kiếm hoặc thay đổi bộ lọc.",
- "preferences_quality_option_small": "Nhỏ",
+ "preferences_quality_option_small": "Thấp",
"preferences_quality_dash_option_144p": "144p",
"invidious": "Invidious",
"preferences_quality_dash_option_240p": "240p",
- "Import/export": "Xuất/nhập dữ liệu",
- "preferences_quality_dash_option_4320p": "4320p",
+ "Import/export": "Nhập/Xuất",
+ "preferences_quality_dash_option_4320p": "4320p (8K)",
"preferences_quality_option_dash": "DASH (tự tối ưu chất lượng)",
"generic_subscriptions_count_0": "{{count}} người đăng kí",
- "preferences_quality_dash_option_1440p": "1440p",
+ "preferences_quality_dash_option_1440p": "1440p (2K)",
"preferences_quality_dash_option_480p": "480p",
- "preferences_quality_dash_option_2160p": "2160p",
+ "preferences_quality_dash_option_2160p": "2160p (4K)",
"search_message_no_results": "Tìm kiếm không có kết quả.",
"preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_720p": "720p",
"preferences_quality_option_medium": "Trung bình",
- "Load more": "Hiển thị thêm",
+ "Load more": "Tải thêm",
"comments_points_count_0": "{{count}} điểm",
- "Import YouTube playlist (.csv)": "Nhập danh sách phát YouTube (.csv)",
+ "Import YouTube playlist (.csv)": "Nhập các danh sách phát từ YouTube (.csv)",
"preferences_quality_dash_option_best": "Tốt nhất",
"preferences_quality_dash_option_360p": "360p",
"subscriptions_unseen_notifs_count_0": "{{count}} thông báo chưa đọc",
@@ -382,10 +382,102 @@
"search_message_use_another_instance": " Bạn cũng có thể tìm kiếm <a href=\"`x`\"> ở một phiên bản khác</a>.",
"Standard YouTube license": "Giấy phép YouTube thông thường",
"Album: ": "Album: ",
- "preferences_save_player_pos_label": "Lưu vị trí xem cuối cùng ",
+ "preferences_save_player_pos_label": "Lưu vị trí xem: ",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Xin chào! Có vẻ như bạn đã tắt JavaScript. Bấm vào đây để xem bình luận, lưu ý rằng thời gian tải có thể lâu hơn.",
"Chinese (China)": "Tiếng Trung (Trung Quốc)",
"generic_button_cancel": "Hủy",
"Chinese": "Tiếng Trung",
- "generic_button_delete": "Xóa"
+ "generic_button_delete": "Xóa",
+ "Korean (auto-generated)": "Tiếng Hàn (được tạo tự động)",
+ "search_filters_features_option_three_sixty": "360°",
+ "channel_tab_podcasts_label": "Podcast",
+ "Spanish (Mexico)": "Tiếng Tây Ban Nha (Mexico)",
+ "search_filters_apply_button": "Áp dụng các mục đã chọn",
+ "Download is disabled": "Tải xuống đã bị vô hiệu hóa.",
+ "next_steps_error_message_go_to_youtube": "Đi đến YouTube",
+ "German (auto-generated)": "Tiếng Đức (được tạo tự động)",
+ "Japanese (auto-generated)": "Tiếng Nhật (được tạo tự động)",
+ "footer_donate_page": "Ủng hộ",
+ "crash_page_before_reporting": "Trước khi báo cáo lỗi, hãy chắc chắn rằng bạn đã:",
+ "Channel Sponsor": "Nhà tài trợ của kênh",
+ "videoinfo_started_streaming_x_ago": "Đã bắt đầu phát sóng `x` trước",
+ "videoinfo_youTube_embed_link": "Nhúng",
+ "channel_tab_streams_label": "Phát trực tiếp",
+ "playlist_button_add_items": "Thêm video",
+ "generic_count_minutes_0": "{{count}} phút",
+ "user_saved_playlists": "`x` danh sách phát đã lưu",
+ "Spanish (Spain)": "Tiếng Tây Ban Nha (Tây Ban Nha)",
+ "crash_page_refresh": "Đã thử <a href=\"`x`\">tải lại trang</a>",
+ "Chinese (Hong Kong)": "Tiếng Trung (Hồng Kông)",
+ "generic_count_months_0": "{{count}} tháng",
+ "download_subtitles": "Phụ đề - `x` (.vtt)",
+ "generic_button_save": "Lưu",
+ "crash_page_search_issue": "Tìm <a href=\"`x`\">lỗi có sẵn trên GitHub</a>",
+ "none": "không",
+ "English (United States)": "Tiếng Anh (Mỹ)",
+ "next_steps_error_message_refresh": "Tải lại",
+ "Video unavailable": "Video không có sẵn",
+ "footer_source_code": "Mã nguồn",
+ "search_filters_duration_option_short": "Ngắn (< 4 phút)",
+ "search_filters_duration_option_long": "Dài (> 20 phút)",
+ "tokens_count_0": "{{count}} mã thông báo",
+ "Italian (auto-generated)": "Tiếng Ý (được tạo tự động)",
+ "channel_tab_shorts_label": "Shorts",
+ "channel_tab_releases_label": "Mới tải lên",
+ "`x` ago": "`x` trước",
+ "Interlingue": "Tiếng Khoa học Quốc tế",
+ "generic_channels_count_0": "{{count}} kênh",
+ "Chinese (Taiwan)": "Tiếng Trung (Đài Loan)",
+ "adminprefs_modified_source_code_url_label": "URL tới kho lưu trữ mã nguồn đã sửa đổi",
+ "Turkish (auto-generated)": "Tiếng Thổ Nhĩ Kỳ (được tạo tự động)",
+ "Indonesian (auto-generated)": "Tiếng Indonesia (được tạo tự động)",
+ "Portuguese (auto-generated)": "Tiếng Bồ Đào Nha (được tạo tự động)",
+ "generic_count_years_0": "{{count}} năm",
+ "videoinfo_invidious_embed_link": "Liên kết nhúng",
+ "Popular enabled: ": "Đã bật phổ biến: ",
+ "Spanish (auto-generated)": "Tiếng Tây Ban Nha (được tạo tự động)",
+ "English (United Kingdom)": "Tiếng Anh Anh",
+ "channel_tab_playlists_label": "Danh sách phát",
+ "generic_button_edit": "Sửa",
+ "search_filters_features_option_purchased": "Đã mua",
+ "search_filters_date_option_none": "Mọi thời điểm",
+ "Cantonese (Hong Kong)": "Tiếng Quảng Châu (Hồng Kông)",
+ "crash_page_report_issue": "Nếu các điều trên không giúp được, xin hãy <a href=\"`x`\">tạo vấn đề mới trên GitHub</a> (ưu tiên tiếng Anh) và đính kèm đoạn chữ sau trong nội dung (giữ nguyên KHÔNG dịch):",
+ "crash_page_switch_instance": "Đã thử <a href=\"`x`\">dùng một phiên bản khác</a>",
+ "generic_count_weeks_0": "{{count}} tuần",
+ "videoinfo_watch_on_youTube": "Xem trên YouTube",
+ "footer_modfied_source_code": "Mã nguồn đã chỉnh sửa",
+ "generic_button_rss": "RSS",
+ "generic_count_hours_0": "{{count}} giờ",
+ "French (auto-generated)": "Tiếng Pháp (được tạo tự động)",
+ "crash_page_read_the_faq": "Đọc <a href=\"`x`\">Hỏi đáp thường gặp (FAQ)</a>",
+ "user_created_playlists": "`x` danh sách phát đã tạo",
+ "channel_tab_channels_label": "Kênh",
+ "search_filters_type_option_all": "Mọi thể loại",
+ "Russian (auto-generated)": "Tiếng Nga (được tạo tự động)",
+ "comments_view_x_replies_0": "Xem {{count}} lượt trả lời",
+ "footer_original_source_code": "Mã nguồn gốc",
+ "Portuguese (Brazil)": "Tiếng Bồ Đào Nha (Brazil)",
+ "search_filters_features_option_vr180": "VR180",
+ "error_video_not_in_playlist": "Video không tồn tại trong danh sách phát. <a href=\"`x`\">Bấm để trở về trang chủ của danh sách phát.</a>",
+ "Dutch (auto-generated)": "Tiếng Hà Lan (được tạo tự động)",
+ "generic_count_days_0": "{{count}} ngày",
+ "Vietnamese (auto-generated)": "Tiếng Việt (được tạo tự động)",
+ "search_filters_duration_option_none": "Mọi thời lượng",
+ "footer_documentation": "Tài liệu",
+ "next_steps_error_message": "Bạn có thể thử: ",
+ "Import YouTube watch history (.json)": "Nhập lịch sử xem từ YouTube (.json)",
+ "search_filters_duration_option_medium": "Trung bình (4 - 20 phút)",
+ "generic_count_seconds_0": "{{count}} giây",
+ "search_filters_date_label": "Ngày tải lên",
+ "crash_page_you_found_a_bug": "Có vẻ như bạn đã tìm ra lỗi trong Indivious!",
+ "Add to playlist": "Thêm vào danh sách phát",
+ "Add to playlist: ": "Thêm vào danh sách phát: ",
+ "Answer": "Trả lời",
+ "toggle_theme": "Bật/tắt diện mạo",
+ "carousel_slide": "Trang {{current}} trên tổng {{total}} trang",
+ "carousel_skip": "Bỏ qua Carousel",
+ "carousel_go_to": "Đi tới trang `x`",
+ "Search for videos": "Tìm kiếm video",
+ "The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý."
}
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 62f45a29..776c5ddb 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -26,7 +26,7 @@
"Import and Export Data": "导入与导出数据",
"Import": "导入",
"Import Invidious data": "导入 Invidious JSON 数据",
- "Import YouTube subscriptions": "导入 YouTube/OPML 订阅",
+ "Import YouTube subscriptions": "导入 YouTube CSV 或 OPML 订阅",
"Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)",
"Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)",
"Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)",
@@ -436,7 +436,7 @@
"Turkish (auto-generated)": "土耳其语 (自动生成)",
"Spanish (Spain)": "西班牙语 (西班牙)",
"preferences_watch_history_label": "启用观看历史: ",
- "search_message_use_another_instance": " 你也可以 <a href=\"`x`\">在另一实例上搜索</a>。",
+ "search_message_use_another_instance": "你也可以 <a href=\"`x`\">在另一实例上搜索</a>。",
"search_filters_title": "过滤器",
"search_filters_date_label": "上传日期",
"search_filters_apply_button": "应用所选过滤器",
@@ -461,6 +461,7 @@
"Standard YouTube license": "标准 YouTube 许可证",
"Download is disabled": "已禁用下载",
"Import YouTube playlist (.csv)": "导入 YouTube 播放列表(.csv)",
+ "Import YouTube watch history (.json)": "导入 YouTube 观看历史(.json)",
"generic_button_cancel": "取消",
"playlist_button_add_items": "添加视频",
"generic_button_delete": "删除",
@@ -468,5 +469,15 @@
"generic_button_edit": "编辑",
"generic_button_save": "保存",
"generic_button_rss": "RSS",
- "channel_tab_releases_label": "公告"
+ "channel_tab_releases_label": "公告",
+ "generic_channels_count_0": "{{count}} 个频道",
+ "toggle_theme": "切换主题",
+ "Add to playlist": "添加到播放列表",
+ "Add to playlist: ": "添加到播放列表: ",
+ "Answer": "响应",
+ "Search for videos": "搜索视频",
+ "The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
+ "carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
+ "carousel_skip": "跳过图集",
+ "carousel_go_to": "转到图 `x`"
}
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index da81922b..1e17deb6 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -26,7 +26,7 @@
"Import and Export Data": "匯入與匯出資料",
"Import": "匯入",
"Import Invidious data": "匯入 Invidious JSON 資料",
- "Import YouTube subscriptions": "匯入 YouTube/OPML 訂閱",
+ "Import YouTube subscriptions": "匯入 YouTube CSV 或 OPML 訂閱",
"Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)",
"Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)",
"Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)",
@@ -338,13 +338,13 @@
"channel_tab_community_label": "社群",
"search_filters_sort_option_relevance": "關聯",
"search_filters_sort_option_rating": "評分",
- "search_filters_sort_option_date": "日期",
+ "search_filters_sort_option_date": "上傳日期",
"search_filters_sort_option_views": "檢視",
"search_filters_type_label": "內容類型",
"search_filters_duration_label": "時長",
"search_filters_features_label": "特色",
"search_filters_sort_label": "排序",
- "search_filters_date_option_hour": "小時",
+ "search_filters_date_option_hour": "最後一小時",
"search_filters_date_option_today": "今天",
"search_filters_date_option_week": "週",
"search_filters_date_option_month": "月",
@@ -442,7 +442,7 @@
"search_filters_duration_option_none": "任何時長",
"search_filters_duration_option_medium": "中等(4到20分鐘)",
"search_filters_features_option_vr180": "VR180",
- "search_message_use_another_instance": " 您也可以<a href=\"`x`\">在其他站台上搜尋</a>。",
+ "search_message_use_another_instance": "您也可以<a href=\"`x`\">在其他站台上搜尋</a>。",
"search_filters_title": "過濾條件",
"search_filters_date_label": "上傳日期",
"search_filters_type_option_all": "任何類型",
@@ -461,6 +461,7 @@
"Standard YouTube license": "標準 YouTube 授權條款",
"Download is disabled": "已停用下載",
"Import YouTube playlist (.csv)": "匯入 YouTube 播放清單 (.csv)",
+ "Import YouTube watch history (.json)": "匯入 YouTube 觀看歷史 (.json)",
"generic_button_cancel": "取消",
"generic_button_edit": "編輯",
"generic_button_save": "儲存",
@@ -468,5 +469,15 @@
"generic_button_delete": "刪除",
"playlist_button_add_items": "新增影片",
"channel_tab_podcasts_label": "Podcast",
- "channel_tab_releases_label": "發布"
+ "channel_tab_releases_label": "發布",
+ "generic_channels_count_0": "{{count}} 個頻道",
+ "toggle_theme": "切換佈景主題",
+ "Add to playlist": "新增至播放清單",
+ "Add to playlist: ": "新增至播放清單: ",
+ "Answer": "答案",
+ "Search for videos": "搜尋影片",
+ "carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
+ "carousel_skip": "略過輪播",
+ "carousel_go_to": "跳到投影片 `x`",
+ "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
}
diff --git a/mocks b/mocks
-Subproject 11ec372f72747c09d48ffef04843f72be67d5b5
+Subproject b55d58dea94f7144ff0205857dfa70ec14eaa87
diff --git a/shard.lock b/shard.lock
index efb60a59..a097b081 100644
--- a/shard.lock
+++ b/shard.lock
@@ -2,7 +2,7 @@ version: 2.0
shards:
ameba:
git: https://github.com/crystal-ameba/ameba.git
- version: 1.5.0
+ version: 1.6.1
athena-negotiation:
git: https://github.com/athena-framework/negotiation.git
@@ -10,16 +10,20 @@ shards:
backtracer:
git: https://github.com/sija/backtracer.cr.git
- version: 1.2.1
+ version: 1.2.2
db:
git: https://github.com/crystal-lang/crystal-db.git
- version: 0.10.1
+ version: 0.13.1
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.2.2
+ http_proxy:
+ git: https://github.com/mamantoha/http_proxy.git
+ version: 0.10.3
+
kemal:
git: https://github.com/kemalcr/kemal.git
version: 1.1.2
@@ -30,7 +34,7 @@ shards:
pg:
git: https://github.com/will/crystal-pg.git
- version: 0.24.0
+ version: 0.28.0
protodec:
git: https://github.com/iv-org/protodec.git
@@ -42,9 +46,9 @@ shards:
spectator:
git: https://github.com/icy-arctic-fox/spectator.git
- version: 0.10.4
+ version: 0.10.6
sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
- version: 0.18.0
+ version: 0.21.0
diff --git a/shard.yml b/shard.yml
index be06a7df..af7e4186 100644
--- a/shard.yml
+++ b/shard.yml
@@ -1,21 +1,20 @@
name: invidious
-version: 0.20.1
+version: 2.20241110.0-dev
authors:
- - Omar Roth <omarroth@protonmail.com>
- - Invidious team
+ - Invidious team <contact@invidious.io>
+ - Contributors!
-targets:
- invidious:
- main: src/invidious.cr
+description: |
+ Invidious is an alternative front-end to YouTube
dependencies:
pg:
github: will/crystal-pg
- version: ~> 0.24.0
+ version: ~> 0.28.0
sqlite3:
github: crystal-lang/crystal-sqlite3
- version: ~> 0.18.0
+ version: ~> 0.21.0
kemal:
github: kemalcr/kemal
version: ~> 1.1.2
@@ -28,6 +27,9 @@ dependencies:
athena-negotiation:
github: athena-framework/negotiation
version: ~> 0.1.1
+ http_proxy:
+ github: mamantoha/http_proxy
+ version: ~> 0.10.3
development_dependencies:
spectator:
@@ -35,8 +37,12 @@ development_dependencies:
version: ~> 0.10.4
ameba:
github: crystal-ameba/ameba
- version: ~> 1.5.0
+ version: ~> 1.6.1
-crystal: ">= 1.0.0, < 2.0.0"
+crystal: ">= 1.10.0, < 2.0.0"
license: AGPLv3
+
+repository: https://github.com/iv-org/invidious
+homepage: https://invidious.io
+documentation: https://docs.invidious.io
diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr
new file mode 100644
index 00000000..dc1f4613
--- /dev/null
+++ b/spec/helpers/vtt/builder_spec.cr
@@ -0,0 +1,87 @@
+require "../../spec_helper.cr"
+
+MockLines = ["Line 1", "Line 2"]
+MockLinesWithEscapableCharacter = ["<Line 1>", "&Line 2>", '\u200E' + "Line\u200F 3", "\u00A0Line 4"]
+
+Spectator.describe "WebVTT::Builder" do
+ it "correctly builds a vtt file" do
+ result = WebVTT.build do |vtt|
+ 2.times do |i|
+ vtt.cue(
+ Time::Span.new(seconds: i),
+ Time::Span.new(seconds: i + 1),
+ MockLines[i]
+ )
+ end
+ end
+
+ expect(result).to eq([
+ "WEBVTT",
+ "",
+ "00:00:00.000 --> 00:00:01.000",
+ "Line 1",
+ "",
+ "00:00:01.000 --> 00:00:02.000",
+ "Line 2",
+ "",
+ "",
+ ].join('\n'))
+ end
+
+ it "correctly builds a vtt file with setting fields" do
+ setting_fields = {
+ "Kind" => "captions",
+ "Language" => "en",
+ }
+
+ result = WebVTT.build(setting_fields) do |vtt|
+ 2.times do |i|
+ vtt.cue(
+ Time::Span.new(seconds: i),
+ Time::Span.new(seconds: i + 1),
+ MockLines[i]
+ )
+ end
+ end
+
+ expect(result).to eq([
+ "WEBVTT",
+ "Kind: captions",
+ "Language: en",
+ "",
+ "00:00:00.000 --> 00:00:01.000",
+ "Line 1",
+ "",
+ "00:00:01.000 --> 00:00:02.000",
+ "Line 2",
+ "",
+ "",
+ ].join('\n'))
+ end
+
+ it "properly escapes characters" do
+ result = WebVTT.build do |vtt|
+ 4.times do |i|
+ vtt.cue(Time::Span.new(seconds: i), Time::Span.new(seconds: i + 1), MockLinesWithEscapableCharacter[i])
+ end
+ end
+
+ expect(result).to eq([
+ "WEBVTT",
+ "",
+ "00:00:00.000 --> 00:00:01.000",
+ "&lt;Line 1&gt;",
+ "",
+ "00:00:01.000 --> 00:00:02.000",
+ "&amp;Line 2&gt;",
+ "",
+ "00:00:02.000 --> 00:00:03.000",
+ "&lrm;Line&rlm; 3",
+ "",
+ "00:00:03.000 --> 00:00:04.000",
+ "&nbsp;Line 4",
+ "",
+ "",
+ ].join('\n'))
+ end
+end
diff --git a/spec/i18next_plurals_spec.cr b/spec/i18next_plurals_spec.cr
index ee9ff394..dcd0f5ec 100644
--- a/spec/i18next_plurals_spec.cr
+++ b/spec/i18next_plurals_spec.cr
@@ -15,12 +15,15 @@ FORM_TESTS = {
"ar" => I18next::Plurals::PluralForms::Special_Arabic,
"be" => I18next::Plurals::PluralForms::Dual_Slavic,
"cy" => I18next::Plurals::PluralForms::Special_Welsh,
+ "fr" => I18next::Plurals::PluralForms::Special_French_Portuguese,
"en" => I18next::Plurals::PluralForms::Single_not_one,
- "fr" => I18next::Plurals::PluralForms::Single_gt_one,
+ "es" => I18next::Plurals::PluralForms::Special_Spanish_Italian,
"ga" => I18next::Plurals::PluralForms::Special_Irish,
"gd" => I18next::Plurals::PluralForms::Special_Scottish_Gaelic,
"he" => I18next::Plurals::PluralForms::Special_Hebrew,
+ "hr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian,
"is" => I18next::Plurals::PluralForms::Special_Icelandic,
+ "it" => I18next::Plurals::PluralForms::Special_Spanish_Italian,
"jv" => I18next::Plurals::PluralForms::Special_Javanese,
"kw" => I18next::Plurals::PluralForms::Special_Cornish,
"lt" => I18next::Plurals::PluralForms::Special_Lithuanian,
@@ -30,13 +33,14 @@ FORM_TESTS = {
"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,
+ "pt" => I18next::Plurals::PluralForms::Special_French_Portuguese,
+ "pt-PT" => I18next::Plurals::PluralForms::Single_gt_one,
+ "pt-BR" => I18next::Plurals::PluralForms::Special_French_Portuguese,
"ro" => I18next::Plurals::PluralForms::Special_Romanian,
- "su" => I18next::Plurals::PluralForms::None,
"sk" => I18next::Plurals::PluralForms::Special_Czech_Slovak,
"sl" => I18next::Plurals::PluralForms::Special_Slovenian,
+ "su" => I18next::Plurals::PluralForms::None,
+ "sr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian,
}
SUFFIX_TESTS = {
@@ -73,10 +77,18 @@ SUFFIX_TESTS = {
{num: 1, suffix: ""},
{num: 10, suffix: "_plural"},
],
+ "es" => [
+ {num: 0, suffix: "_2"},
+ {num: 1, suffix: "_0"},
+ {num: 10, suffix: "_2"},
+ {num: 6_000_000, suffix: "_1"},
+ ],
"fr" => [
- {num: 0, suffix: ""},
- {num: 1, suffix: ""},
- {num: 10, suffix: "_plural"},
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_0"},
+ {num: 10, suffix: "_2"},
+ {num: 4_000_000, suffix: "_1"},
+ {num: 6_260_000, suffix: "_2"},
],
"ga" => [
{num: 1, suffix: "_0"},
@@ -155,31 +167,24 @@ SUFFIX_TESTS = {
{num: 1, suffix: "_0"},
{num: 5, suffix: "_2"},
],
- "pt" => [
- {num: 0, suffix: ""},
- {num: 1, suffix: ""},
- {num: 10, suffix: "_plural"},
+ "pt-BR" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_0"},
+ {num: 10, suffix: "_2"},
+ {num: 42, suffix: "_2"},
+ {num: 9_000_000, suffix: "_1"},
],
"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"},
+ {num: 9_000_000, 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"},
@@ -191,6 +196,18 @@ SUFFIX_TESTS = {
{num: 2, suffix: "_2"},
{num: 3, suffix: "_3"},
],
+ "su" => [
+ {num: 0, suffix: "_0"},
+ {num: 1, suffix: "_0"},
+ {num: 10, suffix: "_0"},
+ ],
+ "sr" => [
+ {num: 1, suffix: "_0"},
+ {num: 51, suffix: "_0"},
+ {num: 32, suffix: "_1"},
+ {num: 100, suffix: "_2"},
+ {num: 100_000, suffix: "_2"},
+ ],
}
Spectator.describe "i18next_Plural_Resolver" do
diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/hashtag_spec.cr
index 266ec57b..abc81225 100644
--- a/spec/invidious/hashtag_spec.cr
+++ b/spec/invidious/hashtag_spec.cr
@@ -27,8 +27,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32)
expect(video_11.views).to eq(40_504_893)
- expect(video_11.live_now).to be_false
- expect(video_11.premium).to be_false
+ expect(video_11.badges.live_now?).to be_false
+ expect(video_11.badges.premium?).to be_false
expect(video_11.premiere_timestamp).to be_nil
#
@@ -49,8 +49,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32)
expect(video_35.views).to eq(30_790_049)
- expect(video_35.live_now).to be_false
- expect(video_35.premium).to be_false
+ expect(video_35.badges.live_now?).to be_false
+ expect(video_35.badges.premium?).to be_false
expect(video_35.premiere_timestamp).to be_nil
end
@@ -80,8 +80,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32)
expect(video_41.views).to eq(63_240)
- expect(video_41.live_now).to be_false
- expect(video_41.premium).to be_false
+ expect(video_41.badges.live_now?).to be_false
+ expect(video_41.badges.premium?).to be_false
expect(video_41.premiere_timestamp).to be_nil
#
@@ -102,8 +102,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32)
expect(video_48.views).to eq(68_704)
- expect(video_48.live_now).to be_false
- expect(video_48.premium).to be_false
+ expect(video_48.badges.live_now?).to be_false
+ expect(video_48.badges.premium?).to be_false
expect(video_48.premiere_timestamp).to be_nil
end
end
diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr
index 142e1653..9fbb6d6f 100644
--- a/spec/invidious/helpers_spec.cr
+++ b/spec/invidious/helpers_spec.cr
@@ -3,18 +3,6 @@ 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")
diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr
index b0897a63..3cefafa1 100644
--- a/spec/invidious/search/iv_filters_spec.cr
+++ b/spec/invidious/search/iv_filters_spec.cr
@@ -301,7 +301,6 @@ Spectator.describe Invidious::Search::Filters do
it "Encodes features filter (single)" do
Invidious::Search::Filters::Features.each do |value|
- string = described_class.format_features(value)
filters = described_class.new(features: value)
expect("#{filters.to_iv_params}")
diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr
index bf7f21e7..8abed5ce 100644
--- a/spec/invidious/search/yt_filters_spec.cr
+++ b/spec/invidious/search/yt_filters_spec.cr
@@ -12,45 +12,45 @@ end
# page of Youtube with any browser devtools HTML inspector.
DATE_FILTERS = {
- Invidious::Search::Filters::Date::Hour => "EgIIAQ%3D%3D",
- Invidious::Search::Filters::Date::Today => "EgIIAg%3D%3D",
- Invidious::Search::Filters::Date::Week => "EgIIAw%3D%3D",
- Invidious::Search::Filters::Date::Month => "EgIIBA%3D%3D",
- Invidious::Search::Filters::Date::Year => "EgIIBQ%3D%3D",
+ Invidious::Search::Filters::Date::Hour => "EgIIAfABAQ%3D%3D",
+ Invidious::Search::Filters::Date::Today => "EgIIAvABAQ%3D%3D",
+ Invidious::Search::Filters::Date::Week => "EgIIA_ABAQ%3D%3D",
+ Invidious::Search::Filters::Date::Month => "EgIIBPABAQ%3D%3D",
+ Invidious::Search::Filters::Date::Year => "EgIIBfABAQ%3D%3D",
}
TYPE_FILTERS = {
- Invidious::Search::Filters::Type::Video => "EgIQAQ%3D%3D",
- Invidious::Search::Filters::Type::Channel => "EgIQAg%3D%3D",
- Invidious::Search::Filters::Type::Playlist => "EgIQAw%3D%3D",
- Invidious::Search::Filters::Type::Movie => "EgIQBA%3D%3D",
+ Invidious::Search::Filters::Type::Video => "EgIQAfABAQ%3D%3D",
+ Invidious::Search::Filters::Type::Channel => "EgIQAvABAQ%3D%3D",
+ Invidious::Search::Filters::Type::Playlist => "EgIQA_ABAQ%3D%3D",
+ Invidious::Search::Filters::Type::Movie => "EgIQBPABAQ%3D%3D",
}
DURATION_FILTERS = {
- Invidious::Search::Filters::Duration::Short => "EgIYAQ%3D%3D",
- Invidious::Search::Filters::Duration::Medium => "EgIYAw%3D%3D",
- Invidious::Search::Filters::Duration::Long => "EgIYAg%3D%3D",
+ Invidious::Search::Filters::Duration::Short => "EgIYAfABAQ%3D%3D",
+ Invidious::Search::Filters::Duration::Medium => "EgIYA_ABAQ%3D%3D",
+ Invidious::Search::Filters::Duration::Long => "EgIYAvABAQ%3D%3D",
}
FEATURE_FILTERS = {
- Invidious::Search::Filters::Features::Live => "EgJAAQ%3D%3D",
- Invidious::Search::Filters::Features::FourK => "EgJwAQ%3D%3D",
- Invidious::Search::Filters::Features::HD => "EgIgAQ%3D%3D",
- Invidious::Search::Filters::Features::Subtitles => "EgIoAQ%3D%3D",
- Invidious::Search::Filters::Features::CCommons => "EgIwAQ%3D%3D",
- Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AQ%3D%3D",
- Invidious::Search::Filters::Features::VR180 => "EgPQAQE%3D",
- Invidious::Search::Filters::Features::ThreeD => "EgI4AQ%3D%3D",
- Invidious::Search::Filters::Features::HDR => "EgPIAQE%3D",
- Invidious::Search::Filters::Features::Location => "EgO4AQE%3D",
- Invidious::Search::Filters::Features::Purchased => "EgJIAQ%3D%3D",
+ Invidious::Search::Filters::Features::Live => "EgJAAfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::FourK => "EgJwAfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::HD => "EgIgAfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::Subtitles => "EgIoAfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::CCommons => "EgIwAfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::VR180 => "EgPQAQHwAQE%3D",
+ Invidious::Search::Filters::Features::ThreeD => "EgI4AfABAQ%3D%3D",
+ Invidious::Search::Filters::Features::HDR => "EgPIAQHwAQE%3D",
+ Invidious::Search::Filters::Features::Location => "EgO4AQHwAQE%3D",
+ Invidious::Search::Filters::Features::Purchased => "EgJIAfABAQ%3D%3D",
}
SORT_FILTERS = {
- Invidious::Search::Filters::Sort::Relevance => "",
- Invidious::Search::Filters::Sort::Date => "CAI%3D",
- Invidious::Search::Filters::Sort::Views => "CAM%3D",
- Invidious::Search::Filters::Sort::Rating => "CAE%3D",
+ Invidious::Search::Filters::Sort::Relevance => "8AEB",
+ Invidious::Search::Filters::Sort::Date => "CALwAQE%3D",
+ Invidious::Search::Filters::Sort::Views => "CAPwAQE%3D",
+ Invidious::Search::Filters::Sort::Rating => "CAHwAQE%3D",
}
Spectator.describe Invidious::Search::Filters do
diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr
index a6a3e60a..f96703f6 100644
--- a/spec/invidious/videos/regular_videos_extract_spec.cr
+++ b/spec/invidious/videos/regular_videos_extract_spec.cr
@@ -17,8 +17,8 @@ Spectator.describe "parse_video_info" do
# Basic video infos
expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island")
- expect(info["views"].as_i).to eq(126_573_823)
- expect(info["likes"].as_i).to eq(5_157_654)
+ expect(info["views"].as_i).to eq(220_226_287)
+ expect(info["likes"].as_i).to eq(6_870_691)
# For some reason the video length from VideoDetails and the
# one from microformat differs by 1s...
@@ -48,12 +48,12 @@ Spectator.describe "parse_video_info" do
expect(info["relatedVideos"].as_a.size).to eq(20)
- expect(info["relatedVideos"][0]["id"]).to eq("Hwybp38GnZw")
- expect(info["relatedVideos"][0]["title"]).to eq("I Built Willy Wonka's Chocolate Factory!")
+ expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4")
+ expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!")
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
- expect(info["relatedVideos"][0]["view_count"]).to eq("179877630")
- expect(info["relatedVideos"][0]["short_view_count"]).to eq("179M")
+ expect(info["relatedVideos"][0]["view_count"]).to eq("230617484")
+ expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M")
expect(info["relatedVideos"][0]["author_verified"]).to eq("true")
# Description
@@ -67,7 +67,7 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
- expect(info["genreUcid"].as_s).to be_empty
+ expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos
@@ -76,11 +76,11 @@ Spectator.describe "parse_video_info" do
expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
expect(info["authorThumbnail"].as_s).to eq(
- "https://yt3.ggpht.com/ytc/AL5GRJVuqw82ERvHzsmBxL7avr1dpBtsVIXcEzBPZaloFg=s48-c-k-c0x00ffffff-no-rj"
+ "https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_true
- expect(info["subCountText"].as_s).to eq("143M")
+ expect(info["subCountText"].as_s).to eq("320M")
end
it "parses a regular video with no descrition/comments" do
@@ -99,8 +99,8 @@ Spectator.describe "parse_video_info" do
# Basic video infos
expect(info["title"].as_s).to eq("Chris Rea - Auberge")
- expect(info["views"].as_i).to eq(10_943_126)
- expect(info["likes"].as_i).to eq(0)
+ expect(info["views"].as_i).to eq(14_324_584)
+ expect(info["likes"].as_i).to eq(35_870)
expect(info["lengthSeconds"].as_i).to eq(283_i64)
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
@@ -132,14 +132,14 @@ Spectator.describe "parse_video_info" do
# Related videos
- expect(info["relatedVideos"].as_a.size).to eq(19)
+ expect(info["relatedVideos"].as_a.size).to eq(20)
- expect(info["relatedVideos"][0]["id"]).to eq("Ww3KeZ2_Yv4")
- expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea")
- expect(info["relatedVideos"][0]["author"]).to eq("PanMusic")
- expect(info["relatedVideos"][0]["ucid"]).to eq("UCsKAPSuh1iNbLWUga_igPyA")
- expect(info["relatedVideos"][0]["view_count"]).to eq("31581")
- expect(info["relatedVideos"][0]["short_view_count"]).to eq("31K")
+ expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4")
+ expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version")
+ expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH")
+ expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ")
+ expect(info["relatedVideos"][0]["view_count"]).to eq("53298661")
+ expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M")
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
# Description
@@ -151,16 +151,18 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Music")
- expect(info["genreUcid"].as_s).to be_empty
+ expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos
- expect(info["author"].as_s).to eq("ChrisReaOfficial")
+ expect(info["author"].as_s).to eq("ChrisReaVideos")
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
- expect(info["authorThumbnail"].as_s).to be_empty
+ expect(info["authorThumbnail"].as_s).to eq(
+ "https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj"
+ )
expect(info["authorVerified"].as_bool).to be_false
- expect(info["subCountText"].as_s).to eq("-")
+ expect(info["subCountText"].as_s).to eq("3.11K")
end
end
diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr
index 25e08c51..c3a9b228 100644
--- a/spec/invidious/videos/scheduled_live_extract_spec.cr
+++ b/spec/invidious/videos/scheduled_live_extract_spec.cr
@@ -94,7 +94,7 @@ Spectator.describe "parse_video_info" do
# Video metadata
expect(info["genre"].as_s).to eq("Entertainment")
- expect(info["genreUcid"].as_s).to be_empty
+ expect(info["genreUcid"].as_s?).to be_nil
expect(info["license"].as_s).to be_empty
# Author infos
diff --git a/src/invidious.cr b/src/invidious.cr
index e0bd0101..b422dcbb 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -23,6 +23,7 @@ require "kilt"
require "./ext/kemal_content_for.cr"
require "./ext/kemal_static_file_handler.cr"
+require "http_proxy"
require "athena-negotiation"
require "openssl/hmac"
require "option_parser"
@@ -92,6 +93,10 @@ SOFTWARE = {
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
+# Image request pool
+
+GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
+
# CLI
Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]"
@@ -117,6 +122,9 @@ Kemal.config.extra_options do |parser|
parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level|
CONFIG.log_level = LogLevel.parse(log_level)
end
+ parser.on("-k", "--colorize", "Colorize logs") do
+ CONFIG.colorize_logs = true
+ end
parser.on("-v", "--version", "Print version") do
puts SOFTWARE.to_pretty_json
exit
@@ -133,7 +141,7 @@ if CONFIG.output.upcase != "STDOUT"
FileUtils.mkdir_p(File.dirname(CONFIG.output))
end
OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a")
-LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level)
+LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs)
# Check table integrity
Invidious::Database.check_integrity(CONFIG)
@@ -153,6 +161,15 @@ Invidious::Database.check_integrity(CONFIG)
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %}
+# Misc
+
+DECRYPT_FUNCTION =
+ if sig_helper_address = CONFIG.signature_server.presence
+ IV::DecryptFunction.new(sig_helper_address)
+ else
+ nil
+ end
+
# Start jobs
if CONFIG.channel_threads > 0
@@ -163,11 +180,6 @@ if CONFIG.feed_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB)
end
-DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling)
-if CONFIG.decrypt_polling
- Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new
-end
-
if CONFIG.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE)
end
@@ -185,6 +197,8 @@ Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
+Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
+
Invidious::Jobs.start_all
def popular_videos
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
index 0054f8f2..13909527 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -14,12 +14,14 @@ record AboutChannel,
is_family_friendly : Bool,
allowed_regions : Array(String),
tabs : Array(String),
- verified : Bool
+ tags : Array(String),
+ verified : Bool,
+ is_age_gated : Bool
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==")
+ # Fetch channel information from channel home page
+ initdata = YoutubeAPI.browse(browse_id: ucid, params: "")
rescue
raise InfoException.new("Could not get channel info.")
end
@@ -43,36 +45,102 @@ def get_about_info(ucid, locale) : AboutChannel
auto_generated = true
end
- if auto_generated
- author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
- author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
- author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
-
- # Raises a KeyError on failure.
- banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
+ tags = [] of String
+ tab_names = [] of String
+ total_views = 0_i64
+ joined = Time.unix(0)
- description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
+ if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
+ description_node = nil
+ author = age_gate_renderer["channelTitle"].as_s
+ ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
+ author_url = "https://www.youtube.com/channel/#{ucid}"
+ author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
+ banner = nil
+ is_family_friendly = false
+ is_age_gated = true
+ tab_names = ["videos", "shorts", "streams"]
+ auto_generated = false
else
- author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
- author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
- author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
- author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
+ if auto_generated
+ author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
+ author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
+ author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
+
+ # Raises a KeyError on failure.
+ banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
+
+ description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
+ # some channels have the description in a simpleText
+ # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
+ description_node = description_base_node.dig?("simpleText") || description_base_node
+
+ tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
+ .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
+ else
+ author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
+ author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
+ author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
+ author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
- ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
+ ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
- # Raises a KeyError on failure.
- banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
+ # Raises a KeyError on failure.
+ banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
+ banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
+ banner = banners.try &.[-1]?.try &.["url"].as_s?
- # if banner.includes? "channels/c4/default_banner"
- # banner = nil
- # end
+ # if banner.includes? "channels/c4/default_banner"
+ # banner = nil
+ # end
- description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
- end
+ description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
+ tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
+ end
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+ is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+ if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
+ # Get the name of the tabs available on this channel
+ tab_names = tabs_json.as_a.compact_map do |entry|
+ name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
+
+ # This is a small fix to not add extra code on the HTML side
+ # I.e, the URL for the "live" tab is .../streams, so use "streams"
+ # everywhere for the sake of simplicity
+ (name == "live") ? "streams" : name
+ end
+
+ # Get the currently active tab ("About")
+ about_tab = extract_selected_tab(tabs_json)
+
+ # Try to find the about metadata section
+ channel_about_meta = about_tab.dig?(
+ "content",
+ "sectionListRenderer", "contents", 0,
+ "itemSectionRenderer", "contents", 0,
+ "channelAboutFullMetadataRenderer"
+ )
+
+ if !channel_about_meta.nil?
+ total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
+
+ # The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
+ joined = extract_text(channel_about_meta["joinedDateText"]?)
+ .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
+
+ # Normal Auto-generated channels
+ # https://support.google.com/youtube/answer/2579942
+ # For auto-generated channels, channel_about_meta only has
+ # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
+ auto_generated = (
+ (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
+ extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
+ channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
+ )
+ end
+ end
+ end
allowed_regions = initdata
.dig?("microformat", "microformatDataRenderer", "availableCountries")
@@ -91,55 +159,18 @@ def get_about_info(ucid, locale) : AboutChannel
end
end
- total_views = 0_i64
- joined = Time.unix(0)
-
- tab_names = [] of String
-
- if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
- # Get the name of the tabs available on this channel
- tab_names = tabs_json.as_a.compact_map do |entry|
- name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
+ sub_count = 0
- # This is a small fix to not add extra code on the HTML side
- # I.e, the URL for the "live" tab is .../streams, so use "streams"
- # everywhere for the sake of simplicity
- (name == "live") ? "streams" : name
- end
-
- # Get the currently active tab ("About")
- about_tab = extract_selected_tab(tabs_json)
-
- # Try to find the about metadata section
- channel_about_meta = about_tab.dig?(
- "content",
- "sectionListRenderer", "contents", 0,
- "itemSectionRenderer", "contents", 0,
- "channelAboutFullMetadataRenderer"
- )
-
- if !channel_about_meta.nil?
- total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
-
- # The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
- joined = extract_text(channel_about_meta["joinedDateText"]?)
- .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
-
- # Normal Auto-generated channels
- # https://support.google.com/youtube/answer/2579942
- # For auto-generated channels, channel_about_meta only has
- # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
- auto_generated = (
- (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
- extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube"
- )
+ if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
+ metadata_rows.each do |row|
+ metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") }
+ if !metadata_part.nil?
+ sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32
+ end
+ break if sub_count != 0
end
end
- sub_count = initdata
- .dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s?
- .try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0
-
AboutChannel.new(
ucid: ucid,
author: author,
@@ -155,7 +186,9 @@ def get_about_info(ucid, locale) : AboutChannel
is_family_friendly: is_family_friendly,
allowed_regions: allowed_regions,
tabs: tab_names,
+ tags: tags,
verified: author_verified || false,
+ is_age_gated: is_age_gated || false,
)
end
diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr
index c3d6124f..1478c8fc 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -93,7 +93,7 @@ struct ChannelVideo
def to_tuple
{% begin %}
{
- {{*@type.instance_vars.map(&.name)}}
+ {{@type.instance_vars.map(&.name).splat}}
}
{% end %}
end
@@ -223,7 +223,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
length_seconds = channel_video.try &.length_seconds
length_seconds ||= 0
- live_now = channel_video.try &.live_now
+ live_now = channel_video.try &.badges.live_now?
live_now ||= false
premiere_timestamp = channel_video.try &.premiere_timestamp
@@ -232,7 +232,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
id: video_id,
title: title,
published: published,
- updated: Time.utc,
+ updated: updated,
ucid: ucid,
author: author,
length_seconds: length_seconds,
@@ -275,7 +275,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
ucid: video.ucid,
author: video.author,
length_seconds: video.length_seconds,
- live_now: video.live_now,
+ live_now: video.badges.live_now?,
premiere_timestamp: video.premiere_timestamp,
views: video.views,
})
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 791f1641..49ffd990 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -24,7 +24,33 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode)
end
-def extract_channel_community(items, *, ucid, locale, format, thin_mode)
+def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
+ object = {
+ "2:string" => "community",
+ "25:embedded" => {
+ "22:string" => post_id.to_s,
+ },
+ "45:embedded" => {
+ "2:varint" => 1_i64,
+ "3:varint" => 1_i64,
+ },
+ }
+ 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) }
+
+ initial_data = YoutubeAPI.browse(ucid, params: params)
+
+ items = [] of JSON::Any
+ extract_items(initial_data) do |item|
+ items << item
+ end
+
+ return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true)
+end
+
+def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false)
if message = items[0]["messageRenderer"]?
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
@@ -39,6 +65,9 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode)
response = JSON.build do |json|
json.object do
json.field "authorId", ucid
+ if is_single_post
+ json.field "singlePost", true
+ end
json.field "comments" do
json.array do
items.each do |post|
@@ -240,8 +269,10 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode)
end
end
end
- if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
- json.field "continuation", extract_channel_community_cursor(cont.as_s)
+ if !is_single_post
+ if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token")
+ json.field "continuation", extract_channel_community_cursor(cont.as_s)
+ end
end
end
end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index beb86e08..96400f47 100644
--- a/src/invidious/channels/videos.cr
+++ b/src/invidious/channels/videos.cr
@@ -1,73 +1,3 @@
-def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
- object_inner_2 = {
- "2:0:embedded" => {
- "1:0:varint" => 0_i64,
- },
- "5:varint" => 50_i64,
- "6:varint" => 1_i64,
- "7:varint" => (page * 30).to_i64,
- "9:varint" => 1_i64,
- "10:varint" => 0_i64,
- }
-
- object_inner_2_encoded = object_inner_2
- .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) }
-
- sort_by_numerical =
- case sort_by
- when "newest" then 1_i64
- when "popular" then 2_i64
- when "oldest" then 4_i64
- else 1_i64 # Fallback to "newest"
- end
-
- object_inner_1 = {
- "110:embedded" => {
- "3:embedded" => {
- "15:embedded" => {
- "1:embedded" => {
- "1:string" => object_inner_2_encoded,
- },
- "2:embedded" => {
- "1:string" => "00000000-0000-0000-0000-000000000000",
- },
- "3:varint" => sort_by_numerical,
- },
- },
- },
- }
-
- object_inner_1_encoded = object_inner_1
- .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" => ucid,
- "3:string" => object_inner_1_encoded,
- "35:string" => "browse-feed#{ucid}videos102",
- },
- }
-
- 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) }
-
- return continuation
-end
-
-# Used in bypass_captcha_job.cr
-def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
- continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2)
- return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
-end
-
module Invidious::Channel::Tabs
extend self
@@ -75,10 +5,6 @@ module Invidious::Channel::Tabs
# Regular videos
# -------------------
- def make_initial_video_ctoken(ucid, sort_by) : String
- return produce_channel_videos_continuation(ucid, sort_by: sort_by)
- end
-
# Wrapper for AboutChannel, as we still need to call get_videos with
# an author name and ucid directly (e.g in RSS feeds).
# TODO: figure out how to get rid of that
@@ -100,7 +26,7 @@ module Invidious::Channel::Tabs
end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
- continuation ||= make_initial_video_ctoken(ucid, sort_by)
+ continuation ||= make_initial_videos_ctoken(ucid, sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid)
@@ -129,14 +55,10 @@ module Invidious::Channel::Tabs
# Shorts
# -------------------
- def get_shorts(channel : AboutChannel, continuation : String? = nil)
- if continuation.nil?
- # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts"
- # TODO: try to extract the continuation tokens that allows other sorting options
- initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")
- else
- initial_data = YoutubeAPI.browse(continuation: continuation)
- end
+ def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
+ continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by)
+ initial_data = YoutubeAPI.browse(continuation: continuation)
+
return extract_items(initial_data, channel.author, channel.ucid)
end
@@ -144,21 +66,17 @@ module Invidious::Channel::Tabs
# Livestreams
# -------------------
- def get_livestreams(channel : AboutChannel, continuation : String? = nil)
- if continuation.nil?
- # EgdzdHJlYW1z8gYECgJ6AA%3D%3D is the protobuf object to load "streams"
- initial_data = YoutubeAPI.browse(channel.ucid, params: "EgdzdHJlYW1z8gYECgJ6AA%3D%3D")
- else
- initial_data = YoutubeAPI.browse(continuation: continuation)
- end
+ def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
+ continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by)
+ initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, channel.author, channel.ucid)
end
- def get_60_livestreams(channel : AboutChannel, continuation : String? = nil)
+ def get_60_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
if continuation.nil?
- # Fetch the first "page" of streams
- items, next_continuation = get_livestreams(channel)
+ # Fetch the first "page" of stream
+ items, next_continuation = get_livestreams(channel, sort_by: sort_by)
else
# Fetch a "page" of streams using the given continuation token
items, next_continuation = get_livestreams(channel, continuation: continuation)
@@ -173,4 +91,102 @@ module Invidious::Channel::Tabs
return items, next_continuation
end
+
+ # -------------------
+ # C-tokens
+ # -------------------
+
+ private def sort_options_videos_short(sort_by : String)
+ case sort_by
+ when "newest" then return 4_i64
+ when "popular" then return 2_i64
+ when "oldest" then return 5_i64
+ else return 4_i64 # Fallback to "newest"
+ end
+ end
+
+ # Generate the initial "continuation token" to get the first page of the
+ # "videos" tab. The following page requires the ctoken provided in that
+ # first page, and so on.
+ private def make_initial_videos_ctoken(ucid : String, sort_by = "newest")
+ object = {
+ "15:embedded" => {
+ "2:embedded" => {
+ "1:string" => "00000000-0000-0000-0000-000000000000",
+ },
+ "4:varint" => sort_options_videos_short(sort_by),
+ },
+ }
+
+ return channel_ctoken_wrap(ucid, object)
+ end
+
+ # Generate the initial "continuation token" to get the first page of the
+ # "shorts" tab. The following page requires the ctoken provided in that
+ # first page, and so on.
+ private def make_initial_shorts_ctoken(ucid : String, sort_by = "newest")
+ object = {
+ "10:embedded" => {
+ "2:embedded" => {
+ "1:string" => "00000000-0000-0000-0000-000000000000",
+ },
+ "4:varint" => sort_options_videos_short(sort_by),
+ },
+ }
+
+ return channel_ctoken_wrap(ucid, object)
+ end
+
+ # Generate the initial "continuation token" to get the first page of the
+ # "livestreams" tab. The following page requires the ctoken provided in that
+ # first page, and so on.
+ private def make_initial_livestreams_ctoken(ucid : String, sort_by = "newest")
+ sort_by_numerical =
+ case sort_by
+ when "newest" then 12_i64
+ when "popular" then 14_i64
+ when "oldest" then 13_i64
+ else 12_i64 # Fallback to "newest"
+ end
+
+ object = {
+ "14:embedded" => {
+ "2:embedded" => {
+ "1:string" => "00000000-0000-0000-0000-000000000000",
+ },
+ "5:varint" => sort_by_numerical,
+ },
+ }
+
+ return channel_ctoken_wrap(ucid, object)
+ end
+
+ # The protobuf structure common between videos/shorts/livestreams
+ private def channel_ctoken_wrap(ucid : String, object)
+ object_inner = {
+ "110:embedded" => {
+ "3:embedded" => object,
+ },
+ }
+
+ object_inner_encoded = object_inner
+ .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" => ucid,
+ "3:string" => object_inner_encoded,
+ },
+ }
+
+ 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) }
+
+ return continuation
+ end
end
diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr
index c8cdc2df..1f55bfe6 100644
--- a/src/invidious/comments/content.cr
+++ b/src/invidious/comments/content.cr
@@ -5,35 +5,35 @@ def text_to_parsed_content(text : String) : JSON::Any
# In first case line is just a simple node before
# check patterns inside line
# { 'text': line }
- currentNodes = [] of JSON::Any
- initialNode = {"text" => line}
- currentNodes << (JSON.parse(initialNode.to_json))
+ current_nodes = [] of JSON::Any
+ initial_node = {"text" => line}
+ current_nodes << (JSON.parse(initial_node.to_json))
# For each match with url pattern, get last node and preserve
# last node before create new node with url information
# { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } }
- line.scan(/https?:\/\/[^ ]*/).each do |urlMatch|
+ line.scan(/https?:\/\/[^ ]*/).each do |url_match|
# Retrieve last node and update node without match
- lastNode = currentNodes[currentNodes.size - 1].as_h
- splittedLastNode = lastNode["text"].as_s.split(urlMatch[0])
- lastNode["text"] = JSON.parse(splittedLastNode[0].to_json)
- currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json)
+ last_node = current_nodes[-1].as_h
+ splitted_last_node = last_node["text"].as_s.split(url_match[0])
+ last_node["text"] = JSON.parse(splitted_last_node[0].to_json)
+ current_nodes[-1] = JSON.parse(last_node.to_json)
# Create new node with match and navigation infos
- currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}}
- currentNodes << (JSON.parse(currentNode.to_json))
+ current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}}
+ current_nodes << (JSON.parse(current_node.to_json))
# If text remain after match create new simple node with text after match
- afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""}
- currentNodes << (JSON.parse(afterNode.to_json))
+ after_node = {"text" => splitted_last_node.size > 1 ? splitted_last_node[1] : ""}
+ current_nodes << (JSON.parse(after_node.to_json))
end
# After processing of matches inside line
# Add \n at end of last node for preserve carriage return
- lastNode = currentNodes[currentNodes.size - 1].as_h
- lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json)
- currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json)
+ last_node = current_nodes[-1].as_h
+ last_node["text"] = JSON.parse("#{last_node["text"]}\n".to_json)
+ current_nodes[-1] = JSON.parse(last_node.to_json)
# Finally add final nodes to nodes returned
- currentNodes.each do |node|
+ current_nodes.each do |node|
nodes << (node)
end
end
@@ -53,8 +53,8 @@ def content_to_comment_html(content, video_id : String? = "")
text = HTML.escape(run["text"].as_s)
- if navigationEndpoint = run.dig?("navigationEndpoint")
- text = parse_link_endpoint(navigationEndpoint, text, video_id)
+ if navigation_endpoint = run.dig?("navigationEndpoint")
+ text = parse_link_endpoint(navigation_endpoint, text, video_id)
end
text = "<b>#{text}</b>" if run["bold"]?
@@ -64,15 +64,15 @@ def content_to_comment_html(content, video_id : String? = "")
# check for custom emojis
if run["emoji"]?
if run["emoji"]["isCustomEmoji"]?.try &.as_bool
- if emojiImage = run.dig?("emoji", "image")
- emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text
- emojiThumb = emojiImage["thumbnails"][0]
+ if emoji_image = run.dig?("emoji", "image")
+ emoji_alt = emoji_image.dig?("accessibility", "accessibilityData", "label").try &.as_s || text
+ emoji_thumb = emoji_image["thumbnails"][0]
text = String.build do |str|
- str << %(<img alt=") << emojiAlt << "\" "
- str << %(src="/ggpht) << URI.parse(emojiThumb["url"].as_s).request_target << "\" "
- str << %(title=") << emojiAlt << "\" "
- str << %(width=") << emojiThumb["width"] << "\" "
- str << %(height=") << emojiThumb["height"] << "\" "
+ str << %(<img alt=") << emoji_alt << "\" "
+ str << %(src="/ggpht) << URI.parse(emoji_thumb["url"].as_s).request_target << "\" "
+ str << %(title=") << emoji_alt << "\" "
+ str << %(width=") << emoji_thumb["width"] << "\" "
+ str << %(height=") << emoji_thumb["height"] << "\" "
str << %(class="channel-emoji" />)
end
else
diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr
index 1ba1b534..0716fcde 100644
--- a/src/invidious/comments/youtube.cr
+++ b/src/invidious/comments/youtube.cr
@@ -13,6 +13,51 @@ module Invidious::Comments
client_config = YoutubeAPI::ClientConfig.new(region: region)
response = YoutubeAPI.next(continuation: ctoken, client_config: client_config)
+ return parse_youtube(id, response, format, locale, thin_mode, sort_by)
+ end
+
+ def fetch_community_post_comments(ucid, post_id)
+ object = {
+ "2:string" => "community",
+ "25:embedded" => {
+ "22:string" => post_id,
+ },
+ "45:embedded" => {
+ "2:varint" => 1_i64,
+ "3:varint" => 1_i64,
+ },
+ "53:embedded" => {
+ "4:embedded" => {
+ "6:varint" => 0_i64,
+ "27:varint" => 1_i64,
+ "29:string" => post_id,
+ "30:string" => ucid,
+ },
+ "8:string" => "comments-section",
+ },
+ }
+
+ object_parsed = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+
+ object2 = {
+ "80226972:embedded" => {
+ "2:string" => ucid,
+ "3:string" => object_parsed,
+ },
+ }
+
+ continuation = object2.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) }
+
+ initial_data = YoutubeAPI.browse(continuation: continuation)
+ return initial_data
+ end
+
+ def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", is_post = false)
contents = nil
if on_response_received_endpoints = response["onResponseReceivedEndpoints"]?
@@ -59,6 +104,8 @@ module Invidious::Comments
end
end
+ mutations = response.dig?("frameworkUpdates", "entityBatchUpdate", "mutations").try &.as_a || [] of JSON::Any
+
response = JSON.build do |json|
json.object do
if header
@@ -68,7 +115,11 @@ module Invidious::Comments
json.field "commentCount", comment_count
end
- json.field "videoId", id
+ if is_post
+ json.field "postId", id
+ else
+ json.field "videoId", id
+ end
json.field "comments" do
json.array do
@@ -82,73 +133,138 @@ module Invidious::Comments
node_replies = node["replies"]["commentRepliesRenderer"]
end
- if node["comment"]?
- node_comment = node["comment"]["commentRenderer"]
- else
- node_comment = node["commentRenderer"]
- end
+ if cvm = node["commentViewModel"]?
+ # two commentViewModels for inital request
+ # one commentViewModel when getting a replies to a comment
+ cvm = cvm["commentViewModel"] if cvm["commentViewModel"]?
+
+ comment_key = cvm["commentKey"]
+ toolbar_key = cvm["toolbarStateKey"]
+ comment_mutation = mutations.find { |i| i.dig?("payload", "commentEntityPayload", "key") == comment_key }
+ toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key }
+
+ if !comment_mutation.nil? && !toolbar_mutation.nil?
+ # todo parse styleRuns, commandRuns and attachmentRuns for comments
+ html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id)
+ comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author")
+ json.field "authorId", comment_author["channelId"].as_s
+ json.field "authorUrl", "/channel/#{comment_author["channelId"].as_s}"
+ json.field "author", comment_author["displayName"].as_s
+ json.field "verified", comment_author["isVerified"].as_bool
+ json.field "authorThumbnails" do
+ json.array do
+ comment_mutation.dig?("payload", "commentEntityPayload", "avatar", "image", "sources").try &.as_a.each do |thumbnail|
+ json.object do
+ json.field "url", thumbnail["url"]
+ json.field "width", thumbnail["width"]
+ json.field "height", thumbnail["height"]
+ end
+ end
+ end
+ end
- content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || ""
- author = node_comment["authorText"]?.try &.["simpleText"]? || ""
+ json.field "authorIsChannelOwner", comment_author["isCreator"].as_bool
+ json.field "isSponsor", (comment_author["sponsorBadgeUrl"]? != nil)
- json.field "verified", (node_comment["authorCommentBadge"]? != nil)
+ if sponsor_badge_url = comment_author["sponsorBadgeUrl"]?
+ # Sponsor icon thumbnails always have one object and there's only ever the url property in it
+ json.field "sponsorIconUrl", sponsor_badge_url
+ end
- json.field "author", author
- json.field "authorThumbnails" do
- json.array do
- node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
- json.object do
- json.field "url", thumbnail["url"]
- json.field "width", thumbnail["width"]
- json.field "height", thumbnail["height"]
+ comment_toolbar = comment_mutation.dig("payload", "commentEntityPayload", "toolbar")
+ json.field "likeCount", short_text_to_number(comment_toolbar["likeCountNotliked"].as_s)
+ reply_count = short_text_to_number(comment_toolbar["replyCount"]?.try &.as_s || "0")
+
+ if heart_state = toolbar_mutation.dig?("payload", "engagementToolbarStateEntityPayload", "heartState")
+ if heart_state.as_s == "TOOLBAR_HEART_STATE_HEARTED"
+ json.field "creatorHeart" do
+ json.object do
+ json.field "creatorThumbnail", comment_toolbar["creatorThumbnailUrl"].as_s
+ json.field "creatorName", comment_toolbar["heartActiveTooltip"].as_s.sub("❤ by ", "")
+ end
+ end
end
end
+
+ published_text = comment_mutation.dig?("payload", "commentEntityPayload", "properties", "publishedTime").try &.as_s
end
- end
- if node_comment["authorEndpoint"]?
- json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
- json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
+ json.field "isPinned", (cvm.dig?("pinnedText") != nil)
+ json.field "commentId", cvm["commentId"]
else
- json.field "authorId", ""
- json.field "authorUrl", ""
- end
+ if node["comment"]?
+ node_comment = node["comment"]["commentRenderer"]
+ else
+ node_comment = node["commentRenderer"]
+ end
+ json.field "commentId", node_comment["commentId"]
+ html_content = node_comment["contentText"]?.try { |t| parse_content(t, id) }
+
+ json.field "verified", (node_comment["authorCommentBadge"]? != nil)
+
+ json.field "author", node_comment["authorText"]?.try &.["simpleText"]? || ""
+ json.field "authorThumbnails" do
+ json.array do
+ node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
+ json.object do
+ json.field "url", thumbnail["url"]
+ json.field "width", thumbnail["width"]
+ json.field "height", thumbnail["height"]
+ end
+ end
+ end
+ end
- published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s
- published = decode_date(published_text.rchop(" (edited)"))
+ if comment_action_buttons_renderer = node_comment.dig?("actionButtons", "commentActionButtonsRenderer")
+ json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
+ if comment_action_buttons_renderer["creatorHeart"]?
+ heart_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
+ json.field "creatorHeart" do
+ json.object do
+ json.field "creatorThumbnail", heart_data["thumbnails"][-1]["url"]
+ json.field "creatorName", heart_data["accessibility"]["accessibilityData"]["label"]
+ end
+ end
+ end
+ end
- if published_text.includes?(" (edited)")
- json.field "isEdited", true
- else
- json.field "isEdited", false
- end
+ if node_comment["authorEndpoint"]?
+ json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
+ json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
+ else
+ json.field "authorId", ""
+ json.field "authorUrl", ""
+ end
- json.field "content", html_to_content(content_html)
- json.field "contentHtml", content_html
+ json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
+ json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil)
+ published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s
- json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil)
- json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil)
- if node_comment["sponsorCommentBadge"]?
- # Sponsor icon thumbnails always have one object and there's only ever the url property in it
- json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s
- end
- json.field "published", published.to_unix
- json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
+ json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil)
+ if node_comment["sponsorCommentBadge"]?
+ # Sponsor icon thumbnails always have one object and there's only ever the url property in it
+ json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s
+ end
- comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"]
+ reply_count = node_comment["replyCount"]?
+ end
- json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
- json.field "commentId", node_comment["commentId"]
- json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
+ content_html = html_content || ""
+ json.field "content", html_to_content(content_html)
+ json.field "contentHtml", content_html
- if comment_action_buttons_renderer["creatorHeart"]?
- hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
- json.field "creatorHeart" do
- json.object do
- json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"]
- json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"]
- end
+ if published_text != nil
+ published_text = published_text.to_s
+ if published_text.includes?(" (edited)")
+ json.field "isEdited", true
+ published = decode_date(published_text.rchop(" (edited)"))
+ else
+ json.field "isEdited", false
+ published = decode_date(published_text)
end
+
+ json.field "published", published.to_unix
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
end
if node_replies && !response["commentRepliesContinuation"]?
@@ -161,7 +277,7 @@ module Invidious::Comments
json.field "replies" do
json.object do
- json.field "replyCount", node_comment["replyCount"]? || 1
+ json.field "replyCount", reply_count || 1
json.field "continuation", continuation
end
end
@@ -187,7 +303,6 @@ module Invidious::Comments
if format == "html"
response = JSON.parse(response)
content_html = Frontend::Comments.template_youtube(response, locale, thin_mode)
-
response = JSON.build do |json|
json.object do
json.field "contentHtml", content_html
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index 429d9246..4b3bdafc 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -13,6 +13,7 @@ struct ConfigPreferences
property annotations : Bool = false
property annotations_subscribed : Bool = false
+ property preload : Bool = true
property autoplay : Bool = false
property captions : Array(String) = ["", "", ""]
property comments : Array(String) = ["youtube", ""]
@@ -48,12 +49,21 @@ struct ConfigPreferences
def to_tuple
{% begin %}
{
- {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}}
+ {{(@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }).splat}}
}
{% end %}
end
end
+struct HTTPProxyConfig
+ include YAML::Serializable
+
+ property user : String
+ property password : String
+ property host : String
+ property port : Int32
+end
+
class Config
include YAML::Serializable
@@ -68,14 +78,14 @@ class Config
property output : String = "STDOUT"
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
property log_level : LogLevel = LogLevel::Info
+ # Enables colors in logs. Useful for debugging purposes
+ property colorize_logs : Bool = false
# Database configuration with separate parameters (username, hostname, etc)
property db : DBConfig? = nil
# Database configuration using 12-Factor "Database URL" syntax
@[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("")
- # Use polling to keep decryption function up to date
- property decrypt_polling : Bool = false
# Used for crawling channels: threads should check all videos uploaded by a channel
property full_refresh : Bool = false
@@ -120,23 +130,30 @@ class Config
# Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
@[YAML::Field(converter: Preferences::FamilyConverter)]
property force_resolve : Socket::Family = Socket::Family::UNSPEC
+
+ # External signature solver server socket (either a path to a UNIX domain socket or "<IP>:<Port>")
+ property signature_server : String? = nil
+
# Port to listen for connections (overridden by command line argument)
property port : Int32 = 3000
# Host to bind (overridden by command line argument)
property host_binding : String = "0.0.0.0"
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
property pool_size : Int32 = 100
+ # HTTP Proxy configuration
+ property http_proxy : HTTPProxyConfig? = nil
# Use Innertube's transcripts API instead of timedtext for closed captions
property use_innertube_for_captions : Bool = false
+ # visitor data ID for Google session
+ property visitor_data : String? = nil
+ # poToken for passing bot attestation
+ property po_token : String? = nil
+
# Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new
- # Key for Anti-Captcha
- property captcha_key : String? = nil
- # API URL for Anti-Captcha
- property captcha_api_url : String = "https://api.anti-captcha.com"
# Playlist length limit
property playlist_length_limit : Int32 = 500
@@ -167,6 +184,9 @@ class Config
config = Config.from_yaml(config_yaml)
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
+ #
+ # Also checks if any top-level config options are set to "CHANGE_ME!!"
+ # TODO: Support non-top-level config options such as the ones in DBConfig
{% for ivar in Config.instance_vars %}
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
@@ -203,6 +223,12 @@ class Config
exit(1)
end
end
+
+ # Warn when any config attribute is set to "CHANGE_ME!!"
+ if config.{{ivar.id}} == "CHANGE_ME!!"
+ puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
+ exit(1)
+ end
{% end %}
# HMAC_key is mandatory
@@ -210,9 +236,6 @@ class Config
if config.hmac_key.empty?
puts "Config: 'hmac_key' is required/can't be empty"
exit(1)
- elsif config.hmac_key == "CHANGE_ME!!"
- puts "Config: The value of 'hmac_key' needs to be changed!!"
- exit(1)
end
# Build database_url from db.* if it's not set directly
diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr
index c6754a1e..08aa719a 100644
--- a/src/invidious/database/playlists.cr
+++ b/src/invidious/database/playlists.cr
@@ -140,6 +140,7 @@ module Invidious::Database::Playlists
request = <<-SQL
SELECT id,title FROM playlists
WHERE author = $1 AND id LIKE 'IV%'
+ ORDER BY title
SQL
PG_DB.query_all(request, email, as: {String, String})
diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr
index 1df549e2..9e4963fd 100644
--- a/src/invidious/database/statistics.cr
+++ b/src/invidious/database/statistics.cr
@@ -15,7 +15,7 @@ module Invidious::Database::Statistics
PG_DB.query_one(request, as: Int64)
end
- def count_users_active_1m : Int64
+ def count_users_active_6m : Int64
request = <<-SQL
SELECT count(*) FROM users
WHERE CURRENT_TIMESTAMP - updated < '6 months'
@@ -24,7 +24,7 @@ module Invidious::Database::Statistics
PG_DB.query_one(request, as: Int64)
end
- def count_users_active_6m : Int64
+ def count_users_active_1m : Int64
request = <<-SQL
SELECT count(*) FROM users
WHERE CURRENT_TIMESTAMP - updated < '1 month'
diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr
index b5647bae..4dda683e 100644
--- a/src/invidious/frontend/comments_reddit.cr
+++ b/src/invidious/frontend/comments_reddit.cr
@@ -33,7 +33,7 @@ module Invidious::Frontend::Comments
<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_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>
+ <span title="#{child.created_utc.to_s("%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>
<div>
diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr
index 41f43f04..a0e1d783 100644
--- a/src/invidious/frontend/comments_youtube.cr
+++ b/src/invidious/frontend/comments_youtube.cr
@@ -23,6 +23,24 @@ module Invidious::Frontend::Comments
</div>
</div>
END_HTML
+ elsif comments["authorId"]? && !comments["singlePost"]?
+ # for posts we should display a link to the post
+ replies_count_text = translate_count(locale,
+ "comments_view_x_replies",
+ child["replyCount"].as_i64 || 0,
+ NumberFormatting::Separator
+ )
+
+ replies_html = <<-END_HTML
+ <div class="pure-g">
+ <div class="pure-u-1-24"></div>
+ <div class="pure-u-23-24">
+ <p>
+ <a href="/post/#{child["commentId"]}?ucid=#{comments["authorId"]}">#{replies_count_text}</a>
+ </p>
+ </div>
+ </div>
+ END_HTML
end
if !thin_mode
@@ -89,6 +107,36 @@ module Invidious::Frontend::Comments
</div>
END_HTML
end
+ when "multiImage"
+ html << <<-END_HTML
+ <section class="carousel">
+ <a class="skip-link" href="#skip-#{child["commentId"]}">#{translate(locale, "carousel_skip")}</a>
+ <div class="slides">
+ END_HTML
+ image_array = attachment["images"].as_a
+
+ image_array.each_index do |i|
+ html << <<-END_HTML
+ <div class="slides-item slide-#{i + 1}" id="#{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_slide", {"current" => (i + 1).to_s, "total" => image_array.size.to_s})}" tabindex="0">
+ <img loading="lazy" src="/ggpht#{URI.parse(image_array[i][1]["url"].as_s).request_target}" alt="" />
+ </div>
+ END_HTML
+ end
+
+ html << <<-END_HTML
+ </div>
+ <div class="carousel__nav">
+ END_HTML
+ attachment["images"].as_a.each_index do |i|
+ html << <<-END_HTML
+ <a class="slider-nav" href="##{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_go_to", (i + 1).to_s)}" tabindex="-1" aria-hidden="true">#{i + 1}</a>
+ END_HTML
+ end
+ html << <<-END_HTML
+ </div>
+ <div id="skip-#{child["commentId"]}"></div>
+ </section>
+ END_HTML
else nil # Ignore
end
end
@@ -101,12 +149,12 @@ module Invidious::Frontend::Comments
if comments["videoId"]?
html << <<-END_HTML
- <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
+ <a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
- <a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
+ <a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end
diff --git a/src/invidious/frontend/misc.cr b/src/invidious/frontend/misc.cr
index 43ba9f5c..7a6cf79d 100644
--- a/src/invidious/frontend/misc.cr
+++ b/src/invidious/frontend/misc.cr
@@ -6,9 +6,9 @@ module Invidious::Frontend::Misc
if prefs.automatic_instance_redirect
current_page = env.get?("current_page").as(String)
- redirect_url = "/redirect?referer=#{current_page}"
+ return "/redirect?referer=#{current_page}"
else
- redirect_url = "https://redirect.invidious.io#{env.request.resource}"
+ return "https://redirect.invidious.io#{env.request.resource}"
end
end
end
diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr
index 5fd81168..2e2f6ad0 100644
--- a/src/invidious/frontend/watch_page.cr
+++ b/src/invidious/frontend/watch_page.cr
@@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
@full_videos,
@video_streams,
@audio_streams,
- @captions
+ @captions,
)
end
end
@@ -42,8 +42,7 @@ module Invidious::Frontend::WatchPage
str << translate(locale, "Download as: ")
str << "</label>\n"
- # TODO: remove inline style
- str << "\t\t<select style=\"width:100%\" name='download_widget' id='download_widget'>\n"
+ str << "\t\t<select name='download_widget' id='download_widget'>\n"
# Non-DASH videos (audio+video)
diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr
index bf56d826..fec3f62c 100644
--- a/src/invidious/helpers/crystal_class_overrides.cr
+++ b/src/invidious/helpers/crystal_class_overrides.cr
@@ -3,9 +3,9 @@
# IPv6 addresses.
#
class TCPSocket
- def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC)
+ def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
- super(addrinfo.family, addrinfo.type, addrinfo.protocol)
+ super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking)
connect(addrinfo, timeout: connect_timeout) do |error|
close
error
@@ -26,7 +26,7 @@ class HTTP::Client
end
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
- io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family
+ io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family
io.read_timeout = @read_timeout if @read_timeout
io.write_timeout = @write_timeout if @write_timeout
io.sync = false
@@ -35,7 +35,7 @@ class HTTP::Client
if tls = @tls
tcp_socket = io
begin
- io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host)
+ io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.'))
rescue exc
# don't leak the TCP socket when the SSL connection failed
tcp_socket.close
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
index 6e5a975d..900cb0c6 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -3,7 +3,7 @@
# -------------------
macro error_template(*args)
- error_template_helper(env, {{*args}})
+ error_template_helper(env, {{args.splat}})
end
def github_details(summary : String, content : String)
@@ -43,6 +43,8 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
# URLs for the error message below
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
url_search_issues = "https://github.com/iv-org/invidious/issues"
+ url_search_issues += "?q=is:issue+is:open+"
+ url_search_issues += URI.encode_www_form("[Bug] #{issue_title}")
url_switch = "https://redirect.invidious.io" + env.request.resource
@@ -95,7 +97,7 @@ end
# -------------------
macro error_atom(*args)
- error_atom_helper(env, {{*args}})
+ error_atom_helper(env, {{args.splat}})
end
def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
@@ -121,14 +123,14 @@ end
# -------------------
macro error_json(*args)
- error_json_helper(env, {{*args}})
+ error_json_helper(env, {{args.splat}})
end
def error_json_helper(
env : HTTP::Server::Context,
status_code : Int32,
exception : Exception,
- additional_fields : Hash(String, Object) | Nil = nil
+ additional_fields : Hash(String, Object) | Nil = nil,
)
if exception.is_a?(InfoException)
return error_json_helper(env, status_code, exception.message || "", additional_fields)
@@ -150,7 +152,7 @@ def error_json_helper(
env : HTTP::Server::Context,
status_code : Int32,
message : String,
- additional_fields : Hash(String, Object) | Nil = nil
+ additional_fields : Hash(String, Object) | Nil = nil,
)
env.response.content_type = "application/json"
env.response.status_code = status_code
@@ -190,7 +192,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
<a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a>
</li>
<li>
- <a href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
+ <a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
</li>
</ul>
END_HTML
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index d140a858..13ea9fe9 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -27,6 +27,7 @@ class Kemal::RouteHandler
# Processes the route if it's a match. Otherwise renders 404.
private def process_request(context)
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
+ return if context.response.closed?
content = context.route.handler.call(context)
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
@@ -97,7 +98,7 @@ 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, nil)
+ scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil)
if email = Invidious::Database::SessionIDs.select_email(session)
user = Invidious::Database::Users.select!(email: email)
@@ -142,63 +143,8 @@ class APIHandler < Kemal::Handler
exclude ["/api/v1/auth/notifications"], "POST"
def call(env)
- return call_next env unless only_match? env
-
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- # Since /api/v1/notifications is an event-stream, we don't want
- # to wrap the response
- return call_next env if exclude_match? env
-
- # Here we swap out the socket IO so we can modify the response as needed
- output = env.response.output
- env.response.output = IO::Memory.new
-
- begin
- call_next env
-
- env.response.output.rewind
-
- if env.response.output.as(IO::Memory).size != 0 &&
- env.response.headers.includes_word?("Content-Type", "application/json")
- response = JSON.parse(env.response.output)
-
- if fields_text = env.params.query["fields"]?
- begin
- JSONFilter.filter(response, fields_text)
- rescue ex
- env.response.status_code = 400
- response = {"error" => ex.message}
- end
- end
-
- if env.params.query["pretty"]?.try &.== "1"
- response = response.to_pretty_json
- else
- response = response.to_json
- end
- else
- response = env.response.output.gets_to_end
- end
- rescue ex
- env.response.content_type = "application/json" if env.response.headers.includes_word?("Content-Type", "text/html")
- env.response.status_code = 500
-
- if env.response.headers.includes_word?("Content-Type", "application/json")
- response = {"error" => ex.message || "Unspecified error"}
-
- if env.params.query["pretty"]?.try &.== "1"
- response = response.to_pretty_json
- else
- response = response.to_json
- end
- end
- ensure
- env.response.output = output
- env.response.print response
-
- env.response.flush
- end
+ env.response.headers["Access-Control-Allow-Origin"] = "*" if only_match?(env)
+ call_next env
end
end
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index 23ff0da9..6add0237 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -78,15 +78,6 @@ def create_notification_stream(env, topics, connection_channel)
video.published = published
response = JSON.parse(video.to_json(locale, nil))
- if fields_text = env.params.query["fields"]?
- begin
- JSONFilter.filter(response, fields_text)
- rescue ex
- env.response.status_code = 400
- response = {"error" => ex.message}
- end
- end
-
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
@@ -113,15 +104,6 @@ def create_notification_stream(env, topics, connection_channel)
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"]?
- begin
- JSONFilter.filter(response, fields_text)
- rescue ex
- env.response.status_code = 400
- response = {"error" => ex.message}
- end
- end
-
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
@@ -155,15 +137,6 @@ def create_notification_stream(env, topics, connection_channel)
video.published = Time.unix(published)
response = JSON.parse(video.to_json(locale, nil))
- if fields_text = env.params.query["fields"]?
- begin
- JSONFilter.filter(response, fields_text)
- rescue ex
- env.response.status_code = 400
- response = {"error" => ex.message}
- end
- end
-
env.response.puts "id: #{id}"
env.response.puts "data: #{response.to_json}"
env.response.puts
@@ -208,3 +181,20 @@ def proxy_file(response, env)
IO.copy response.body_io, env.response
end
end
+
+# Fetch the playback requests tracker from the statistics endpoint.
+#
+# Creates a new tracker when unavailable.
+def get_playback_statistic
+ if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty?
+ tracker = {
+ "totalRequests" => 0_i64,
+ "successfulRequests" => 0_i64,
+ "ratio" => 0_f64,
+ }
+
+ Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker
+ end
+
+ return tracker.as(Hash(String, Int64 | Float64))
+end
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index 76e477a4..1ba3ea61 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -1,8 +1,22 @@
+# Languages requiring a better level of translation (at least 20%)
+# to be added to the list below:
+#
+# "af" => "", # Afrikaans
+# "az" => "", # Azerbaijani
+# "be" => "", # Belarusian
+# "bn_BD" => "", # Bengali (Bangladesh)
+# "ia" => "", # Interlingua
+# "or" => "", # Odia
+# "tk" => "", # Turkmen
+# "tok => "", # Toki Pona
+#
LOCALES_LIST = {
"ar" => "العربية", # Arabic
+ "bg" => "български", # Bulgarian
"bn" => "বাংলা", # Bengali
"ca" => "Català", # Catalan
"cs" => "Čeština", # Czech
+ "cy" => "Cymraeg", # Welsh
"da" => "Dansk", # Danish
"de" => "Deutsch", # German
"el" => "Ελληνικά", # Greek
@@ -23,6 +37,7 @@ LOCALES_LIST = {
"it" => "Italiano", # Italian
"ja" => "日本語", # Japanese
"ko" => "한국어", # Korean
+ "lmo" => "Lombard", # Lombard
"lt" => "Lietuvių", # Lithuanian
"nb-NO" => "Norsk bokmål", # Norwegian Bokmål
"nl" => "Nederlands", # Dutch
@@ -78,7 +93,7 @@ def load_all_locales
return locales
end
-def translate(locale : String?, key : String, text : String | Nil = nil) : String
+def translate(locale : String?, key : String, text : String | Hash(String, 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)
@@ -101,10 +116,12 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin
match_length = 0
raw_data.as_h.each do |hash_key, value|
- if md = text.try &.match(/#{hash_key}/)
- if md[0].size >= match_length
- translation = value.as_s
- match_length = md[0].size
+ if text.is_a?(String)
+ if md = text.try &.match(/#{hash_key}/)
+ if md[0].size >= match_length
+ translation = value.as_s
+ match_length = md[0].size
+ end
end
end
end
@@ -114,8 +131,13 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin
raise "Invalid translation \"#{raw_data}\""
end
- if text
+ if text.is_a?(String)
translation = translation.gsub("`x`", text)
+ elsif text.is_a?(Hash(String, String))
+ # adds support for multi string interpolation. Based on i18next https://www.i18next.com/translation-function/interpolation#basic
+ text.each_key do |hash_key|
+ translation = translation.gsub("{{#{hash_key}}}", text[hash_key])
+ end
end
return translation
diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr
index e84f88fb..684e6d14 100644
--- a/src/invidious/helpers/i18next.cr
+++ b/src/invidious/helpers/i18next.cr
@@ -35,27 +35,35 @@ module I18next::Plurals
Special_Slovenian = 21
Special_Hebrew = 22
Special_Odia = 23
+
+ # Mixed v3/v4 rules in Weblate
+ # `es`, `pt` and `pt-PT` doesn't seem to have been refreshed
+ # by weblate yet, but I suspect it will happen one day.
+ # See: https://github.com/translate/translate/issues/4873
+ Special_French_Portuguese
+ Special_Hungarian_Serbian
+ Special_Spanish_Italian
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",
+ "ach", "ak", "am", "arn", "br", "fa", "fil", "gun", "ln", "mfe", "mg",
+ "mi", "oc", "pt-PT", "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",
+ "eo", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi",
+ "hu", "hy", "ia", "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",
+ "ps", "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",
+ "ay", "bo", "cgg", "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",
+ "be", "bs", "cnr", "dz", "ru", "uk",
],
}
@@ -81,6 +89,13 @@ module I18next::Plurals
"ro" => PluralForms::Special_Romanian,
"sk" => PluralForms::Special_Czech_Slovak,
"sl" => PluralForms::Special_Slovenian,
+ # Mixed v3/v4 rules
+ "es" => PluralForms::Special_Spanish_Italian,
+ "fr" => PluralForms::Special_French_Portuguese,
+ "hr" => PluralForms::Special_Hungarian_Serbian,
+ "it" => PluralForms::Special_Spanish_Italian,
+ "pt" => PluralForms::Special_French_Portuguese,
+ "sr" => PluralForms::Special_Hungarian_Serbian,
}
# These are the v1 and v2 compatible suffixes.
@@ -150,9 +165,8 @@ module I18next::Plurals
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)$/)
+ # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code
+ if !locale.matches?(/^pt-PT$/)
locale = locale.split('-')[0]
end
@@ -174,7 +188,7 @@ module I18next::Plurals
# Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check
# from original i18next code
- private def is_simple_plural(form : PluralForms) : Bool
+ private def simple_plural?(form : PluralForms) : Bool
case form
when .single_gt_one? then return true
when .single_not_one? then return true
@@ -196,7 +210,7 @@ module I18next::Plurals
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)
+ if @simplify_plural_suffix && simple_plural?(plural_form)
return (idx == 1) ? "_plural" : ""
end
@@ -246,6 +260,10 @@ module I18next::Plurals
when .special_slovenian? then return special_slovenian(count)
when .special_hebrew? then return special_hebrew(count)
when .special_odia? then return special_odia(count)
+ # Mixed v3/v4 forms
+ when .special_spanish_italian? then return special_cldr_spanish_italian(count)
+ when .special_french_portuguese? then return special_cldr_french_portuguese(count)
+ when .special_hungarian_serbian? then return special_cldr_hungarian_serbian(count)
else
# default, if nothing matched above
return 0_u8
@@ -507,5 +525,42 @@ module I18next::Plurals
def self.special_odia(count : Int) : UInt8
return (count == 1) ? 0_u8 : 1_u8
end
+
+ # -------------------
+ # "v3.5" rules
+ # -------------------
+
+ # Plural form for Spanish & Italian languages
+ #
+ # This rule is mostly compliant to CLDR v42
+ #
+ def self.special_cldr_spanish_italian(count : Int) : UInt8
+ return 0_u8 if (count == 1) # one
+ return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many
+ return 2_u8 # other
+ end
+
+ # Plural form for French and Portuguese
+ #
+ # This rule is mostly compliant to CLDR v42
+ #
+ def self.special_cldr_french_portuguese(count : Int) : UInt8
+ return 0_u8 if (count == 0 || count == 1) # one
+ return 1_u8 if (count % 1_000_000 == 0) # many
+ return 2_u8 # other
+ end
+
+ # Plural form for Hungarian and Serbian
+ #
+ # This rule is mostly compliant to CLDR v42
+ #
+ def self.special_cldr_hungarian_serbian(count : Int) : UInt8
+ n_mod_10 = count % 10
+ n_mod_100 = count % 100
+
+ return 0_u8 if (n_mod_10 == 1 && n_mod_100 != 11) # one
+ return 1_u8 if (2 <= n_mod_10 <= 4 && (n_mod_100 < 12 || 14 < n_mod_100)) # few
+ return 2_u8 # other
+ end
end
end
diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr
deleted file mode 100644
index 3f4080ba..00000000
--- a/src/invidious/helpers/json_filter.cr
+++ /dev/null
@@ -1,248 +0,0 @@
-module JSONFilter
- alias BracketIndex = Hash(Int64, Int64)
-
- alias GroupedFieldsValue = String | Array(GroupedFieldsValue)
- alias GroupedFieldsList = Array(GroupedFieldsValue)
-
- class FieldsParser
- class ParseError < Exception
- end
-
- # Returns the `Regex` pattern used to match nest groups
- def self.nest_group_pattern : Regex
- # uses a '.' character to match json keys as they are allowed
- # to contain any unicode codepoint
- /(?:|,)(?<groupname>[^,\n]*?)\(/
- end
-
- # Returns the `Regex` pattern used to check if there are any empty nest groups
- def self.unnamed_nest_group_pattern : Regex
- /^\(|\(\(|\/\(/
- end
-
- def self.parse_fields(fields_text : String, &) : Nil
- if fields_text.empty?
- raise FieldsParser::ParseError.new "Fields is empty"
- end
-
- opening_bracket_count = fields_text.count('(')
- closing_bracket_count = fields_text.count(')')
-
- if opening_bracket_count != closing_bracket_count
- bracket_type = opening_bracket_count > closing_bracket_count ? "opening" : "closing"
- raise FieldsParser::ParseError.new "There are too many #{bracket_type} brackets (#{opening_bracket_count}:#{closing_bracket_count})"
- elsif match_result = unnamed_nest_group_pattern.match(fields_text)
- raise FieldsParser::ParseError.new "Unnamed nest group at position #{match_result.begin}"
- end
-
- # first, handle top-level single nested properties: items/id, playlistItems/snippet, etc
- parse_single_nests(fields_text) { |nest_list| yield nest_list }
-
- # next, handle nest groups: items(id, etag, etc)
- parse_nest_groups(fields_text) { |nest_list| yield nest_list }
- end
-
- def self.parse_single_nests(fields_text : String, &) : Nil
- single_nests = remove_nest_groups(fields_text)
-
- if !single_nests.empty?
- property_nests = single_nests.split(',')
-
- property_nests.each do |nest|
- nest_list = nest.split('/')
- if nest_list.includes? ""
- raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list}"
- end
- yield nest_list
- end
- # else
- # raise FieldsParser::ParseError.new "Empty key in nest list 22: #{fields_text} | #{single_nests}"
- end
- end
-
- def self.parse_nest_groups(fields_text : String, &) : Nil
- nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64)
- bracket_pairs = get_bracket_pairs(fields_text, true)
-
- text_index = 0
- regex_index = 0
-
- while regex_result = self.nest_group_pattern.match(fields_text, regex_index)
- raw_match = regex_result[0]
- group_name = regex_result["groupname"]
-
- text_index = regex_result.begin
- regex_index = regex_result.end
-
- if text_index.nil? || regex_index.nil?
- raise FieldsParser::ParseError.new "Received invalid index while parsing nest groups: text_index: #{text_index} | regex_index: #{regex_index}"
- end
-
- offset = raw_match.starts_with?(',') ? 1 : 0
-
- opening_bracket_index = (text_index + group_name.size) + offset
- closing_bracket_index = bracket_pairs[opening_bracket_index]
- content_start = opening_bracket_index + 1
-
- content = fields_text[content_start...closing_bracket_index]
-
- if content.empty?
- raise FieldsParser::ParseError.new "Empty nest group at position #{content_start}"
- else
- content = remove_nest_groups(content)
- end
-
- while nest_stack.size > 0 && closing_bracket_index > nest_stack[nest_stack.size - 1][:closing_bracket_index]
- if nest_stack.size
- nest_stack.pop
- end
- end
-
- group_name.split('/').each do |name|
- nest_stack.push({
- group_name: name,
- closing_bracket_index: closing_bracket_index,
- })
- end
-
- if !content.empty?
- properties = content.split(',')
-
- properties.each do |prop|
- nest_list = nest_stack.map { |nest_prop| nest_prop[:group_name] }
-
- if !prop.empty?
- if prop.includes?('/')
- parse_single_nests(prop) { |list| nest_list += list }
- else
- nest_list.push prop
- end
- else
- raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list << prop}"
- end
-
- yield nest_list
- end
- end
- end
- end
-
- def self.remove_nest_groups(text : String) : String
- content_bracket_pairs = get_bracket_pairs(text, false)
-
- content_bracket_pairs.each_key.to_a.reverse.each do |opening_bracket|
- closing_bracket = content_bracket_pairs[opening_bracket]
- last_comma = text.rindex(',', opening_bracket) || 0
-
- text = text[0...last_comma] + text[closing_bracket + 1...text.size]
- end
-
- return text.starts_with?(',') ? text[1...text.size] : text
- end
-
- def self.get_bracket_pairs(text : String, recursive = true) : BracketIndex
- istart = [] of Int64
- bracket_index = BracketIndex.new
-
- text.each_char_with_index do |char, index|
- if char == '('
- istart.push(index.to_i64)
- end
-
- if char == ')'
- begin
- opening = istart.pop
- if recursive || (!recursive && istart.size == 0)
- bracket_index[opening] = index.to_i64
- end
- rescue
- raise FieldsParser::ParseError.new "No matching opening parenthesis at: #{index}"
- end
- end
- end
-
- if istart.size != 0
- idx = istart.pop
- raise FieldsParser::ParseError.new "No matching closing parenthesis at: #{idx}"
- end
-
- return bracket_index
- end
- end
-
- class FieldsGrouper
- alias SkeletonValue = Hash(String, SkeletonValue)
-
- def self.create_json_skeleton(fields_text : String) : SkeletonValue
- root_hash = {} of String => SkeletonValue
-
- FieldsParser.parse_fields(fields_text) do |nest_list|
- current_item = root_hash
- nest_list.each do |key|
- if current_item[key]?
- current_item = current_item[key]
- else
- current_item[key] = {} of String => SkeletonValue
- current_item = current_item[key]
- end
- end
- end
- root_hash
- end
-
- def self.create_grouped_fields_list(json_skeleton : SkeletonValue) : GroupedFieldsList
- grouped_fields_list = GroupedFieldsList.new
- json_skeleton.each do |key, value|
- grouped_fields_list.push key
-
- nested_keys = create_grouped_fields_list(value)
- grouped_fields_list.push nested_keys unless nested_keys.empty?
- end
- return grouped_fields_list
- end
- end
-
- class FilterError < Exception
- end
-
- def self.filter(item : JSON::Any, fields_text : String, in_place : Bool = true)
- skeleton = FieldsGrouper.create_json_skeleton(fields_text)
- grouped_fields_list = FieldsGrouper.create_grouped_fields_list(skeleton)
- filter(item, grouped_fields_list, in_place)
- end
-
- def self.filter(item : JSON::Any, grouped_fields_list : GroupedFieldsList, in_place : Bool = true) : JSON::Any
- item = item.clone unless in_place
-
- if !item.as_h? && !item.as_a?
- raise FilterError.new "Can't filter '#{item}' by #{grouped_fields_list}"
- end
-
- top_level_keys = Array(String).new
- grouped_fields_list.each do |value|
- if value.is_a? String
- top_level_keys.push value
- elsif value.is_a? Array
- if !top_level_keys.empty?
- key_to_filter = top_level_keys.last
-
- if item.as_h?
- filter(item[key_to_filter], value, in_place: true)
- elsif item.as_a?
- item.as_a.each { |arr_item| filter(arr_item[key_to_filter], value, in_place: true) }
- end
- else
- raise FilterError.new "Tried to filter while top level keys list is empty"
- end
- end
- end
-
- if item.as_h?
- item.as_h.select! top_level_keys
- elsif item.as_a?
- item.as_a.map { |value| filter(value, top_level_keys, in_place: true) }
- end
-
- item
- end
-end
diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr
index e2e50905..03349595 100644
--- a/src/invidious/helpers/logger.cr
+++ b/src/invidious/helpers/logger.cr
@@ -1,3 +1,5 @@
+require "colorize"
+
enum LogLevel
All = 0
Trace = 1
@@ -10,7 +12,9 @@ enum LogLevel
end
class Invidious::LogHandler < Kemal::BaseLogHandler
- def initialize(@io : IO = STDOUT, @level = LogLevel::Debug)
+ def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true)
+ Colorize.enabled = use_color
+ Colorize.on_tty_only!
end
def call(context : HTTP::Server::Context)
@@ -34,28 +38,27 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
context
end
- def puts(message : String)
- @io << message << '\n'
- @io.flush
- end
-
def write(message : String)
@io << message
@io.flush
end
- def set_log_level(level : String)
- @level = LogLevel.parse(level)
- end
-
- def set_log_level(level : LogLevel)
- @level = level
+ def color(level)
+ case level
+ when LogLevel::Trace then :cyan
+ when LogLevel::Debug then :green
+ when LogLevel::Info then :white
+ when LogLevel::Warn then :yellow
+ when LogLevel::Error then :red
+ when LogLevel::Fatal then :magenta
+ else :default
+ end
end
{% for level in %w(trace debug info warn error fatal) %}
def {{level.id}}(message : String)
if LogLevel::{{level.id.capitalize}} >= @level
- puts("#{Time.utc} [{{level.id}}] #{message}")
+ puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})))
end
end
{% end %}
diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr
index e0bd7279..f8e8f187 100644
--- a/src/invidious/helpers/serialized_yt_data.cr
+++ b/src/invidious/helpers/serialized_yt_data.cr
@@ -1,3 +1,16 @@
+@[Flags]
+enum VideoBadges
+ LiveNow
+ Premium
+ ThreeD
+ FourK
+ New
+ EightK
+ VR180
+ VR360
+ ClosedCaptions
+end
+
struct SearchVideo
include DB::Serializable
@@ -9,10 +22,10 @@ struct SearchVideo
property views : Int64
property description_html : String
property length_seconds : Int32
- property live_now : Bool
- property premium : Bool
property premiere_timestamp : Time?
property author_verified : Bool
+ property author_thumbnail : String?
+ property badges : VideoBadges
def to_xml(auto_generated, query_params, xml : XML::Builder)
query_params["v"] = self.id
@@ -76,6 +89,24 @@ struct SearchVideo
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorVerified", self.author_verified
+ author_thumbnail = self.author_thumbnail
+
+ if author_thumbnail
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+ end
+
json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, self.id)
end
@@ -88,13 +119,20 @@ struct SearchVideo
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
+ json.field "liveNow", self.badges.live_now?
+ json.field "premium", self.badges.premium?
+ json.field "isUpcoming", self.upcoming?
if self.premiere_timestamp
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
end
+ json.field "isNew", self.badges.new?
+ json.field "is4k", self.badges.four_k?
+ json.field "is8k", self.badges.eight_k?
+ json.field "isVr180", self.badges.vr180?
+ json.field "isVr360", self.badges.vr360?
+ json.field "is3d", self.badges.three_d?
+ json.field "hasCaptions", self.badges.closed_captions?
end
end
@@ -109,7 +147,7 @@ struct SearchVideo
to_json(nil, json)
end
- def is_upcoming
+ def upcoming?
premiere_timestamp ? true : false
end
end
@@ -186,6 +224,7 @@ struct SearchChannel
property author_thumbnail : String
property subscriber_count : Int32
property video_count : Int32
+ property channel_handle : String?
property description_html : String
property auto_generated : Bool
property author_verified : Bool
@@ -203,7 +242,7 @@ struct SearchChannel
qualities.each do |quality|
json.object do
- json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
+ json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
json.field "width", quality
json.field "height", quality
end
@@ -214,6 +253,7 @@ struct SearchChannel
json.field "autoGenerated", self.auto_generated
json.field "subCount", self.subscriber_count
json.field "videoCount", self.video_count
+ json.field "channelHandle", self.channel_handle
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr
new file mode 100644
index 00000000..6d198a42
--- /dev/null
+++ b/src/invidious/helpers/sig_helper.cr
@@ -0,0 +1,349 @@
+require "uri"
+require "socket"
+require "socket/tcp_socket"
+require "socket/unix_socket"
+
+{% if flag?(:advanced_debug) %}
+ require "io/hexdump"
+{% end %}
+
+private alias NetworkEndian = IO::ByteFormat::NetworkEndian
+
+module Invidious::SigHelper
+ enum UpdateStatus
+ Updated
+ UpdateNotRequired
+ Error
+ end
+
+ # -------------------
+ # Payload types
+ # -------------------
+
+ abstract struct Payload
+ end
+
+ struct StringPayload < Payload
+ getter string : String
+
+ def initialize(str : String)
+ raise Exception.new("SigHelper: String can't be empty") if str.empty?
+ @string = str
+ end
+
+ def self.from_bytes(slice : Bytes)
+ size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice)
+ if size == 0 # Error code
+ raise Exception.new("SigHelper: Server encountered an error")
+ end
+
+ if (slice.bytesize - 2) != size
+ raise Exception.new("SigHelper: String size mismatch")
+ end
+
+ if str = String.new(slice[2..])
+ return self.new(str)
+ else
+ raise Exception.new("SigHelper: Can't read string from socket")
+ end
+ end
+
+ def to_io(io)
+ # `.to_u16` raises if there is an overflow during the conversion
+ io.write_bytes(@string.bytesize.to_u16, NetworkEndian)
+ io.write(@string.to_slice)
+ end
+ end
+
+ private enum Opcode
+ FORCE_UPDATE = 0
+ DECRYPT_N_SIGNATURE = 1
+ DECRYPT_SIGNATURE = 2
+ GET_SIGNATURE_TIMESTAMP = 3
+ GET_PLAYER_STATUS = 4
+ PLAYER_UPDATE_TIMESTAMP = 5
+ end
+
+ private record Request,
+ opcode : Opcode,
+ payload : Payload?
+
+ # ----------------------
+ # High-level functions
+ # ----------------------
+
+ class Client
+ @mux : Multiplexor
+
+ def initialize(uri_or_path)
+ @mux = Multiplexor.new(uri_or_path)
+ end
+
+ # Forces the server to re-fetch the YouTube player, and extract the necessary
+ # components from it (nsig function code, sig function code, signature timestamp).
+ def force_update : UpdateStatus
+ request = Request.new(Opcode::FORCE_UPDATE, nil)
+
+ value = send_request(request) do |bytes|
+ IO::ByteFormat::NetworkEndian.decode(UInt16, bytes)
+ end
+
+ case value
+ when 0x0000 then return UpdateStatus::Error
+ when 0xFFFF then return UpdateStatus::UpdateNotRequired
+ when 0xF44F then return UpdateStatus::Updated
+ else
+ code = value.nil? ? "nil" : value.to_s(base: 16)
+ raise Exception.new("SigHelper: Invalid status code received #{code}")
+ end
+ end
+
+ # Decrypt a provided n signature using the server's current nsig function
+ # code, and return the result (or an error).
+ def decrypt_n_param(n : String) : String?
+ request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n))
+
+ n_dec = self.send_request(request) do |bytes|
+ StringPayload.from_bytes(bytes).string
+ end
+
+ return n_dec
+ end
+
+ # Decrypt a provided s signature using the server's current sig function
+ # code, and return the result (or an error).
+ def decrypt_sig(sig : String) : String?
+ request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig))
+
+ sig_dec = self.send_request(request) do |bytes|
+ StringPayload.from_bytes(bytes).string
+ end
+
+ return sig_dec
+ end
+
+ # Return the signature timestamp from the server's current player
+ def get_signature_timestamp : UInt64?
+ request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil)
+
+ return self.send_request(request) do |bytes|
+ IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
+ end
+ end
+
+ # Return the current player's version
+ def get_player : UInt32?
+ request = Request.new(Opcode::GET_PLAYER_STATUS, nil)
+
+ return self.send_request(request) do |bytes|
+ has_player = (bytes[0] == 0xFF)
+ player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4])
+ has_player ? player_version : nil
+ end
+ end
+
+ # Return when the player was last updated
+ def get_player_timestamp : UInt64?
+ request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil)
+
+ return self.send_request(request) do |bytes|
+ IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
+ end
+ end
+
+ private def send_request(request : Request, &)
+ channel = @mux.send(request)
+ slice = channel.receive
+ return yield slice
+ rescue ex
+ LOGGER.debug("SigHelper: Error when sending a request")
+ LOGGER.trace(ex.inspect_with_backtrace)
+ return nil
+ end
+ end
+
+ # ---------------------
+ # Low level functions
+ # ---------------------
+
+ class Multiplexor
+ alias TransactionID = UInt32
+ record Transaction, channel = ::Channel(Bytes).new
+
+ @prng = Random.new
+ @mutex = Mutex.new
+ @queue = {} of TransactionID => Transaction
+
+ @conn : Connection
+ @uri_or_path : String
+
+ def initialize(@uri_or_path)
+ @conn = Connection.new(uri_or_path)
+ listen
+ end
+
+ def listen : Nil
+ raise "Socket is closed" if @conn.closed?
+
+ LOGGER.debug("SigHelper: Multiplexor listening")
+
+ spawn do
+ loop do
+ begin
+ receive_data
+ rescue ex
+ LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...")
+ # We close the socket because for some reason is not closed.
+ @conn.close
+ loop do
+ begin
+ @conn = Connection.new(@uri_or_path)
+ LOGGER.info("SigHelper: Reconnected to SigHelper!")
+ rescue ex
+ LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying")
+ sleep 500.milliseconds
+ next
+ end
+ break if !@conn.closed?
+ end
+ end
+ Fiber.yield
+ end
+ end
+ end
+
+ def send(request : Request)
+ transaction = Transaction.new
+ transaction_id = @prng.rand(TransactionID)
+
+ # Add transaction to queue
+ @mutex.synchronize do
+ # On a 32-bits random integer, this should never happen. Though, just in case, ...
+ if @queue[transaction_id]?
+ raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!")
+ end
+
+ @queue[transaction_id] = transaction
+ end
+
+ write_packet(transaction_id, request)
+
+ return transaction.channel
+ end
+
+ def receive_data
+ transaction_id, slice = read_packet
+
+ @mutex.synchronize do
+ if transaction = @queue.delete(transaction_id)
+ # Remove transaction from queue and send data to the channel
+ transaction.channel.send(slice)
+ LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel")
+ else
+ raise Exception.new("SigHelper: Received transaction was not in queue")
+ end
+ end
+ end
+
+ # Read a single packet from the socket
+ private def read_packet : {TransactionID, Bytes}
+ # Header
+ transaction_id = @conn.read_bytes(UInt32, NetworkEndian)
+ length = @conn.read_bytes(UInt32, NetworkEndian)
+
+ LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}")
+
+ if length > 67_000
+ raise Exception.new("SigHelper: Packet longer than expected (#{length})")
+ end
+
+ # Payload
+ slice = Bytes.new(length)
+ @conn.read(slice) if length > 0
+
+ LOGGER.trace("SigHelper: payload = #{slice}")
+ LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done")
+
+ return transaction_id, slice
+ end
+
+ # Write a single packet to the socket
+ private def write_packet(transaction_id : TransactionID, request : Request)
+ LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}")
+
+ io = IO::Memory.new(1024)
+ io.write_bytes(request.opcode.to_u8, NetworkEndian)
+ io.write_bytes(transaction_id, NetworkEndian)
+
+ if payload = request.payload
+ payload.to_io(io)
+ end
+
+ @conn.send(io)
+ @conn.flush
+
+ LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done")
+ end
+ end
+
+ class Connection
+ @socket : UNIXSocket | TCPSocket
+
+ {% if flag?(:advanced_debug) %}
+ @io : IO::Hexdump
+ {% end %}
+
+ def initialize(host_or_path : String)
+ case host_or_path
+ when .starts_with?('/')
+ # Make sure that the file exists
+ if File.exists?(host_or_path)
+ @socket = UNIXSocket.new(host_or_path)
+ else
+ raise Exception.new("SigHelper: '#{host_or_path}' no such file")
+ end
+ when .starts_with?("tcp://")
+ uri = URI.parse(host_or_path)
+ @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
+ else
+ uri = URI.parse("tcp://#{host_or_path}")
+ @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
+ end
+ LOGGER.info("SigHelper: Using helper at '#{host_or_path}'")
+
+ {% if flag?(:advanced_debug) %}
+ @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true)
+ {% end %}
+
+ @socket.sync = false
+ @socket.blocking = false
+ end
+
+ def closed? : Bool
+ return @socket.closed?
+ end
+
+ def close : Nil
+ @socket.close if !@socket.closed?
+ end
+
+ def flush(*args, **options)
+ @socket.flush(*args, **options)
+ end
+
+ def send(*args, **options)
+ @socket.send(*args, **options)
+ end
+
+ # Wrap IO functions, with added debug tooling if needed
+ {% for function in %w(read read_bytes write write_bytes) %}
+ def {{function.id}}(*args, **options)
+ {% if flag?(:advanced_debug) %}
+ @io.{{function.id}}(*args, **options)
+ {% else %}
+ @socket.{{function.id}}(*args, **options)
+ {% end %}
+ end
+ {% end %}
+ end
+end
diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr
index ee09415b..82a28fc0 100644
--- a/src/invidious/helpers/signatures.cr
+++ b/src/invidious/helpers/signatures.cr
@@ -1,73 +1,53 @@
-alias SigProc = Proc(Array(String), Int32, Array(String))
+require "http/params"
+require "./sig_helper"
-struct DecryptFunction
- @decrypt_function = [] of {SigProc, Int32}
- @decrypt_time = Time.monotonic
+class Invidious::DecryptFunction
+ @last_update : Time = Time.utc - 42.days
- def initialize(@use_polling = true)
+ def initialize(uri_or_path)
+ @client = SigHelper::Client.new(uri_or_path)
+ self.check_update
end
- def update_decrypt_function
- @decrypt_function = fetch_decrypt_function
- end
-
- private def fetch_decrypt_function(id = "CvFH_6DNRCY")
- document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body
- url = document.match(/src="(?<url>\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"]
- player = YT_POOL.client &.get(url).body
-
- function_name = player.match(/^(?<name>[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"]
- function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?<body>[^}]+)}/m).not_nil!["body"]
- function_body = function_body.split(";")[1..-2]
-
- var_name = function_body[0][0, 2]
- var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?<body>(.*?))};/).not_nil!["body"]
-
- operations = {} of String => SigProc
- var_body.split("},").each do |operation|
- op_name = operation.match(/^[^:]+/).not_nil![0]
- op_body = operation.match(/\{[^}]+/).not_nil![0]
-
- case op_body
- when "{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
- operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a }
- end
- end
+ def check_update
+ # If we have updated in the last 5 minutes, do nothing
+ return if (Time.utc - @last_update) < 5.minutes
- decrypt_function = [] of {SigProc, Int32}
- function_body.each do |function|
- function = function.lchop(var_name).delete("[].")
+ # Get the amount of time elapsed since when the player was updated, in the
+ # event where multiple invidious processes are run in parallel.
+ update_time_elapsed = (@client.get_player_timestamp || 301).seconds
- op_name = function.match(/[^\(]+/).not_nil![0]
- value = function.match(/\(\w,(?<value>[\d]+)\)/).not_nil!["value"].to_i
-
- decrypt_function << {operations[op_name], value}
+ if update_time_elapsed > 5.minutes
+ LOGGER.debug("Signature: Player might be outdated, updating")
+ @client.force_update
+ @last_update = Time.utc
end
-
- return decrypt_function
end
- def decrypt_signature(fmt : Hash(String, JSON::Any))
- return "" if !fmt["s"]? || !fmt["sp"]?
-
- sp = fmt["sp"].as_s
- sig = fmt["s"].as_s.split("")
- if !@use_polling
- now = Time.monotonic
- if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0
- @decrypt_function = fetch_decrypt_function
- @decrypt_time = Time.monotonic
- end
- end
+ def decrypt_nsig(n : String) : String?
+ self.check_update
+ return @client.decrypt_n_param(n)
+ rescue ex
+ LOGGER.debug(ex.message || "Signature: Unknown error")
+ LOGGER.trace(ex.inspect_with_backtrace)
+ return nil
+ end
- @decrypt_function.each do |proc, value|
- sig = proc.call(sig, value)
- end
+ def decrypt_signature(str : String) : String?
+ self.check_update
+ return @client.decrypt_sig(str)
+ rescue ex
+ LOGGER.debug(ex.message || "Signature: Unknown error")
+ LOGGER.trace(ex.inspect_with_backtrace)
+ return nil
+ end
- return "&#{sp}=#{sig.join("")}"
+ def get_sts : UInt64?
+ self.check_update
+ return @client.get_signature_timestamp
+ rescue ex
+ LOGGER.debug(ex.message || "Signature: Unknown error")
+ LOGGER.trace(ex.inspect_with_backtrace)
+ return nil
end
end
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index a006d602..4d9bb28d 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -52,9 +52,9 @@ def recode_length_seconds(time)
end
def decode_interval(string : String) : Time::Span
- rawMinutes = string.try &.to_i32?
+ raw_minutes = string.try &.to_i32?
- if !rawMinutes
+ if !raw_minutes
hours = /(?<hours>\d+)h/.match(string).try &.["hours"].try &.to_i32
hours ||= 0
@@ -63,7 +63,7 @@ def decode_interval(string : String) : Time::Span
time = Time::Span.new(hours: hours, minutes: minutes)
else
- time = Time::Span.new(minutes: rawMinutes)
+ time = Time::Span.new(minutes: raw_minutes)
end
return time
@@ -262,7 +262,7 @@ def get_referer(env, fallback = "/", unroll = true)
end
referer = referer.request_target
- referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,0-9a-zA-Z]/, "").lstrip("/\\")
+ referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z]/, "").lstrip("/\\")
if referer == env.request.path
referer = fallback
@@ -323,68 +323,6 @@ def parse_range(range)
return 0_i64, nil
end
-def fetch_random_instance
- begin
- instance_api_client = make_client(URI.parse("https://api.invidious.io"))
-
- # Timeouts
- instance_api_client.connect_timeout = 10.seconds
- instance_api_client.dns_timeout = 10.seconds
-
- instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a
- instance_api_client.close
- rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException
- instance_list = [] of JSON::Any
- end
-
- filtered_instance_list = [] of String
-
- instance_list.each do |data|
- # TODO Check if current URL is onion instance and use .onion types if so.
- if data[1]["type"] == "https"
- # Instances can have statistics disabled, which is an requirement of version validation.
- # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails.
- begin
- data[1]["stats"].as_nil
- next
- rescue TypeCastError
- end
-
- # stats endpoint could also lack the software dict.
- next if data[1]["stats"]["software"]?.nil?
-
- # Makes sure the instance isn't too outdated.
- if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"]
- remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/)
- next if !remote_commit_date
-
- remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC)
- local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC)
-
- next if (remote_commit_date - local_commit_date).abs.days > 30
-
- begin
- data[1]["monitor"].as_nil
- health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"]
- filtered_instance_list << data[0].as_s if health.to_s.to_f > 90
- rescue TypeCastError
- # We can't check the health if the monitoring is broken. Thus we'll just add it to the list
- # and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that
- # it's an error that often occurs with all the instances at the same time, we have to just skip the check.
- filtered_instance_list << data[0].as_s
- end
- end
- end
- end
-
- # If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io
- if filtered_instance_list.size == 0
- return "redirect.invidious.io"
- end
-
- return filtered_instance_list.sample(1)[0]
-end
-
def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String
str = uri.to_s.sub(/^https?:\/\//, "")
if str.size > max_length
diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr
new file mode 100644
index 00000000..260d250f
--- /dev/null
+++ b/src/invidious/helpers/webvtt.cr
@@ -0,0 +1,81 @@
+# Namespace for logic relating to generating WebVTT files
+#
+# Probably not compliant to WebVTT's specs but it is enough for Invidious.
+module WebVTT
+ # A WebVTT builder generates WebVTT files
+ private class Builder
+ # See https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload
+ private ESCAPE_SUBSTITUTIONS = {
+ '&' => "&amp;",
+ '<' => "&lt;",
+ '>' => "&gt;",
+ '\u200E' => "&lrm;",
+ '\u200F' => "&rlm;",
+ '\u00A0' => "&nbsp;",
+ }
+
+ def initialize(@io : IO)
+ end
+
+ # Writes an vtt cue with the specified time stamp and contents
+ def cue(start_time : Time::Span, end_time : Time::Span, text : String)
+ timestamp(start_time, end_time)
+ @io << self.escape(text)
+ @io << "\n\n"
+ end
+
+ private def timestamp(start_time : Time::Span, end_time : Time::Span)
+ timestamp_component(start_time)
+ @io << " --> "
+ timestamp_component(end_time)
+
+ @io << '\n'
+ end
+
+ private def timestamp_component(timestamp : Time::Span)
+ @io << timestamp.hours.to_s.rjust(2, '0')
+ @io << ':' << timestamp.minutes.to_s.rjust(2, '0')
+ @io << ':' << timestamp.seconds.to_s.rjust(2, '0')
+ @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0')
+ end
+
+ private def escape(text : String) : String
+ return text.gsub(ESCAPE_SUBSTITUTIONS)
+ end
+
+ def document(setting_fields : Hash(String, String)? = nil, &)
+ @io << "WEBVTT\n"
+
+ if setting_fields
+ setting_fields.each do |name, value|
+ @io << name << ": " << value << '\n'
+ end
+ end
+
+ @io << '\n'
+
+ yield
+ end
+ end
+
+ # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder`
+ #
+ # ```
+ # string = WebVTT.build do |vtt|
+ # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1")
+ # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2")
+ # end
+ #
+ # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n"
+ # ```
+ #
+ # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file.
+ def self.build(setting_fields : Hash(String, String)? = nil, &)
+ String.build do |str|
+ builder = Builder.new(str)
+ builder.document(setting_fields) do
+ yield builder
+ end
+ end
+ end
+end
diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr
index 222dfc4a..623a9177 100644
--- a/src/invidious/http_server/utils.cr
+++ b/src/invidious/http_server/utils.cr
@@ -11,11 +11,12 @@ module Invidious::HttpServer
params = url.query_params
params["host"] = url.host.not_nil! # Should never be nil, in theory
params["region"] = region if !region.nil?
+ url.query_params = params
if absolute
- return "#{HOST_URL}#{url.request_target}?#{params}"
+ return "#{HOST_URL}#{url.request_target}"
else
- return "#{url.request_target}?#{params}"
+ return url.request_target
end
end
diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr
deleted file mode 100644
index 71f8a938..00000000
--- a/src/invidious/jobs/bypass_captcha_job.cr
+++ /dev/null
@@ -1,135 +0,0 @@
-class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
- def begin
- loop do
- begin
- random_video = PG_DB.query_one?("select id, ucid from (select id, ucid from channel_videos limit 1000) as s ORDER BY RANDOM() LIMIT 1", as: {id: String, ucid: String})
- if !random_video
- random_video = {id: "zj82_v2R6ts", ucid: "UCK87Lox575O_HCHBWaBSyGA"}
- end
- {"/watch?v=#{random_video["id"]}&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: random_video["ucid"])}.each do |path|
- response = YT_POOL.client &.get(path)
- if response.body.includes?("To continue with your YouTube experience, please fill out the form below.")
- html = XML.parse_html(response.body)
- form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil!
- site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"]
- s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"]
-
- inputs = {} of String => String
- form.xpath_nodes(%(.//input[@name])).map do |node|
- inputs[node["name"]] = node["value"]
- end
-
- headers = response.cookies.add_request_headers(HTTP::Headers.new)
-
- response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/createTask",
- headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
- "clientKey" => CONFIG.captcha_key,
- "task" => {
- "type" => "NoCaptchaTaskProxyless",
- "websiteURL" => "https://www.youtube.com#{path}",
- "websiteKey" => site_key,
- "recaptchaDataSValue" => s_value,
- },
- }.to_json).body)
-
- raise response["error"].as_s if response["error"]?
- task_id = response["taskId"].as_i
-
- loop do
- sleep 10.seconds
-
- response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/getTaskResult",
- headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
- "clientKey" => CONFIG.captcha_key,
- "taskId" => task_id,
- }.to_json).body)
-
- if response["status"]?.try &.== "ready"
- break
- elsif response["errorId"]?.try &.as_i != 0
- raise response["errorDescription"].as_s
- end
- end
-
- inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
- headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || ""
- response = YT_POOL.client &.post("/das_captcha", headers, form: inputs)
-
- response.cookies
- .select { |cookie| cookie.name != "PREF" }
- .each { |cookie| CONFIG.cookies << cookie }
-
- # Persist cookies between runs
- File.write("config/config.yml", CONFIG.to_yaml)
- elsif response.headers["Location"]?.try &.includes?("/sorry/index")
- location = response.headers["Location"].try { |u| URI.parse(u) }
- headers = HTTP::Headers{":authority" => location.host.not_nil!}
- response = YT_POOL.client &.get(location.request_target, headers)
-
- html = XML.parse_html(response.body)
- form = html.xpath_node(%(//form[@action="index"])).not_nil!
- site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"]
- s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"]
-
- inputs = {} of String => String
- form.xpath_nodes(%(.//input[@name])).map do |node|
- inputs[node["name"]] = node["value"]
- end
-
- captcha_client = HTTPClient.new(URI.parse(CONFIG.captcha_api_url))
- captcha_client.family = CONFIG.force_resolve || Socket::Family::INET
- response = JSON.parse(captcha_client.post("/createTask",
- headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
- "clientKey" => CONFIG.captcha_key,
- "task" => {
- "type" => "NoCaptchaTaskProxyless",
- "websiteURL" => location.to_s,
- "websiteKey" => site_key,
- "recaptchaDataSValue" => s_value,
- },
- }.to_json).body)
-
- captcha_client.close
-
- raise response["error"].as_s if response["error"]?
- task_id = response["taskId"].as_i
-
- loop do
- sleep 10.seconds
-
- response = JSON.parse(captcha_client.post("/getTaskResult",
- headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
- "clientKey" => CONFIG.captcha_key,
- "taskId" => task_id,
- }.to_json).body)
-
- if response["status"]?.try &.== "ready"
- break
- elsif response["errorId"]?.try &.as_i != 0
- raise response["errorDescription"].as_s
- end
- end
-
- inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
- headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || ""
- response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs)
- headers = HTTP::Headers{
- "Cookie" => URI.parse(response.headers["location"]).query_params["google_abuse"].split(";")[0],
- }
- cookies = HTTP::Cookies.from_client_headers(headers)
-
- cookies.each { |cookie| CONFIG.cookies << cookie }
-
- # Persist cookies between runs
- File.write("config/config.yml", CONFIG.to_yaml)
- end
- end
- rescue ex
- LOGGER.error("BypassCaptchaJob: #{ex.message}")
- ensure
- sleep 1.minute
- Fiber.yield
- end
- end
- end
-end
diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr
new file mode 100644
index 00000000..cb4280b9
--- /dev/null
+++ b/src/invidious/jobs/instance_refresh_job.cr
@@ -0,0 +1,97 @@
+class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob
+ # We update the internals of a constant as so it can be accessed from anywhere
+ # within the codebase
+ #
+ # "INSTANCES" => Array(Tuple(String, String)) # region, instance
+
+ INSTANCES = {"INSTANCES" => [] of Tuple(String, String)}
+
+ def initialize
+ end
+
+ def begin
+ loop do
+ refresh_instances
+ LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes")
+ sleep 30.minute
+ Fiber.yield
+ end
+ end
+
+ # Refreshes the list of instances used for redirects.
+ #
+ # Does the following three checks for each instance
+ # - Is it a clear-net instance?
+ # - Is it an instance with a good uptime?
+ # - Is it an updated instance?
+ private def refresh_instances
+ raw_instance_list = self.fetch_instances
+ filtered_instance_list = [] of Tuple(String, String)
+
+ raw_instance_list.each do |instance_data|
+ # TODO allow Tor hidden service instances when the current instance
+ # is also a hidden service. Same for i2p and any other non-clearnet instances.
+ begin
+ domain = instance_data[0]
+ info = instance_data[1]
+ stats = info["stats"]
+
+ next unless info["type"] == "https"
+ next if bad_uptime?(info["monitor"])
+ next if outdated?(stats["software"]["version"])
+
+ filtered_instance_list << {info["region"].as_s, domain.as_s}
+ rescue ex
+ if domain
+ LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ")
+ else
+ LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ")
+ end
+ end
+ end
+
+ if !filtered_instance_list.empty?
+ INSTANCES["INSTANCES"] = filtered_instance_list
+ end
+ end
+
+ # Fetches information regarding instances from api.invidious.io or an otherwise configured URL
+ private def fetch_instances : Array(JSON::Any)
+ begin
+ # We directly call the stdlib HTTP::Client here as it allows us to negate the effects
+ # of the force_resolve config option. This is needed as api.invidious.io does not support ipv6
+ # and as such the following request raises if we were to use force_resolve with the ipv6 value.
+ instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io"))
+
+ # Timeouts
+ instance_api_client.connect_timeout = 10.seconds
+ instance_api_client.dns_timeout = 10.seconds
+
+ raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a
+ instance_api_client.close
+ rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException
+ raw_instance_list = [] of JSON::Any
+ end
+
+ return raw_instance_list
+ end
+
+ # Checks if the given target instance is outdated
+ private def outdated?(target_instance_version) : Bool
+ remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/)
+ return false if !remote_commit_date
+
+ remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC)
+ local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC)
+
+ return (remote_commit_date - local_commit_date).abs.days > 30
+ end
+
+ # Checks if the uptime of the target instance is greater than 90% over a 30 day period
+ private def bad_uptime?(target_instance_health_monitor) : Bool
+ return true if !target_instance_health_monitor["down"].as_bool == false
+ return true if target_instance_health_monitor["uptime"].as_f < 90
+
+ return false
+ end
+end
diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr
index a113bd77..66c91ad5 100644
--- a/src/invidious/jobs/statistics_refresh_job.cr
+++ b/src/invidious/jobs/statistics_refresh_job.cr
@@ -18,6 +18,13 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
"updatedAt" => Time.utc.to_unix,
"lastChannelRefreshedAt" => 0_i64,
},
+
+ #
+ # "totalRequests" => 0_i64,
+ # "successfulRequests" => 0_i64
+ # "ratio" => 0_i64
+ #
+ "playback" => {} of String => Int64 | Float64,
}
private getter db : DB::Database
@@ -30,7 +37,7 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
loop do
refresh_stats
- sleep 1.minute
+ sleep 10.minute
Fiber.yield
end
end
@@ -49,12 +56,15 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
users = STATISTICS.dig("usage", "users").as(Hash(String, 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
+ users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_6m
+ users["activeMonth"] = Invidious::Database::Statistics.count_users_active_1m
STATISTICS["metadata"] = {
"updatedAt" => Time.utc.to_unix,
"lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64,
}
+
+ # Reset playback requests tracker
+ STATISTICS["playback"] = {} of String => Int64 | Float64
end
end
diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr
deleted file mode 100644
index 6fa0ae1b..00000000
--- a/src/invidious/jobs/update_decrypt_function_job.cr
+++ /dev/null
@@ -1,14 +0,0 @@
-class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob
- def begin
- loop do
- begin
- DECRYPT_FUNCTION.update_decrypt_function
- rescue ex
- LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}")
- ensure
- sleep 1.minute
- Fiber.yield
- end
- end
- end
-end
diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr
index fe4b5223..3439ae60 100644
--- a/src/invidious/jsonify/api_v1/video_json.cr
+++ b/src/invidious/jsonify/api_v1/video_json.cr
@@ -39,6 +39,7 @@ module Invidious::JSONify::APIv1
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
+ json.field "authorVerified", video.author_verified
json.field "authorThumbnails" do
json.array do
@@ -61,7 +62,8 @@ module Invidious::JSONify::APIv1
json.field "rating", 0_i64
json.field "isListed", video.is_listed
json.field "liveNow", video.live_now
- json.field "isUpcoming", video.is_upcoming
+ json.field "isPostLiveDvr", video.post_live_dvr
+ json.field "isUpcoming", video.upcoming?
if video.premiere_timestamp
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
@@ -107,30 +109,36 @@ module Invidious::JSONify::APIv1
# On livestreams, it's not present, so always fall back to the
# current unix timestamp (up to mS precision) for compatibility.
last_modified = fmt["lastModified"]?
- last_modified ||= "#{Time.utc.to_unix_ms.to_s}000"
+ last_modified ||= "#{Time.utc.to_unix_ms}000"
json.field "lmt", last_modified
json.field "projectionType", fmt["projectionType"]
- if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
- fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
+ height = fmt["height"]?.try &.as_i
+ width = fmt["width"]?.try &.as_i
+
+ fps = fmt["fps"]?.try &.as_i
+
+ if fps
json.field "fps", fps
- json.field "container", fmt_info["ext"]
- json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
+ end
- if fmt_info["height"]?
- json.field "resolution", "#{fmt_info["height"]}p"
+ if height && width
+ json.field "size", "#{width}x#{height}"
+ json.field "resolution", "#{height}p"
- quality_label = "#{fmt_info["height"]}p"
- if fps > 30
- quality_label += "60"
- end
- json.field "qualityLabel", quality_label
+ quality_label = "#{width > height ? height : width}p"
- if fmt_info["width"]?
- json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
- end
+ if fps && fps > 30
+ quality_label += fps.to_s
end
+
+ json.field "qualityLabel", quality_label
+ end
+
+ if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
+ json.field "container", fmt_info["ext"]
+ json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
end
# Livestream chunk infos
@@ -154,31 +162,44 @@ module Invidious::JSONify::APIv1
json.array do
video.fmt_stream.each do |fmt|
json.object do
- json.field "url", fmt["url"]
+ if proxy
+ json.field "url", Invidious::HttpServer::Utils.proxy_video_url(
+ fmt["url"].to_s, absolute: true
+ )
+ else
+ json.field "url", fmt["url"]
+ end
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
json.field "quality", fmt["quality"]
- fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
- if fmt_info
- fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
+ json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
+
+ height = fmt["height"]?.try &.as_i
+ width = fmt["width"]?.try &.as_i
+
+ fps = fmt["fps"]?.try &.as_i
+
+ if fps
json.field "fps", fps
- json.field "container", fmt_info["ext"]
- json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
+ end
- if fmt_info["height"]?
- json.field "resolution", "#{fmt_info["height"]}p"
+ if height && width
+ json.field "size", "#{width}x#{height}"
+ json.field "resolution", "#{height}p"
- quality_label = "#{fmt_info["height"]}p"
- if fps > 30
- quality_label += "60"
- end
- json.field "qualityLabel", quality_label
+ quality_label = "#{width > height ? height : width}p"
- if fmt_info["width"]?
- json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
- end
+ if fps && fps > 30
+ quality_label += fps.to_s
end
+
+ json.field "qualityLabel", quality_label
+ end
+
+ if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
+ json.field "container", fmt_info["ext"]
+ json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
end
end
end
@@ -226,6 +247,7 @@ module Invidious::JSONify::APIv1
json.field "author", rv["author"]
json.field "authorUrl", "/channel/#{rv["ucid"]?}"
json.field "authorId", rv["ucid"]?
+ json.field "authorVerified", rv["author_verified"] == "true"
if rv["author_thumbnail"]?
json.field "authorThumbnails" do
json.array do
@@ -245,6 +267,12 @@ module Invidious::JSONify::APIv1
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]?
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
+ json.field "published", rv["published"]?
+ if !rv["published"]?.nil?
+ json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
+ else
+ json.field "publishedText", ""
+ end
end
end
end
@@ -255,17 +283,17 @@ module Invidious::JSONify::APIv1
def storyboards(json, id, storyboards)
json.array do
- storyboards.each do |storyboard|
+ storyboards.each do |sb|
json.object do
- json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
- json.field "templateUrl", storyboard[:url]
- json.field "width", storyboard[:width]
- json.field "height", storyboard[:height]
- json.field "count", storyboard[:count]
- json.field "interval", storyboard[:interval]
- json.field "storyboardWidth", storyboard[:storyboard_width]
- json.field "storyboardHeight", storyboard[:storyboard_height]
- json.field "storyboardCount", storyboard[:storyboard_count]
+ json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}"
+ json.field "templateUrl", sb.url.to_s
+ json.field "width", sb.width
+ json.field "height", sb.height
+ json.field "count", sb.count
+ json.field "interval", sb.interval
+ json.field "storyboardWidth", sb.columns
+ json.field "storyboardHeight", sb.rows
+ json.field "storyboardCount", sb.images_count
end
end
end
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index 823ca85b..28ff0ff6 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
})
end
-def template_mix(mix)
+def template_mix(mix, listen)
html = <<-END_HTML
<h3>
<a href="/mix?list=#{mix["mixId"]}">
@@ -95,7 +95,7 @@ def template_mix(mix)
mix["videos"].as_a.each do |video|
html += <<-END_HTML
<li class="pure-menu-item">
- <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}">
<div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index 955e0855..b670c009 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -46,8 +46,14 @@ struct PlaylistVideo
XML.build { |xml| to_xml(xml) }
end
+ def to_json(locale : String?, json : JSON::Builder)
+ to_json(json)
+ end
+
def to_json(json : JSON::Builder, index : Int32? = nil)
json.object do
+ json.field "type", "video"
+
json.field "title", self.title
json.field "videoId", self.id
@@ -67,6 +73,7 @@ struct PlaylistVideo
end
json.field "lengthSeconds", self.length_seconds
+ json.field "liveNow", self.live_now
end
end
@@ -263,7 +270,7 @@ end
def subscribe_playlist(user, playlist)
playlist = InvidiousPlaylist.new({
- title: playlist.title.byte_slice(0, 150),
+ title: playlist.title[..150],
id: playlist.id,
author: user.email,
description: "", # Max 5000 characters
@@ -366,6 +373,8 @@ def fetch_playlist(plid : String)
if text.includes? "video"
video_count = text.gsub(/\D/, "").to_i? || 0
+ elsif text.includes? "episode"
+ video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64
else
@@ -496,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
return videos
end
-def template_playlist(playlist)
+def template_playlist(playlist, listen)
html = <<-END_HTML
<h3>
<a href="/playlist?list=#{playlist["playlistId"]}">
@@ -510,7 +519,7 @@ def template_playlist(playlist)
playlist["videos"].as_a.each do |video|
html += <<-END_HTML
<li class="pure-menu-item" id="#{video["videoId"]}">
- <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
+ <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
<div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr
index 9d930841..c8db207c 100644
--- a/src/invidious/routes/account.cr
+++ b/src/invidious/routes/account.cr
@@ -53,7 +53,7 @@ module Invidious::Routes::Account
return error_template(401, "Password is a required field")
end
- new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
+ new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
return error_template(400, "New passwords must match")
@@ -240,7 +240,7 @@ module Invidious::Routes::Account
return error_template(400, ex)
end
- 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?
@@ -328,17 +328,9 @@ module Invidious::Routes::Account
end
end
- if env.params.query["action_revoke_token"]?
- action = "action_revoke_token"
- else
- return env.redirect referer
- end
-
- session = env.params.query["session"]?
- session ||= ""
-
- case action
- when .starts_with? "action_revoke_token"
+ case action = env.params.query["action"]?
+ when "revoke_token"
+ session = env.params.query["session"]
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
return error_json(400, "Unsupported action #{action}")
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index 662d1002..6c4225e5 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -21,28 +21,27 @@ module Invidious::Routes::API::Manifest
end
if dashmpd = video.dash_manifest_url
- manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body
+ response = YT_POOL.client &.get(URI.parse(dashmpd).request_target)
- manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
- url = baseurl.lchop("<BaseURL>")
- url = url.rchop("</BaseURL>")
-
- if local
- uri = URI.parse(url)
- url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/"
- end
+ if response.status_code != 200
+ haltf env, status_code: response.status_code
+ end
+ # Proxy URLs for video playback on invidious.
+ # Other API clients can get the original URLs by omiting `local=true`.
+ manifest = response.body.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
+ url = baseurl.lchop("<BaseURL>").rchop("</BaseURL>")
+ url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local
"<BaseURL>#{url}</BaseURL>"
end
return manifest
end
- adaptive_fmts = video.adaptive_fmts
-
+ # Ditto, only proxify URLs if `local=true` is used
if local
- adaptive_fmts.each do |fmt|
- fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}")
+ video.adaptive_fmts.each do |fmt|
+ fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true))
end
end
@@ -64,17 +63,23 @@ module Invidious::Routes::API::Manifest
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any
+ lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und"
+ is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0
+ displayname = audio_track["displayName"]?.try &.as_s || "Unknown"
+ bitrate = fmt["bitrate"]
+
# Different representations of the same audio should be groupped into one AdaptationSet.
# However, most players don't support auto quality switching, so we have to trick them
# into providing a quality selector.
# See https://github.com/iv-org/invidious/issues/3074 for more details.
- xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
+ xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i
url = fmt["url"].as_s
- xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
+ xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate")
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
@@ -171,8 +176,9 @@ module Invidious::Routes::API::Manifest
manifest = response.body
if local
- manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
- path = URI.parse(match).path
+ manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match|
+ uri = URI.parse(match)
+ path = uri.path
path = path.lchop("/videoplayback/")
path = path.rchop("/")
@@ -201,7 +207,7 @@ module Invidious::Routes::API::Manifest
raw_params["fvip"] = fvip["fvip"]
end
- raw_params["local"] = "true"
+ raw_params["host"] = uri.host.not_nil!
"#{HOST_URL}/videoplayback?#{raw_params}"
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index adf05d30..588bbc2a 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -27,10 +27,21 @@ module Invidious::Routes::API::V1::Channels
# Retrieve "sort by" setting from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
- begin
- videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
- rescue ex
- return error_json(500, ex)
+ if channel.is_age_gated
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
+ videos = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ videos = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ begin
+ videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
+ rescue ex
+ return error_json(500, ex)
+ end
end
JSON.build do |json|
@@ -84,12 +95,14 @@ module Invidious::Routes::API::V1::Channels
json.field "joined", channel.joined.to_unix
json.field "autoGenerated", channel.auto_generated
+ json.field "ageGated", channel.is_age_gated
json.field "isFamilyFriendly", channel.is_family_friendly
json.field "description", html_to_content(channel.description_html)
json.field "descriptionHtml", channel.description_html
json.field "allowedRegions", channel.allowed_regions
json.field "tabs", channel.tabs
+ json.field "tags", channel.tags
json.field "authorVerified", channel.verified
json.field "latestVideos" do
@@ -141,12 +154,23 @@ module Invidious::Routes::API::V1::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
- begin
- videos, next_continuation = Channel::Tabs.get_60_videos(
- channel, continuation: continuation, sort_by: sort_by
- )
- rescue ex
- return error_json(500, ex)
+ if channel.is_age_gated
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
+ videos = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ videos = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ begin
+ videos, next_continuation = Channel::Tabs.get_60_videos(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ rescue ex
+ return error_json(500, ex)
+ end
end
return JSON.build do |json|
@@ -173,14 +197,26 @@ module Invidious::Routes::API::V1::Channels
get_channel()
# Retrieve continuation from URL parameters
+ sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
- begin
- videos, next_continuation = Channel::Tabs.get_shorts(
- channel, continuation: continuation
- )
- rescue ex
- return error_json(500, ex)
+ if channel.is_age_gated
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
+ videos = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ videos = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ begin
+ videos, next_continuation = Channel::Tabs.get_shorts(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ rescue ex
+ return error_json(500, ex)
+ end
end
return JSON.build do |json|
@@ -207,14 +243,26 @@ module Invidious::Routes::API::V1::Channels
get_channel()
# Retrieve continuation from URL parameters
+ sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]?
- begin
- videos, next_continuation = Channel::Tabs.get_60_livestreams(
- channel, continuation: continuation
- )
- rescue ex
- return error_json(500, ex)
+ if channel.is_age_gated
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
+ videos = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ videos = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ begin
+ videos, next_continuation = Channel::Tabs.get_60_livestreams(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ rescue ex
+ return error_json(500, ex)
+ end
end
return JSON.build do |json|
@@ -343,6 +391,59 @@ module Invidious::Routes::API::V1::Channels
end
end
+ def self.post(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ env.response.content_type = "application/json"
+ id = env.params.url["id"].to_s
+ ucid = env.params.query["ucid"]?
+
+ thin_mode = env.params.query["thin_mode"]?
+ thin_mode = thin_mode == "true"
+
+ format = env.params.query["format"]?
+ format ||= "json"
+
+ if ucid.nil?
+ response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
+ return error_json(400, "Invalid post ID") if response["error"]?
+ ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s
+ else
+ ucid = ucid.to_s
+ end
+
+ begin
+ fetch_channel_community_post(ucid, id, locale, format, thin_mode)
+ rescue ex
+ return error_json(500, ex)
+ end
+ end
+
+ def self.post_comments(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ env.response.content_type = "application/json"
+
+ id = env.params.url["id"]
+
+ thin_mode = env.params.query["thin_mode"]?
+ thin_mode = thin_mode == "true"
+
+ format = env.params.query["format"]?
+ format ||= "json"
+
+ continuation = env.params.query["continuation"]?
+
+ case continuation
+ when nil, ""
+ ucid = env.params.query["ucid"]
+ comments = Comments.fetch_community_post_comments(ucid, id)
+ else
+ comments = YoutubeAPI.browse(continuation: continuation)
+ end
+ return Comments.parse_youtube(id, comments, format, locale, thin_mode, is_post: true)
+ end
+
def self.channels(env)
locale = env.get("preferences").as(Preferences).locale
ucid = env.params.url["ucid"]
diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr
index 41865f34..fea2993c 100644
--- a/src/invidious/routes/api/v1/feeds.cr
+++ b/src/invidious/routes/api/v1/feeds.cr
@@ -31,7 +31,7 @@ module Invidious::Routes::API::V1::Feeds
if !CONFIG.popular_enabled
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
- haltf env, 400, error_message
+ haltf env, 403, error_message
end
JSON.build do |json|
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index e499f4d6..4f5b58da 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -6,6 +6,22 @@ module Invidious::Routes::API::V1::Misc
if !CONFIG.statistics_enabled
return {"software" => SOFTWARE}.to_json
else
+ # Calculate playback success rate
+ if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?)
+ tracker = tracker.as(Hash(String, Int64 | Float64))
+
+ if !tracker.empty?
+ total_requests = tracker["totalRequests"]
+ success_count = tracker["successfulRequests"]
+
+ if total_requests.zero?
+ tracker["ratio"] = 1_i64
+ else
+ tracker["ratio"] = (success_count / (total_requests)).round(2)
+ end
+ end
+ end
+
return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
end
end
@@ -26,6 +42,9 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]?
format ||= "json"
+ listen_param = env.params.query["listen"]?
+ listen = (listen_param == "true" || listen_param == "1")
+
if plid.starts_with? "RD"
return env.redirect "/api/v1/mixes/#{plid}"
end
@@ -58,7 +77,9 @@ module Invidious::Routes::API::V1::Misc
response = playlist.to_json(offset, video_id: video_id)
json_response = JSON.parse(response)
- if json_response["videos"].as_a[0]["index"] != offset
+ if json_response["videos"].as_a.empty?
+ json_response = JSON.parse(response)
+ elsif 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)
@@ -67,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
end
if format == "html"
- playlist_html = template_playlist(json_response)
+ playlist_html = template_playlist(json_response, listen)
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 = {
@@ -93,6 +114,9 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]?
format ||= "json"
+ listen_param = env.params.query["listen"]?
+ listen = (listen_param == "true" || listen_param == "1")
+
begin
mix = fetch_mix(rdid, continuation, locale: locale)
@@ -123,9 +147,7 @@ module Invidious::Routes::API::V1::Misc
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do
- json.array do
- Invidious::JSONify::APIv1.thumbnails(json, video.id)
- end
+ Invidious::JSONify::APIv1.thumbnails(json, video.id)
end
json.field "index", video.index
@@ -139,7 +161,7 @@ module Invidious::Routes::API::V1::Misc
if format == "html"
response = JSON.parse(response)
- playlist_html = template_mix(response)
+ playlist_html = template_mix(response, listen)
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
response = {
@@ -161,19 +183,24 @@ module Invidious::Routes::API::V1::Misc
begin
resolved_url = YoutubeAPI.resolve_url(url.as(String))
endpoint = resolved_url["endpoint"]
- pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
- if resolved_ucid = endpoint.dig?("watchEndpoint", "videoId")
- elsif resolved_ucid = endpoint.dig?("browseEndpoint", "browseId")
- elsif pageType == "WEB_PAGE_TYPE_UNKNOWN"
+ page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
+ if page_type == "WEB_PAGE_TYPE_UNKNOWN"
return error_json(400, "Unknown url")
end
+
+ sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
+ params = sub_endpoint.try &.dig?("params")
rescue ex
return error_json(500, ex)
end
JSON.build do |json|
json.object do
- json.field "ucid", resolved_ucid.try &.as_s || ""
- json.field "pageType", pageType
+ json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]?
+ json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]?
+ json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]?
+ json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]?
+ json.field "params", params.try &.as_s
+ json.field "pageType", page_type
end
end
end
diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr
index 9fb283c2..59a30745 100644
--- a/src/invidious/routes/api/v1/search.cr
+++ b/src/invidious/routes/api/v1/search.cr
@@ -31,12 +31,13 @@ module Invidious::Routes::API::V1::Search
query = env.params.query["q"]? || ""
begin
- client = HTTP::Client.new("suggestqueries-clients6.youtube.com")
- url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&xssi=t&gs_ri=youtube&ds=yt"
+ client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true)
+ url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt"
response = client.get(url).body
+ client.close
- body = JSON.parse(response[5..-1]).as_a
+ body = JSON.parse(response[19..-2]).as_a
suggestions = body[1].as_a[0..-2]
JSON.build do |json|
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 25e766d2..368304ac 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -1,3 +1,5 @@
+require "html"
+
module Invidious::Routes::API::V1::Videos
def self.videos(env)
locale = env.get("preferences").as(Preferences).locale
@@ -89,9 +91,14 @@ module Invidious::Routes::API::V1::Videos
if CONFIG.use_innertube_for_captions
params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated)
- initial_data = YoutubeAPI.get_transcript(params)
- webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code)
+ transcript = Invidious::Videos::Transcript.from_raw(
+ YoutubeAPI.get_transcript(params),
+ caption.language_code,
+ caption.auto_generated
+ )
+
+ webvtt = transcript.to_vtt
else
# Timedtext API handling
url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target
@@ -101,20 +108,17 @@ module Invidious::Routes::API::V1::Videos
if caption.name.includes? "auto-generated"
caption_xml = YT_POOL.client &.get(url).body
+ settings_field = {
+ "Kind" => "captions",
+ "Language" => "#{tlang || caption.language_code}",
+ }
+
if caption_xml.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(caption_xml, tlang)
else
caption_xml = XML.parse(caption_xml)
- webvtt = String.build do |str|
- str << <<-END_VTT
- WEBVTT
- Kind: captions
- Language: #{tlang || caption.language_code}
-
-
- END_VTT
-
+ webvtt = WebVTT.build(settings_field) do |builder|
caption_nodes = caption_xml.xpath_nodes("//transcript/text")
caption_nodes.each_with_index do |node, i|
start_time = node["start"].to_f.seconds
@@ -127,9 +131,6 @@ module Invidious::Routes::API::V1::Videos
end_time = start_time + duration
end
- start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
- end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
-
text = HTML.unescape(node.content)
text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
text = text.gsub(/<\/font>/, "")
@@ -137,27 +138,26 @@ module Invidious::Routes::API::V1::Videos
text = "<v #{md["name"]}>#{md["text"]}</v>"
end
- str << <<-END_CUE
- #{start_time} --> #{end_time}
- #{text}
-
-
- END_CUE
+ builder.cue(start_time, end_time, text)
end
end
end
else
- # Some captions have "align:[start/end]" and "position:[num]%"
- # attributes. Those are causing issues with VideoJS, which is unable
- # to properly align the captions on the video, so we remove them.
- #
- # See: https://github.com/iv-org/invidious/issues/2391
- webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
+ uri = URI.parse(url)
+ query_params = uri.query_params
+ query_params["fmt"] = "vtt"
+ uri.query_params = query_params
+ webvtt = YT_POOL.client &.get(uri.request_target).body
+
if webvtt.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(webvtt)
else
- webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
- .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1")
+ # Some captions have "align:[start/end]" and "position:[num]%"
+ # attributes. Those are causing issues with VideoJS, which is unable
+ # to properly align the captions on the video, so we remove them.
+ #
+ # See: https://github.com/iv-org/invidious/issues/2391
+ webvtt = webvtt.gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1")
end
end
end
@@ -189,15 +189,14 @@ module Invidious::Routes::API::V1::Videos
haltf env, 500
end
- storyboards = video.storyboards
- width = env.params.query["width"]?
- height = env.params.query["height"]?
+ width = env.params.query["width"]?.try &.to_i
+ height = env.params.query["height"]?.try &.to_i
if !width && !height
response = JSON.build do |json|
json.object do
json.field "storyboards" do
- Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
+ Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards)
end
end
end
@@ -207,43 +206,48 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "text/vtt"
- storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" }
+ # Select a storyboard matching the user's provided width/height
+ storyboard = video.storyboards.select { |x| x.width == width || x.height == height }
+ haltf env, 404 if storyboard.empty?
- if storyboard.empty?
- haltf env, 404
- else
- storyboard = storyboard[0]
- end
+ # Alias variable, to make the code below esaier to read
+ sb = storyboard[0]
- String.build do |str|
- str << <<-END_VTT
- WEBVTT
- END_VTT
+ # Some base URL segments that we'll use to craft the final URLs
+ work_url = sb.proxied_url.dup
+ template_path = sb.proxied_url.path
- start_time = 0.milliseconds
- end_time = storyboard[:interval].milliseconds
+ # Initialize cue timing variables
+ # NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap
+ # (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000)
+ time_delta = sb.interval.milliseconds
+ start_time = 0.milliseconds
+ end_time = time_delta
- storyboard[:storyboard_count].times do |i|
- url = storyboard[:url]
- authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
- url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
- url = "#{HOST_URL}/sb/#{authority}/#{url}"
+ # Build a VTT file for VideoJS-vtt plugin
+ vtt_file = WebVTT.build do |vtt|
+ sb.images_count.times do |i|
+ # Replace the variable component part of the path
+ work_url.path = template_path.sub("$M", i)
- storyboard[:storyboard_height].times do |j|
- storyboard[:storyboard_width].times do |k|
- str << <<-END_CUE
- #{start_time}.000 --> #{end_time}.000
- #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}
+ sb.rows.times do |j|
+ sb.columns.times do |k|
+ # The URL fragment represents the offset of the thumbnail inside the storyboard image
+ work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}"
+ vtt.cue(start_time, end_time, work_url.to_s)
- END_CUE
-
- start_time += storyboard[:interval].milliseconds
- end_time += storyboard[:interval].milliseconds
+ start_time += time_delta
+ end_time += time_delta
end
end
end
end
+
+ # videojs-vtt-thumbnails is not compliant to the VTT specification, it
+ # doesn't unescape the HTML entities, so we have to do it here:
+ # TODO: remove this when we migrate to VideoJS 8
+ return HTML.unescape(vtt_file)
end
def self.annotations(env)
@@ -264,7 +268,7 @@ module Invidious::Routes::API::V1::Videos
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')
+ index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0')
# IA doesn't handle leading hyphens,
# so we use https://archive.org/details/youtubeannotations_64
@@ -382,4 +386,47 @@ module Invidious::Routes::API::V1::Videos
end
end
end
+
+ def self.clips(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ env.response.content_type = "application/json"
+
+ clip_id = env.params.url["id"]
+ region = env.params.query["region"]?
+ proxy = {"1", "true"}.any? &.== env.params.query["local"]?
+
+ response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
+ return error_json(400, "Invalid clip ID") if response["error"]?
+
+ video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s
+ return error_json(400, "Invalid clip ID") if video_id.nil?
+
+ start_time = nil
+ end_time = nil
+ clip_title = nil
+
+ if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
+ start_time, end_time, clip_title = parse_clip_parameters(params)
+ end
+
+ begin
+ video = get_video(video_id, region: region)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ return error_json(500, ex)
+ end
+
+ return JSON.build do |json|
+ json.object do
+ json.field "startTime", start_time
+ json.field "endTime", end_time
+ json.field "clipTitle", clip_title
+ json.field "video" do
+ Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy)
+ end
+ end
+ end
+ end
end
diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr
index 396840a4..5695dee9 100644
--- a/src/invidious/routes/before_all.cr
+++ b/src/invidious/routes/before_all.cr
@@ -30,7 +30,7 @@ module Invidious::Routes::BeforeAll
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
- frame_ancestors = "'self' http: https:"
+ frame_ancestors = "'self' file: http: https:"
else
frame_ancestors = "'none'"
end
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 9892ae2a..7d634cbb 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -1,6 +1,12 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::Channels
+ # Redirection for unsupported routes ("tabs")
+ def self.redirect_home(env)
+ ucid = env.params.url["ucid"]
+ return env.redirect "/channel/#{URI.encode_www_form(ucid)}"
+ end
+
def self.home(env)
self.videos(env)
end
@@ -14,10 +20,11 @@ module Invidious::Routes::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase
if channel.auto_generated
+ sort_by ||= "last"
sort_options = {"last", "oldest", "newest"}
items, next_continuation = fetch_channel_playlists(
- channel.ucid, channel.author, continuation, (sort_by || "last")
+ channel.ucid, channel.author, continuation, sort_by
)
items.uniq! do |item|
@@ -30,12 +37,26 @@ module Invidious::Routes::Channels
items = items.select(SearchPlaylist)
items.each(&.author = "")
else
- sort_options = {"newest", "oldest", "popular"}
-
# Fetch items and continuation token
- items, next_continuation = Channel::Tabs.get_videos(
- channel, continuation: continuation, sort_by: (sort_by || "newest")
- )
+ if channel.is_age_gated
+ sort_by = ""
+ sort_options = [] of String
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
+ items = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ items = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ sort_by ||= "newest"
+ sort_options = {"newest", "oldest", "popular"}
+
+ items, next_continuation = Channel::Tabs.get_60_videos(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ end
end
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
@@ -52,14 +73,26 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
- # TODO: support sort option for shorts
- sort_by = ""
- sort_options = [] of String
+ if channel.is_age_gated
+ sort_by = ""
+ sort_options = [] of String
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
+ items = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ items = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
+ sort_options = {"newest", "oldest", "popular"}
- # Fetch items and continuation token
- items, next_continuation = Channel::Tabs.get_shorts(
- channel, continuation: continuation
- )
+ # Fetch items and continuation token
+ items, next_continuation = Channel::Tabs.get_shorts(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ end
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
templated "channel"
@@ -75,14 +108,26 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
- # TODO: support sort option for livestreams
- sort_by = ""
- sort_options = [] of String
+ if channel.is_age_gated
+ sort_by = ""
+ sort_options = [] of String
+ begin
+ playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
+ items = get_playlist_videos(playlist, offset: 0)
+ rescue ex : InfoException
+ # playlist doesnt exist.
+ items = [] of PlaylistVideo
+ end
+ next_continuation = nil
+ else
+ sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
+ sort_options = {"newest", "oldest", "popular"}
- # Fetch items and continuation token
- items, next_continuation = Channel::Tabs.get_60_livestreams(
- channel, continuation: continuation
- )
+ # Fetch items and continuation token
+ items, next_continuation = Channel::Tabs.get_60_livestreams(
+ channel, continuation: continuation, sort_by: sort_by
+ )
+ end
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
templated "channel"
@@ -159,6 +204,11 @@ module Invidious::Routes::Channels
end
locale, user, subscriptions, continuation, ucid, channel = data
+ # redirect to post page
+ if lb = env.params.query["lb"]?
+ env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}"
+ end
+
thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode
thin_mode = thin_mode == "true"
@@ -187,6 +237,44 @@ module Invidious::Routes::Channels
templated "community"
end
+ def self.post(env)
+ # /post/{postId}
+ id = env.params.url["id"]
+ ucid = env.params.query["ucid"]?
+
+ prefs = env.get("preferences").as(Preferences)
+
+ locale = prefs.locale
+
+ thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode
+ thin_mode = thin_mode == "true"
+
+ nojs = env.params.query["nojs"]?
+
+ nojs ||= "0"
+ nojs = nojs == "1"
+
+ if !ucid.nil?
+ ucid = ucid.to_s
+ post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
+ else
+ # resolve the url to get the author's UCID
+ response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
+ return error_template(400, "Invalid post ID") if response["error"]?
+
+ ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s
+ post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
+ end
+
+ post_response = JSON.parse(post_response)
+
+ if nojs
+ comments = Comments.fetch_community_post_comments(ucid, id)
+ comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, is_post: true))["contentHtml"]
+ end
+ templated "post"
+ end
+
def self.channels(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
@@ -217,6 +305,11 @@ module Invidious::Routes::Channels
env.redirect "/channel/#{ucid}"
end
+ private KNOWN_TABS = {
+ "home", "videos", "shorts", "streams", "podcasts",
+ "releases", "playlists", "community", "channels", "about",
+ }
+
# Redirects brand url channels to a normal /channel/:ucid route
def self.brand_redirect(env)
locale = env.get("preferences").as(Preferences).locale
@@ -227,7 +320,10 @@ module Invidious::Routes::Channels
yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"]))
# Retrieves URL params that only Invidious uses
- invidious_url_params = URI::Params.encode(env.params.query.to_h.select!(["a", "u", "user"]))
+ invidious_url_params = env.params.query.dup
+ invidious_url_params.delete_all("a")
+ invidious_url_params.delete_all("u")
+ invidious_url_params.delete_all("user")
begin
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
@@ -236,14 +332,17 @@ module Invidious::Routes::Channels
return error_template(404, translate(locale, "This channel does not exist."))
end
- selected_tab = env.request.path.split("/")[-1]
- if {"home", "videos", "shorts", "streams", "playlists", "community", "channels", "about"}.includes? selected_tab
+ selected_tab = env.params.url["tab"]?
+
+ if KNOWN_TABS.includes? selected_tab
url = "/channel/#{ucid}/#{selected_tab}"
else
url = "/channel/#{ucid}"
end
- env.redirect url
+ url += "?#{invidious_url_params}" if !invidious_url_params.empty?
+
+ return env.redirect url
end
# Handles redirects for the /profile endpoint
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 266f7ba4..00f24159 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -157,10 +157,12 @@ module Invidious::Routes::Embed
adaptive_fmts = video.adaptive_fmts
if params.local
- fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
- adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
+ fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
end
+ # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
+ adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
+
video_streams = video.video_streams
audio_streams = video.audio_streams
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index 40bca008..82c04994 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -192,11 +192,10 @@ module Invidious::Routes::Feeds
views: views,
description_html: description_html,
length_seconds: 0,
- live_now: false,
- paid: false,
- premium: false,
premiere_timestamp: nil,
author_verified: false,
+ author_thumbnail: nil,
+ badges: VideoBadges::None,
})
end
@@ -407,14 +406,23 @@ module Invidious::Routes::Feeds
end
spawn do
- rss = XML.parse_html(body)
- rss.xpath_nodes("//feed/entry").each do |entry|
- id = entry.xpath_node("videoid").not_nil!.content
- author = entry.xpath_node("author/name").not_nil!.content
- 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, force_refresh: true)
+ # TODO: unify this with the other almost identical looking parts in this and channels.cr somehow?
+ namespaces = {
+ "yt" => "http://www.youtube.com/xml/schemas/2015",
+ "default" => "http://www.w3.org/2005/Atom",
+ }
+ rss = XML.parse(body)
+ rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry|
+ id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content
+ author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
+ published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content)
+ updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
+
+ begin
+ video = get_video(id, force_refresh: true)
+ rescue
+ next # skip this video since it raised an exception (e.g. it is a scheduled live event)
+ end
if CONFIG.enable_user_notifications
# Deliver notifications to `/api/v1/auth/notifications`
diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr
index b6a2e110..639697db 100644
--- a/src/invidious/routes/images.cr
+++ b/src/invidious/routes/images.cr
@@ -11,29 +11,9 @@ module Invidious::Routes::Images
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
- HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
- return request_proc.call(resp)
+ GGPHT_POOL.client &.get(url, headers) do |resp|
+ return self.proxy_image(env, resp)
end
rescue ex
end
@@ -61,27 +41,10 @@ module Invidious::Routes::Images
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
- HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
- return request_proc.call(resp)
+ get_ytimg_pool(authority).client &.get(url, headers) do |resp|
+ env.response.headers["Connection"] = "close"
+ return self.proxy_image(env, resp)
end
rescue ex
end
@@ -101,26 +64,9 @@ module Invidious::Routes::Images
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
- HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
- return request_proc.call(resp)
+ get_ytimg_pool("i9").client &.get(url, headers) do |resp|
+ return self.proxy_image(env, resp)
end
rescue ex
end
@@ -165,8 +111,7 @@ module Invidious::Routes::Images
if name == "maxres.jpg"
build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
- # 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
+ if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200
name = thumb[:url] + ".jpg"
break
end
@@ -181,29 +126,28 @@ module Invidious::Routes::Images
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
+ begin
+ get_ytimg_pool("i").client &.get(url, headers) do |resp|
+ return self.proxy_image(env, resp)
end
+ rescue ex
+ 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")
+ private def self.proxy_image(env, 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
- proxy_file(response, env)
- }
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
- begin
- # 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
- rescue ex
+ if response.status_code >= 300
+ return env.response.headers.delete("Transfer-Encoding")
end
+
+ return proxy_file(response, env)
end
end
diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr
index d6bd9571..8b620d63 100644
--- a/src/invidious/routes/misc.cr
+++ b/src/invidious/routes/misc.cr
@@ -40,7 +40,16 @@ module Invidious::Routes::Misc
def self.cross_instance_redirect(env)
referer = get_referer(env)
- instance_url = fetch_random_instance
+
+ instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"]
+ if instance_list.empty?
+ instance_url = "redirect.invidious.io"
+ else
+ # Sample returns an array
+ # Instances are packaged as {region, domain} in the instance list
+ instance_url = instance_list.sample(1)[0][1]
+ end
+
env.redirect "https://#{instance_url}#{referer}"
end
end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index 9c6843e9..f2213da4 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -304,23 +304,6 @@ module Invidious::Routes::Playlists
end
end
- if env.params.query["action_create_playlist"]?
- action = "action_create_playlist"
- elsif env.params.query["action_delete_playlist"]?
- action = "action_delete_playlist"
- elsif env.params.query["action_edit_playlist"]?
- action = "action_edit_playlist"
- elsif env.params.query["action_add_video"]?
- action = "action_add_video"
- video_id = env.params.query["video_id"]
- elsif env.params.query["action_remove_video"]?
- action = "action_remove_video"
- elsif env.params.query["action_move_video_before"]?
- action = "action_move_video_before"
- else
- return env.redirect referer
- end
-
begin
playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
@@ -335,12 +318,8 @@ module Invidious::Routes::Playlists
end
end
- email = user.email
-
- case action
- when "action_edit_playlist"
- # TODO: Playlist stub
- when "action_add_video"
+ case action = env.params.query["action"]?
+ when "add_video"
if playlist.index.size >= CONFIG.playlist_length_limit
if redirect
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
@@ -377,12 +356,14 @@ module Invidious::Routes::Playlists
Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
- when "action_remove_video"
+ when "remove_video"
index = env.params.query["set_video_id"]
Invidious::Database::PlaylistVideos.delete(index)
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
- when "action_move_video_before"
+ when "move_video_before"
# TODO: Playlist stub
+ when nil
+ return error_json(400, "Missing action")
else
return error_json(400, "Unsupported action #{action}")
end
diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr
index abe0f34e..39ca77c0 100644
--- a/src/invidious/routes/preferences.cr
+++ b/src/invidious/routes/preferences.cr
@@ -27,6 +27,10 @@ module Invidious::Routes::PreferencesRoute
annotations_subscribed ||= "off"
annotations_subscribed = annotations_subscribed == "on"
+ preload = env.params.body["preload"]?.try &.as(String)
+ preload ||= "off"
+ preload = preload == "on"
+
autoplay = env.params.body["autoplay"]?.try &.as(String)
autoplay ||= "off"
autoplay = autoplay == "on"
@@ -144,6 +148,7 @@ module Invidious::Routes::PreferencesRoute
preferences = Preferences.from_json({
annotations: annotations,
annotations_subscribed: annotations_subscribed,
+ preload: preload,
autoplay: autoplay,
captions: captions,
comments: comments,
@@ -214,7 +219,7 @@ 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)
+ CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.presence
File.write("config/config.yml", CONFIG.to_yaml)
end
@@ -319,6 +324,15 @@ module Invidious::Routes::PreferencesRoute
response: error_template(415, "Invalid playlist file uploaded")
)
end
+ when "import_youtube_wh"
+ filename = part.filename || ""
+ success = Invidious::User::Import.from_youtube_wh(user, body, filename, type)
+
+ if !success
+ haltf(env, status_code: 415,
+ response: error_template(415, "Invalid watch history file uploaded")
+ )
+ end
when "import_freetube"
Invidious::User::Import.from_freetube(user, body)
when "import_newpipe_subscriptions"
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 5be33533..44970922 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -51,6 +51,12 @@ module Invidious::Routes::Search
else
user = env.get? "user"
+ # An URL was copy/pasted in the search box.
+ # Redirect the user to the appropriate page.
+ if query.url?
+ return env.redirect UrlSanitizer.process(query.text).to_s
+ end
+
begin
items = query.process
rescue ex : ChannelSearchException
diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr
index 7f9ec592..1de655d2 100644
--- a/src/invidious/routes/subscriptions.cr
+++ b/src/invidious/routes/subscriptions.cr
@@ -32,24 +32,16 @@ module Invidious::Routes::Subscriptions
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 ||= ""
- case action
- when "action_create_subscription_to_channel"
+ case action = env.params.query["action"]?
+ when "create_subscription_to_channel"
if !user.subscriptions.includes? channel_id
get_channel(channel_id)
Invidious::Database::Users.subscribe_channel(user, channel_id)
end
- when "action_remove_subscriptions"
+ when "remove_subscriptions"
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
else
return error_json(400, "Unsupported action #{action}")
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index 9641e01a..a8f9f665 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -42,7 +42,7 @@ module Invidious::Routes::VideoPlayback
headers["Range"] = "bytes=#{range_for_head}"
end
- client = make_client(URI.parse(host), region)
+ client = make_client(URI.parse(host), region, force_resolve: true)
response = HTTP::Client::Response.new(500)
error = ""
5.times do
@@ -57,7 +57,7 @@ module Invidious::Routes::VideoPlayback
if new_host != host
host = new_host
client.close
- client = make_client(URI.parse(new_host), region)
+ client = make_client(URI.parse(new_host), region, force_resolve: true)
end
url = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
@@ -71,7 +71,7 @@ module Invidious::Routes::VideoPlayback
fvip = "3"
host = "https://r#{fvip}---#{mn}.googlevideo.com"
- client = make_client(URI.parse(host), region)
+ client = make_client(URI.parse(host), region, force_resolve: true)
rescue ex
error = ex.message
end
@@ -80,9 +80,14 @@ module Invidious::Routes::VideoPlayback
# Remove the Range header added previously.
headers.delete("Range") if range_header.nil?
+ playback_statistics = get_playback_statistic()
+ playback_statistics["totalRequests"] += 1
+
if response.status_code >= 400
env.response.content_type = "text/plain"
haltf env, response.status_code
+ else
+ playback_statistics["successfulRequests"] += 1
end
if url.includes? "&file=seg.ts"
@@ -126,7 +131,7 @@ module Invidious::Routes::VideoPlayback
end
# TODO: Record bytes written so we can restart after a chunk fails
- while true
+ loop do
if !range_end && content_length
range_end = content_length
end
@@ -159,10 +164,13 @@ module Invidious::Routes::VideoPlayback
env.response.headers["Access-Control-Allow-Origin"] = "*"
if location = resp.headers["Location"]?
- location = URI.parse(location)
- location = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
+ url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region)
+
+ if title = query_params["title"]?
+ url = "#{url}&title=#{URI.encode_www_form(title)}"
+ end
- env.redirect location
+ env.redirect url
break
end
@@ -191,7 +199,7 @@ module Invidious::Routes::VideoPlayback
break
else
client.close
- client = make_client(URI.parse(host), region)
+ client = make_client(URI.parse(host), region, force_resolve: true)
end
end
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index e5cf3716..1f384546 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -30,14 +30,6 @@ module Invidious::Routes::Watch
return env.redirect "/"
end
- embed_link = "/embed/#{id}"
- if env.params.query.size > 1
- embed_params = HTTP::Params.parse(env.params.query.to_s)
- embed_params.delete_all("v")
- embed_link += "?"
- embed_link += embed_params.to_s
- end
-
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
continuation = process_continuation(env.params.query, plid, id)
@@ -129,10 +121,12 @@ module Invidious::Routes::Watch
adaptive_fmts = video.adaptive_fmts
if params.local
- fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
- adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
+ fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
end
+ # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
+ adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
+
video_streams = video.video_streams
audio_streams = video.audio_streams
@@ -249,18 +243,10 @@ module Invidious::Routes::Watch
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"
+ case action = env.params.query["action"]?
+ when "mark_watched"
Invidious::Database::Users.mark_watched(user, id)
- when "action_mark_unwatched"
+ when "mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id)
else
return error_json(400, "Unsupported action #{action}")
@@ -283,6 +269,12 @@ module Invidious::Routes::Watch
return error_template(400, "Invalid clip ID") if response["error"]?
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
+ if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
+ start_time, end_time, _ = parse_clip_parameters(params)
+ env.params.query["start"] = start_time.to_s if start_time != nil
+ env.params.query["end"] = end_time.to_s if end_time != nil
+ end
+
return env.redirect "/watch?v=#{video_id}&#{env.params.query}"
else
return error_template(404, "The requested clip doesn't exist")
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 9c43171c..9009062f 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -124,28 +124,42 @@ module Invidious::Routing
get "/channel/:ucid/community", Routes::Channels, :community
get "/channel/:ucid/channels", Routes::Channels, :channels
get "/channel/:ucid/about", Routes::Channels, :about
+
get "/channel/:ucid/live", Routes::Channels, :live
get "/user/:user/live", Routes::Channels, :live
get "/c/:user/live", Routes::Channels, :live
+ get "/post/:id", Routes::Channels, :post
- {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path|
- # /c/LinusTechTips
- get "/c/:user#{path}", Routes::Channels, :brand_redirect
- # /user/linustechtips | Not always the same as /c/
- get "/user/:user#{path}", Routes::Channels, :brand_redirect
- # /@LinusTechTips | Handle
- get "/@:user#{path}", Routes::Channels, :brand_redirect
- # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
- get "/attribution_link#{path}", Routes::Channels, :brand_redirect
- # /profile?user=linustechtips
- get "/profile/#{path}", Routes::Channels, :profile
- end
+ # Channel catch-all, to redirect future routes to the channel's home
+ # NOTE: defined last in order to be processed after the other routes
+ get "/channel/:ucid/*", Routes::Channels, :redirect_home
+
+ # /c/LinusTechTips
+ get "/c/:user", Routes::Channels, :brand_redirect
+ get "/c/:user/:tab", Routes::Channels, :brand_redirect
+
+ # /user/linustechtips (Not always the same as /c/)
+ get "/user/:user", Routes::Channels, :brand_redirect
+ get "/user/:user/:tab", Routes::Channels, :brand_redirect
+
+ # /@LinusTechTips (Handle)
+ get "/@:user", Routes::Channels, :brand_redirect
+ get "/@:user/:tab", Routes::Channels, :brand_redirect
+
+ # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
+ get "/attribution_link", Routes::Channels, :brand_redirect
+ get "/attribution_link/:tab", Routes::Channels, :brand_redirect
+
+ # /profile?user=linustechtips
+ get "/profile", Routes::Channels, :profile
+ get "/profile/*", Routes::Channels, :profile
end
def register_watch_routes
get "/watch", Routes::Watch, :handle
post "/watch_ajax", Routes::Watch, :mark_watched
get "/watch/:id", Routes::Watch, :redirect
+ get "/live/:id", Routes::Watch, :redirect
get "/shorts/:id", Routes::Watch, :redirect
get "/clip/:clip", Routes::Watch, :clip
get "/w/:id", Routes::Watch, :redirect
@@ -221,6 +235,7 @@ module Invidious::Routing
get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
+ get "/api/v1/clips/:id", {{namespace}}::Videos, :clips
# Feeds
get "/api/v1/trending", {{namespace}}::Feeds, :trending
@@ -228,17 +243,20 @@ module Invidious::Routing
# Channels
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
+ get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest
+ get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
-
+ get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists
+ get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
+ get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search
- {% for route in {"videos", "latest", "playlists", "community", "search"} %}
- get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
- get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
- {% end %}
+ # Posts
+ get "/api/v1/post/:id", {{namespace}}::Channels, :post
+ get "/api/v1/post/:id/comments", {{namespace}}::Channels, :post_comments
# 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
@@ -249,12 +267,8 @@ module Invidious::Routing
get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag
- # Authenticated
- # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
- #
- # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
- # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+ # Authenticated
get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr
index c2b5c758..bc2715cf 100644
--- a/src/invidious/search/filters.cr
+++ b/src/invidious/search/filters.cr
@@ -75,7 +75,7 @@ module Invidious::Search
@type : Type = Type::All,
@duration : Duration = Duration::None,
@features : Features = Features::None,
- @sort : Sort = Sort::Relevance
+ @sort : Sort = Sort::Relevance,
)
end
@@ -300,9 +300,9 @@ module Invidious::Search
object["9:varint"] = ((page - 1) * 20).to_i64
end
- # If the object is empty, return an empty string,
- # otherwise encode to protobuf then to base64
- return "" if object.empty?
+ # Prevent censoring of self harm topics
+ # See https://github.com/iv-org/invidious/issues/4398
+ object["30:varint"] = 1.to_i64
return object
.try { |i| Protodec::Any.cast_json(i) }
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index e38845d9..94a92e23 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -20,6 +20,9 @@ module Invidious::Search
property region : String?
property channel : String = ""
+ # Flag that indicates if the smart search features have been disabled.
+ @inhibit_ssf : Bool = false
+
# Return true if @raw_query is either `nil` or empty
private def empty_raw_query?
return @raw_query.empty?
@@ -44,14 +47,22 @@ module Invidious::Search
def initialize(
params : HTTP::Params,
@type : Type = Type::Regular,
- @region : String? = nil
+ @region : String? = nil,
)
# Get the raw search query string (common to all search types). In
# Regular search mode, also look for the `search_query` URL parameter
- if @type.regular?
- @raw_query = params["q"]? || params["search_query"]? || ""
- else
- @raw_query = params["q"]? || ""
+ _raw_query = params["q"]?
+ _raw_query ||= params["search_query"]? if @type.regular?
+ _raw_query ||= ""
+
+ # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs.
+ @raw_query = _raw_query.strip
+
+ # Check for smart features (ex: URL search) inhibitor (backslash).
+ # If inhibitor is present, remove it.
+ if @raw_query.starts_with?('\\')
+ @inhibit_ssf = true
+ @raw_query = @raw_query[1..]
end
# Get the page number (also common to all search types)
@@ -85,7 +96,7 @@ module Invidious::Search
@filters = Filters.from_iv_params(params)
@channel = params["channel"]? || ""
- if @filters.default? && @raw_query.includes?(':')
+ if @filters.default? && @raw_query.index(/\w:\w/)
# Parse legacy filters from query
@filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query)
else
@@ -136,5 +147,22 @@ module Invidious::Search
return params
end
+
+ # Checks if the query is a standalone URL
+ def url? : Bool
+ # If the smart features have been inhibited, don't go further.
+ return false if @inhibit_ssf
+
+ # Only supported in regular search mode
+ return false if !@type.regular?
+
+ # If filters are present, that's a regular search
+ return false if !@filters.default?
+
+ # Simple heuristics: domain name
+ return @raw_query.starts_with?(
+ /(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\//
+ )
+ end
end
end
diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr
index 2d9f8a83..107d148d 100644
--- a/src/invidious/trending.cr
+++ b/src/invidious/trending.cr
@@ -22,12 +22,14 @@ def fetch_trending(trending_type, region, locale)
extracted = [] of SearchItem
+ deduplicate = items.size > 1
+
items.each do |itm|
if itm.is_a?(Category)
# Ignore the smaller categories, as they generally contain a sponsored
# channel, which brings a lot of noise on the trending page.
# See: https://github.com/iv-org/invidious/issues/2989
- next if itm.contents.size < 24
+ next if (itm.contents.size < 24 && deduplicate)
extracted.concat extract_category(itm)
else
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
index 86d0ce6e..007eb666 100644
--- a/src/invidious/user/imports.cr
+++ b/src/invidious/user/imports.cr
@@ -115,7 +115,7 @@ struct Invidious::User
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 }
+ privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state }
next if !title
next if !description
@@ -124,7 +124,7 @@ struct Invidious::User
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|
+ item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
if idx > CONFIG.playlist_length_limit
raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
end
@@ -161,7 +161,7 @@ struct Invidious::User
# Youtube
# -------------------
- private def is_opml?(mimetype : String, extension : String)
+ private def opml?(mimetype : String, extension : String)
opml_mimetypes = [
"application/xml",
"text/xml",
@@ -179,10 +179,10 @@ struct Invidious::User
def from_youtube(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last
- if is_opml?(type, extension)
+ if opml?(type, extension)
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]
+ channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0]
end
elsif extension == "json" || type == "application/json"
subscriptions = JSON.parse(body)
@@ -218,6 +218,26 @@ struct Invidious::User
end
end
+ def from_youtube_wh(user : User, body : String, filename : String, type : String) : Bool
+ extension = filename.split(".").last
+
+ if extension == "json" || type == "application/json"
+ data = JSON.parse(body)
+ watched = data.as_a.compact_map do |item|
+ next unless url = item["titleUrl"]?
+ next unless match = url.as_s.match(/\?v=(?<video_id>[a-zA-Z0-9_-]+)$/)
+ match["video_id"]
+ end
+ watched.reverse! # YouTube have newest first
+ user.watched += watched
+ user.watched.uniq!
+ Invidious::Database::Users.update_watch_history(user)
+ return true
+ else
+ return false
+ end
+ end
+
# -------------------
# Freetube
# -------------------
@@ -228,8 +248,12 @@ struct Invidious::User
subs = matches.map(&.["channel_id"])
if subs.empty?
- data = JSON.parse(body)["subscriptions"]
- subs = data.as_a.map(&.["id"].as_s)
+ profiles = body.split('\n', remove_empty: true)
+ profiles.each do |profile|
+ if data = JSON.parse(profile)["subscriptions"]?
+ subs += data.as_a.map(&.["id"].as_s)
+ end
+ end
end
user.subscriptions += subs
@@ -266,42 +290,39 @@ struct Invidious::User
end
def from_newpipe(user : User, body : String) : Bool
- io = IO::Memory.new(body)
-
- Compress::Zip::File.open(io) do |file|
- file.entries.each do |entry|
- entry.open do |file_io|
- # Ensure max size of 4MB
- io_sized = IO::Sized.new(file_io, 0x400000)
-
- next if entry.filename != "newpipe.db"
-
- tempfile = File.tempfile(".db")
-
- begin
- File.write(tempfile.path, io_sized.gets_to_end)
- rescue
- return false
- end
-
- db = DB.open("sqlite3://" + tempfile.path)
+ Compress::Zip::File.open(IO::Memory.new(body), true) do |file|
+ entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" }
+ return false if entry.nil?
+ entry.open do |file_io|
+ # Ensure max size of 4MB
+ io_sized = IO::Sized.new(file_io, 0x400000)
- user.watched += db.query_all("SELECT url FROM streams", as: String)
- .map(&.lchop("https://www.youtube.com/watch?v="))
+ begin
+ temp = File.tempfile(".db") do |tempfile|
+ begin
+ File.write(tempfile.path, io_sized.gets_to_end)
+ rescue
+ return false
+ end
- user.watched.uniq!
- Invidious::Database::Users.update_watch_history(user)
+ DB.open("sqlite3://" + tempfile.path) do |db|
+ user.watched += db.query_all("SELECT url FROM streams", as: String)
+ .map(&.lchop("https://www.youtube.com/watch?v="))
- user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
- .map(&.lchop("https://www.youtube.com/channel/"))
+ user.watched.uniq!
+ Invidious::Database::Users.update_watch_history(user)
- user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions)
+ user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
+ .map(&.lchop("https://www.youtube.com/channel/"))
- Invidious::Database::Users.update_subscriptions(user)
+ user.subscriptions.uniq!
+ user.subscriptions = get_batch_channels(user.subscriptions)
- db.close
- tempfile.delete
+ Invidious::Database::Users.update_subscriptions(user)
+ end
+ end
+ ensure
+ temp.delete if !temp.nil?
end
end
end
diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr
index b3059403..0a8525f3 100644
--- a/src/invidious/user/preferences.cr
+++ b/src/invidious/user/preferences.cr
@@ -4,6 +4,7 @@ struct Preferences
property annotations : Bool = CONFIG.default_user_preferences.annotations
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
+ property preload : Bool = CONFIG.default_user_preferences.preload
property autoplay : Bool = CONFIG.default_user_preferences.autoplay
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 9fbd1374..962f87bd 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -27,12 +27,6 @@ struct Video
@captions = [] of Invidious::Videos::Captions::Metadata
@[DB::Field(ignore: true)]
- property adaptive_fmts : Array(Hash(String, JSON::Any))?
-
- @[DB::Field(ignore: true)]
- property fmt_stream : Array(Hash(String, JSON::Any))?
-
- @[DB::Field(ignore: true)]
property description : String?
module JSONConverter
@@ -82,6 +76,10 @@ struct Video
return (self.video_type == VideoType::Livestream)
end
+ def post_live_dvr
+ return info["isPostLiveDvr"].as_bool
+ end
+
def premiere_timestamp : Time?
info
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
@@ -94,45 +92,24 @@ struct Video
# Methods for parsing streaming data
- def fmt_stream
- return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
-
- fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
- fmt_stream.each do |fmt|
- if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
- s.each do |k, v|
- fmt[k] = JSON::Any.new(v)
- end
- fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
- end
-
- fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
- fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
+ def fmt_stream : Array(Hash(String, JSON::Any))
+ if formats = info.dig?("streamingData", "formats")
+ return formats
+ .as_a.map(&.as_h)
+ .sort_by! { |f| f["width"]?.try &.as_i || 0 }
+ else
+ return [] of Hash(String, JSON::Any)
end
-
- fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
- @fmt_stream = fmt_stream
- return @fmt_stream.as(Array(Hash(String, JSON::Any)))
end
- def adaptive_fmts
- return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts
- fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
- fmt_stream.each do |fmt|
- if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
- s.each do |k, v|
- fmt[k] = JSON::Any.new(v)
- end
- fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
- end
-
- fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
- fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
+ def adaptive_fmts : Array(Hash(String, JSON::Any))
+ if formats = info.dig?("streamingData", "adaptiveFormats")
+ return formats
+ .as_a.map(&.as_h)
+ .sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 }
+ else
+ return [] of Hash(String, JSON::Any)
end
-
- fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
- @adaptive_fmts = fmt_stream
- return @adaptive_fmts.as(Array(Hash(String, JSON::Any)))
end
def video_streams
@@ -146,65 +123,8 @@ struct Video
# Misc. methods
def storyboards
- storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
- .try &.as_s.split("|")
-
- if !storyboards
- if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
- return [{
- url: storyboard.split("#")[0],
- width: 106,
- height: 60,
- count: -1,
- interval: 5000,
- storyboard_width: 3,
- storyboard_height: 3,
- storyboard_count: -1,
- }]
- end
- end
-
- items = [] of NamedTuple(
- url: String,
- width: Int32,
- height: Int32,
- count: Int32,
- interval: Int32,
- storyboard_width: Int32,
- storyboard_height: Int32,
- storyboard_count: Int32)
-
- return items if !storyboards
-
- url = URI.parse(storyboards.shift)
- params = HTTP::Params.parse(url.query || "")
-
- storyboards.each_with_index do |sb, i|
- width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
- params["sigh"] = sigh
- url.query = params.to_s
-
- width = width.to_i
- height = height.to_i
- count = count.to_i
- interval = interval.to_i
- storyboard_width = storyboard_width.to_i
- storyboard_height = storyboard_height.to_i
- storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
-
- items << {
- url: url.to_s.sub("$L", i).sub("$N", "M$M"),
- width: width,
- height: height,
- count: count,
- interval: interval,
- storyboard_width: storyboard_width,
- storyboard_height: storyboard_height,
- storyboard_count: storyboard_count,
- }
- end
-
- items
+ container = info.dig?("storyboards") || JSON::Any.new("{}")
+ return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds)
end
def paid
@@ -227,15 +147,29 @@ struct Video
info.dig?("streamingData", "hlsManifestUrl").try &.as_s
end
- def dash_manifest_url
- info.dig?("streamingData", "dashManifestUrl").try &.as_s
+ def dash_manifest_url : String?
+ raw_dash_url = info.dig?("streamingData", "dashManifestUrl").try &.as_s
+ return nil if raw_dash_url.nil?
+
+ # Use manifest v5 parameter to reduce file size
+ # See https://github.com/iv-org/invidious/issues/4186
+ dash_url = URI.parse(raw_dash_url)
+ dash_query = dash_url.query || ""
+
+ if dash_query.empty?
+ dash_url.path = "#{dash_url.path}/mpd_version/5"
+ else
+ dash_url.query = "#{dash_query}&mpd_version=5"
+ end
+
+ return dash_url.to_s
end
def genre_url : String?
- info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil
+ info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
end
- def is_vr : Bool?
+ def vr? : Bool?
return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type
end
@@ -316,6 +250,21 @@ struct Video
{% if flag?(:debug_macros) %} {{debug}} {% end %}
end
+ # Macro to generate ? and = accessor methods for attributes in `info`
+ private macro predicate_bool(method_name, name)
+ # Return {{name.stringify}} from `info`
+ def {{method_name.id.underscore}}? : Bool
+ return info[{{name.stringify}}]?.try &.as_bool || false
+ end
+
+ # Update {{name.stringify}} into `info`
+ def {{method_name.id.underscore}}=(value : Bool)
+ info[{{name.stringify}}] = JSON::Any.new(value)
+ end
+
+ {% if flag?(:debug_macros) %} {{debug}} {% end %}
+ end
+
# Method definitions, using the macros above
getset_string author
@@ -337,11 +286,12 @@ struct Video
getset_i64 likes
getset_i64 views
+ # TODO: Make predicate_bool the default as to adhere to Crystal conventions
getset_bool allowRatings
getset_bool authorVerified
getset_bool isFamilyFriendly
getset_bool isListed
- getset_bool isUpcoming
+ predicate_bool upcoming, isUpcoming
end
def get_video(id, refresh = true, region = nil, force_refresh = false)
@@ -376,21 +326,6 @@ end
def fetch_video(id, region)
info = extract_video_info(video_id: id)
- allowed_regions = info
- .dig?("microformat", "playerMicroformatRenderer", "availableCountries")
- .try &.as_a.map &.as_s || [] of String
-
- # Check for region-blocks
- if info["reason"]?.try &.as_s.includes?("your country")
- bypass_regions = PROXY_LIST.keys & allowed_regions
- if !bypass_regions.empty?
- region = bypass_regions[rand(bypass_regions.size)]
- region_info = extract_video_info(video_id: id, proxy_region: region)
- region_info["region"] = JSON::Any.new(region) if region
- info = region_info if !region_info["reason"]?
- end
- end
-
if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")
diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr
index 256dfcc0..c811cfe1 100644
--- a/src/invidious/videos/caption.cr
+++ b/src/invidious/videos/caption.cr
@@ -52,17 +52,13 @@ module Invidious::Videos
break
end
end
- result = String.build do |result|
- result << <<-END_VTT
- WEBVTT
- Kind: captions
- Language: #{tlang || @language_code}
+ settings_field = {
+ "Kind" => "captions",
+ "Language" => "#{tlang || @language_code}",
+ }
- END_VTT
-
- result << "\n\n"
-
+ result = WebVTT.build(settings_field) do |vtt|
cues.each_with_index do |node, i|
start_time = node["t"].to_f.milliseconds
@@ -76,29 +72,16 @@ module Invidious::Videos
end_time = start_time + duration
end
- # start_time
- result << start_time.hours.to_s.rjust(2, '0')
- result << ':' << start_time.minutes.to_s.rjust(2, '0')
- result << ':' << start_time.seconds.to_s.rjust(2, '0')
- result << '.' << start_time.milliseconds.to_s.rjust(3, '0')
-
- result << " --> "
-
- # end_time
- result << end_time.hours.to_s.rjust(2, '0')
- result << ':' << end_time.minutes.to_s.rjust(2, '0')
- result << ':' << end_time.seconds.to_s.rjust(2, '0')
- result << '.' << end_time.milliseconds.to_s.rjust(3, '0')
-
- result << "\n"
-
- node.children.each do |s|
- result << s.content
+ text = String.build do |io|
+ node.children.each do |s|
+ io << s.content
+ end
end
- result << "\n"
- result << "\n"
+
+ vtt.cue(start_time, end_time, text)
end
end
+
return result
end
end
@@ -140,6 +123,7 @@ module Invidious::Videos
"Esperanto",
"Estonian",
"Filipino",
+ "Filipino (auto-generated)",
"Finnish",
"French",
"French (auto-generated)",
diff --git a/src/invidious/videos/clip.cr b/src/invidious/videos/clip.cr
new file mode 100644
index 00000000..29c57182
--- /dev/null
+++ b/src/invidious/videos/clip.cr
@@ -0,0 +1,22 @@
+require "json"
+
+# returns start_time, end_time and clip_title
+def parse_clip_parameters(params) : {Float64?, Float64?, String?}
+ decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
+ .try { |i| Base64.decode(i) }
+ .try { |i| IO::Memory.new(i) }
+ .try { |i| Protodec::Any.parse(i) }
+
+ start_time = decoded_protobuf
+ .try(&.["50:0:embedded"]["2:1:varint"].as_i64)
+ .try { |i| i/1000 }
+
+ end_time = decoded_protobuf
+ .try(&.["50:0:embedded"]["3:2:varint"].as_i64)
+ .try { |i| i/1000 }
+
+ clip_title = decoded_protobuf
+ .try(&.["50:0:embedded"]["4:3:string"].as_s)
+
+ return start_time, end_time, clip_title
+end
diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr
index 542cb416..1371bebb 100644
--- a/src/invidious/videos/description.cr
+++ b/src/invidious/videos/description.cr
@@ -7,7 +7,19 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
cp = iter.next
break if cp.is_a?(Iterator::Stop)
- str << cp.chr
+ if cp == 0x26 # Ampersand (&)
+ str << "&amp;"
+ elsif cp == 0x27 # Single quote (')
+ str << "&#39;"
+ elsif cp == 0x22 # Double quote (")
+ str << "&quot;"
+ elsif cp == 0x3C # Less-than (<)
+ str << "&lt;"
+ elsif cp == 0x3E # Greater than (>)
+ str << "&gt;"
+ else
+ str << cp.chr
+ end
# A codepoint from the SMP counts twice
copied += 1 if cp > 0xFFFF
@@ -24,7 +36,13 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
- return content if commands.nil?
+ if commands.nil?
+ # Slightly faster than HTML.escape, as we're only doing one pass on
+ # the string instead of five for the standard library
+ return String.build do |str|
+ copy_string(str, content.each_codepoint, content.size)
+ end
+ end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 06ff96b1..5ca4bdb2 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -36,6 +36,13 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
+ if published_time_text = related["publishedTimeText"]?
+ decoded_time = decode_date(published_time_text["simpleText"].to_s)
+ published = decoded_time.to_rfc3339.to_s
+ else
+ published = nil
+ end
+
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
@@ -47,15 +54,16 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
"view_count" => JSON::Any.new(view_count || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified),
+ "published" => JSON::Any.new(published || ""),
}
end
-def extract_video_info(video_id : String, proxy_region : String? = nil)
+def extract_video_info(video_id : String)
# Init client config for the API
- client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
+ client_config = YoutubeAPI::ClientConfig.new
# Fetch data from the player endpoint
- player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
+ player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
@@ -78,6 +86,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil)
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
# Line to be reverted if one day we solve the video not available issue.
+
+ # Although technically not a call to /videoplayback the fact that YouTube is returning the
+ # wrong video means that we should count it as a failure.
+ get_playback_statistic()["totalRequests"] += 1
+
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
@@ -97,38 +110,42 @@ def extract_video_info(video_id : String, proxy_region : String? = nil)
new_player_response = nil
- if reason.nil?
+ # Don't use Android test suite client if po_token is passed because po_token doesn't
+ # work for Android test suite client.
+ if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
- client_config.client_type = YoutubeAPI::ClientType::Android
- new_player_response = try_fetch_streaming_data(video_id, client_config)
- elsif !reason.includes?("your country") # Handled separately
- # The Android embedded client could help here
- client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
- new_player_response = try_fetch_streaming_data(video_id, client_config)
- end
-
- # Last hope
- if new_player_response.nil?
- client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
+ client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Replace player response and reset reason
if !new_player_response.nil?
- # Preserve storyboard data before replacement
+ # Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
+ new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response
params.delete("reason")
end
- {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f|
+ {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
+ # Convert URLs, if those are present
+ if streaming_data = player_response["streamingData"]?
+ %w[formats adaptiveFormats].each do |key|
+ streaming_data.as_h[key]?.try &.as_a.each do |format|
+ format.as_h["url"] = JSON::Any.new(convert_url(format))
+ end
+ end
+
+ params["streamingData"] = streaming_data
+ end
+
# Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
@@ -137,9 +154,7 @@ end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
- # CgIQBg is a workaround for streaming URLs that returns a 403.
- # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520
- response = YoutubeAPI.player(video_id: id, params: "CgIQBg", client_config: client_config)
+ response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config)
playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
@@ -147,7 +162,7 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf
if id != response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
- raise VideoNotAvailableException.new(
+ raise InfoException.new(
"The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)"
)
elsif playability_status == "OK"
@@ -180,10 +195,11 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
end
video_details = player_response.dig?("videoDetails")
- microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
+ if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer"))
+ microformat = {} of String => JSON::Any
+ end
raise BrokenTubeException.new("videoDetails") if !video_details
- raise BrokenTubeException.new("microformat") if !microformat
# Basic video infos
@@ -208,7 +224,19 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
+ premiere_timestamp ||= player_response.dig?(
+ "playabilityStatus", "liveStreamability",
+ "liveStreamabilityRenderer", "offlineSlate",
+ "liveStreamOfflineSlateRenderer", "scheduledStartTime"
+ )
+ .try &.as_s.to_i64
+ .try { |t| Time.unix(t) }
+
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
+ .try &.as_bool
+ live_now ||= video_details.dig?("isLive").try &.as_bool || false
+
+ post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
# Extra video infos
@@ -217,7 +245,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
- family_friendly = microformat["isFamilySafe"].try &.as_bool
+ family_friendly = microformat["isFamilySafe"]?.try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
@@ -262,7 +290,18 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
- likes_button = toplevel_buttons.try &.as_a
+ # New Format as of december 2023
+ likes_button = toplevel_buttons.dig?(0,
+ "segmentedLikeDislikeButtonViewModel",
+ "likeButtonViewModel",
+ "likeButtonViewModel",
+ "toggleButtonViewModel",
+ "toggleButtonViewModel",
+ "defaultButtonViewModel",
+ "buttonViewModel"
+ )
+
+ likes_button ||= toplevel_buttons.try &.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
@@ -275,9 +314,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
)
if likes_button
+ likes_txt = likes_button.dig?("accessibilityText")
# Note: The like count from `toggledText` is off by one, as it would
# represent the new like count in the event where the user clicks on "like".
- likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
+ 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
@@ -400,6 +440,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
"isListed" => JSON::Any.new(is_listed || false),
"isUpcoming" => JSON::Any.new(is_upcoming || false),
"keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }),
+ "isPostLiveDvr" => JSON::Any.new(post_live_dvr),
# Related videos
"relatedVideos" => JSON::Any.new(related),
# Description
@@ -408,7 +449,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
- "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
+ "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),
"license" => JSON::Any.new(license.try &.as_s || ""),
# Music section
"music" => JSON.parse(music_list.to_json),
@@ -422,3 +463,35 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
return params
end
+
+private def convert_url(fmt)
+ if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
+ sp = cfr["sp"]
+ url = URI.parse(cfr["url"])
+ params = url.query_params
+
+ LOGGER.debug("convert_url: Decoding '#{cfr}'")
+
+ unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
+ params[sp] = unsig if unsig
+ else
+ url = URI.parse(fmt["url"].as_s)
+ params = url.query_params
+ end
+
+ n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
+ params["n"] = n if n
+
+ if token = CONFIG.po_token
+ params["pot"] = token
+ end
+
+ url.query_params = params
+ LOGGER.trace("convert_url: new url is '#{url}'")
+
+ return url.to_s
+rescue ex
+ LOGGER.debug("convert_url: Error when parsing video URL")
+ LOGGER.trace(ex.inspect_with_backtrace)
+ return ""
+end
diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr
new file mode 100644
index 00000000..bd0eef59
--- /dev/null
+++ b/src/invidious/videos/storyboard.cr
@@ -0,0 +1,122 @@
+require "uri"
+require "http/params"
+
+module Invidious::Videos
+ struct Storyboard
+ # Template URL
+ getter url : URI
+ getter proxied_url : URI
+
+ # Thumbnail parameters
+ getter width : Int32
+ getter height : Int32
+ getter count : Int32
+ getter interval : Int32
+
+ # Image (storyboard) parameters
+ getter rows : Int32
+ getter columns : Int32
+ getter images_count : Int32
+
+ def initialize(
+ *, @url, @width, @height, @count, @interval,
+ @rows, @columns, @images_count,
+ )
+ authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
+
+ @proxied_url = URI.parse(HOST_URL)
+ @proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}"
+ @proxied_url.query = @url.query
+ end
+
+ # Parse the JSON structure from Youtube
+ def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard)
+ # Livestream storyboards are a bit different
+ # TODO: document exactly how
+ if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s
+ return [Storyboard.new(
+ url: URI.parse(storyboard.split("#")[0]),
+ width: 106,
+ height: 60,
+ count: -1,
+ interval: 5000,
+ rows: 3,
+ columns: 3,
+ images_count: -1
+ )]
+ end
+
+ # Split the storyboard string into chunks
+ #
+ # General format (whitespaces added for legibility):
+ # https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0>
+ # | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1>
+ # | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2>
+ # | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3>
+ #
+ storyboards = container.dig?("playerStoryboardSpecRenderer", "spec")
+ .try &.as_s.split("|")
+
+ return [] of Storyboard if !storyboards
+
+ # The base URL is the first chunk
+ base_url = URI.parse(storyboards.shift)
+
+ return storyboards.map_with_index do |sb, i|
+ # Separate the different storyboard parameters:
+ # width/height: respective dimensions, in pixels, of a single thumbnail
+ # count: how many thumbnails are displayed across the full video
+ # columns/rows: maximum amount of thumbnails that can be stuffed in a
+ # single image, horizontally and vertically.
+ # interval: interval between two thumbnails, in milliseconds
+ # name: storyboard filename. Usually "M$M" or "default"
+ # sigh: URL cryptographic signature
+ width, height, count, columns, rows, interval, name, sigh = sb.split("#")
+
+ width = width.to_i
+ height = height.to_i
+ count = count.to_i
+ interval = interval.to_i
+ columns = columns.to_i
+ rows = rows.to_i
+
+ # Copy base URL object, so that we can modify it
+ url = base_url.dup
+
+ # Add the signature to the URL
+ params = url.query_params
+ params["sigh"] = sigh
+ url.query_params = params
+
+ # Replace the template parts with what we have
+ url.path = url.path.sub("$L", i).sub("$N", name)
+
+ # This value represents the maximum amount of thumbnails that can fit
+ # in a single image. The last image (or the only one for short videos)
+ # will contain less thumbnails than that.
+ thumbnails_per_image = columns * rows
+
+ # This value represents the total amount of storyboards required to
+ # hold all of the thumbnails. It can't be less than 1.
+ images_count = (count / thumbnails_per_image).ceil.to_i
+
+ # Compute the interval when needed (in general, that's only required
+ # for the first "default" storyboard).
+ if interval == 0
+ interval = ((length_seconds / count) * 1_000).to_i
+ end
+
+ Storyboard.new(
+ url: url,
+ width: width,
+ height: height,
+ count: count,
+ interval: interval,
+ rows: rows,
+ columns: columns,
+ images_count: images_count,
+ )
+ end
+ end
+ end
+end
diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr
index f3360a52..4bd9f820 100644
--- a/src/invidious/videos/transcript.cr
+++ b/src/invidious/videos/transcript.cr
@@ -1,8 +1,26 @@
module Invidious::Videos
- # Namespace for methods primarily relating to Transcripts
- module Transcript
- record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String
+ # A `Transcripts` struct encapsulates a sequence of lines that together forms the whole transcript for a given YouTube video.
+ # These lines can be categorized into two types: section headings and regular lines representing content from the video.
+ struct Transcript
+ # Types
+ record HeadingLine, start_ms : Time::Span, end_ms : Time::Span, line : String
+ record RegularLine, start_ms : Time::Span, end_ms : Time::Span, line : String
+ alias TranscriptLine = HeadingLine | RegularLine
+ property lines : Array(TranscriptLine)
+
+ property language_code : String
+ property auto_generated : Bool
+
+ # User friendly label for the current transcript.
+ # Example: "English (auto-generated)"
+ property label : String
+
+ # Initializes a new Transcript struct with the contents and associated metadata describing it
+ def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool, @label : String)
+ end
+
+ # Generates a protobuf string to fetch the requested transcript from YouTube
def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String
kind = auto_generated ? "asr" : ""
@@ -30,74 +48,79 @@ module Invidious::Videos
return params
end
- def self.convert_transcripts_to_vtt(initial_data : Hash(String, JSON::Any), target_language : String) : String
- # Convert into array of TranscriptLine
- lines = self.parse(initial_data)
+ # Constructs a Transcripts struct from the initial YouTube response
+ def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool)
+ transcript_panel = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer",
+ "content", "transcriptSearchPanelRenderer")
- # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt()
- vtt = String.build do |vtt|
- vtt << <<-END_VTT
- WEBVTT
- Kind: captions
- Language: #{target_language}
+ segment_list = transcript_panel.dig("body", "transcriptSegmentListRenderer")
+ if !segment_list["initialSegments"]?
+ raise NotFoundException.new("Requested transcript does not exist")
+ end
- END_VTT
-
- vtt << "\n\n"
+ # Extract user-friendly label for the current transcript
- lines.each do |line|
- start_time = line.start_ms
- end_time = line.end_ms
+ footer_language_menu = transcript_panel.dig?(
+ "footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems"
+ )
- # start_time
- vtt << start_time.hours.to_s.rjust(2, '0')
- vtt << ':' << start_time.minutes.to_s.rjust(2, '0')
- vtt << ':' << start_time.seconds.to_s.rjust(2, '0')
- vtt << '.' << start_time.milliseconds.to_s.rjust(3, '0')
+ if footer_language_menu
+ label = footer_language_menu.as_a.select(&.["selected"].as_bool)[0]["title"].as_s
+ else
+ label = language_code
+ end
- vtt << " --> "
+ # Extract transcript lines
- # end_time
- vtt << end_time.hours.to_s.rjust(2, '0')
- vtt << ':' << end_time.minutes.to_s.rjust(2, '0')
- vtt << ':' << end_time.seconds.to_s.rjust(2, '0')
- vtt << '.' << end_time.milliseconds.to_s.rjust(3, '0')
+ initial_segments = segment_list["initialSegments"].as_a
- vtt << "\n"
- vtt << line.line
+ lines = [] of TranscriptLine
- vtt << "\n"
- vtt << "\n"
+ initial_segments.each do |line|
+ if unpacked_line = line["transcriptSectionHeaderRenderer"]?
+ line_type = HeadingLine
+ else
+ unpacked_line = line["transcriptSegmentRenderer"]
+ line_type = RegularLine
end
- end
- return vtt
- end
-
- private def self.parse(initial_data : Hash(String, JSON::Any))
- body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer",
- "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer",
- "initialSegments").as_a
+ start_ms = unpacked_line["startMs"].as_s.to_i.millisecond
+ end_ms = unpacked_line["endMs"].as_s.to_i.millisecond
+ text = extract_text(unpacked_line["snippet"]) || ""
- lines = [] of TranscriptLine
- body.each do |line|
- # Transcript section headers. They are not apart of the captions and as such we can safely skip them.
- if line.as_h.has_key?("transcriptSectionHeaderRenderer")
- next
- end
+ lines << line_type.new(start_ms, end_ms, text)
+ end
- line = line["transcriptSegmentRenderer"]
+ return Transcript.new(
+ lines: lines,
+ language_code: language_code,
+ auto_generated: auto_generated,
+ label: label
+ )
+ end
- start_ms = line["startMs"].as_s.to_i.millisecond
- end_ms = line["endMs"].as_s.to_i.millisecond
+ # Converts transcript lines to a WebVTT file
+ #
+ # This is used within Invidious to replace subtitles
+ # as to workaround YouTube's rate-limited timedtext endpoint.
+ def to_vtt
+ settings_field = {
+ "Kind" => "captions",
+ "Language" => @language_code,
+ }
- text = extract_text(line["snippet"]) || ""
+ vtt = WebVTT.build(settings_field) do |builder|
+ @lines.each do |line|
+ # Section headers are excluded from the VTT conversion as to
+ # match the regular captions returned from YouTube as much as possible
+ next if line.is_a? HeadingLine
- lines << TranscriptLine.new(start_ms, end_ms, text)
+ builder.cue(line.start_ms, line.end_ms, line.line)
+ end
end
- return lines
+ return vtt
end
end
end
diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr
index 34cf7ff0..48177bd8 100644
--- a/src/invidious/videos/video_preferences.cr
+++ b/src/invidious/videos/video_preferences.cr
@@ -2,6 +2,7 @@ struct VideoPreferences
include JSON::Serializable
property annotations : Bool
+ property preload : Bool
property autoplay : Bool
property comments : Array(String)
property continue : Bool
@@ -28,6 +29,7 @@ end
def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i?
+ preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
comments = query["comments"]?.try &.split(",").map(&.downcase)
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@@ -50,6 +52,7 @@ def process_video_params(query, preferences)
if preferences
# region ||= preferences.region
annotations ||= preferences.annotations.to_unsafe
+ preload ||= preferences.preload.to_unsafe
autoplay ||= preferences.autoplay.to_unsafe
comments ||= preferences.comments
continue ||= preferences.continue.to_unsafe
@@ -70,6 +73,7 @@ def process_video_params(query, preferences)
end
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
+ preload ||= CONFIG.default_user_preferences.preload.to_unsafe
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
comments ||= CONFIG.default_user_preferences.comments
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
@@ -89,6 +93,7 @@ def process_video_params(query, preferences)
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
annotations = annotations == 1
+ preload = preload == 1
autoplay = autoplay == 1
continue = continue == 1
continue_autoplay = continue_autoplay == 1
@@ -128,6 +133,7 @@ def process_video_params(query, preferences)
params = VideoPreferences.new({
annotations: annotations,
+ preload: preload,
autoplay: autoplay,
comments: comments,
continue: continue,
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index fbb43358..1fe8ab7e 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -32,13 +32,13 @@
<meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta property="og:title" content="<%= author %>">
-<meta property="og:image" content="/ggpht<%= channel_profile_pic %>">
+<meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<meta property="og:description" content="<%= channel.description %>">
<meta name="twitter:card" content="summary">
<meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta name="twitter:title" content="<%= author %>">
<meta name="twitter:description" content="<%= channel.description %>">
-<meta name="twitter:image" content="/ggpht<%= channel_profile_pic %>">
+<meta name="twitter:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%>
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index 24efc34e..d2a305d3 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -26,7 +26,7 @@
<p><%= error_message %></p>
</div>
<% else %>
- <div class="h-box pure-g" id="comments">
+ <div class="h-box pure-g comments" id="comments">
<%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
</div>
<% end %>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index c29ec47b..c966a926 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -26,8 +26,9 @@
</a></div>
</div>
+ <% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></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 %>
+ <% if !item.auto_generated && item.channel_handle.nil? %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchHashtag %>
<% if !thin_mode %>
@@ -81,11 +82,19 @@
</div>
<div class="video-card-row flexible">
- <div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
- <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
- </p>
- </a></div>
+ <div class="flex-left">
+ <% if !item.ucid.to_s.empty? %>
+ <a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ </a>
+ <% else %>
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ <% end %>
+ </div>
</div>
<% when Category %>
<% else %>
@@ -119,7 +128,7 @@
<div class="top-left-overlay">
<%- 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">
+ <form data-onsubmit="return_false" action="/watch_ajax?action=mark_watched&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_watched" data-id="<%= item.id %>">
@@ -129,14 +138,14 @@
<%- end -%>
<%- if plid_form = env.get?("add_playlist_items") -%>
- <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <%- form_parameters = "action=add_video&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
</form>
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
- <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
@@ -159,11 +168,19 @@
</div>
<div class="video-card-row flexible">
- <div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
- <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
- </p>
- </a></div>
+ <div class="flex-left">
+ <% if !item.ucid.to_s.empty? %>
+ <a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ </a>
+ <% else %>
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ <% end %>
+ </div>
<%= rendered "components/video-context-buttons" %>
</div>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index c3c02df0..5c28358b 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -1,5 +1,6 @@
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
+ preload="<% if params.preload %>auto<% else %>none<% end %>"
<% if params.autoplay %>autoplay<% end %>
<% if params.video_loop %>loop<% end %>
<% if params.controls %>controls<% end %>>
diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr
index a03785d1..29da2c52 100644
--- a/src/invidious/views/components/search_box.ecr
+++ b/src/invidious/views/components/search_box.ecr
@@ -6,4 +6,7 @@
title="<%= translate(locale, "search") %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset>
+ <button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
+ <i class="icon ion-ios-search"></i>
+ </button>
</form>
diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr
index 05e4e253..3cfcb0eb 100644
--- a/src/invidious/views/components/subscribe_widget.ecr
+++ b/src/invidious/views/components/subscribe_widget.ecr
@@ -1,13 +1,13 @@
<% if user %>
<% if subscriptions.includes? ucid %>
- <form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
+ <form action="/subscription_ajax?action=remove_subscriptions&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
<% else %>
- <form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
+ <form action="/subscription_ajax?action=create_subscription_to_channel&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr
index 385ed6b3..22458a03 100644
--- a/src/invidious/views/components/video-context-buttons.ecr
+++ b/src/invidious/views/components/video-context-buttons.ecr
@@ -1,6 +1,6 @@
<div class="flex-right flexible">
<div class="icon-buttons">
- <a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" href="https://www.youtube.com/watch<%=endpoint_params%>">
+ <a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" 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">
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index bda4e1f3..13fe4147 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -37,7 +37,7 @@
</a>
<div class="top-left-overlay"><div class="watched">
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&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) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr
index 24ba437d..c27ddba6 100644
--- a/src/invidious/views/playlist.ecr
+++ b/src/invidious/views/playlist.ecr
@@ -83,7 +83,7 @@
<% if !playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-2-3">
- <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
+ <a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>
diff --git a/src/invidious/views/post.ecr b/src/invidious/views/post.ecr
new file mode 100644
index 00000000..fb03a44c
--- /dev/null
+++ b/src/invidious/views/post.ecr
@@ -0,0 +1,48 @@
+<% content_for "header" do %>
+<title>Invidious</title>
+<% end %>
+
+<div>
+ <div id="post" class="comments post-comments">
+ <%= IV::Frontend::Comments.template_youtube(post_response.not_nil!, locale, thin_mode) %>
+ </div>
+
+ <% if nojs %>
+ <hr>
+ <% end %>
+ <br />
+
+ <div id="comments" class="comments post-comments">
+ <% if nojs %>
+ <%= comment_html %>
+ <% else %>
+ <noscript>
+ <a href="/post/<%= id %>?ucid=<%= ucid %>&nojs=1">
+ <%= translate(locale, "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.") %>
+ </a>
+ </noscript>
+ <% end %>
+ </div>
+</div>
+
+<script id="video_data" type="application/json">
+<%=
+{
+ "id" => id,
+ "youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
+ "reddit_comments_text" => "",
+ "reddit_permalink_text" => "",
+ "comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
+ "hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
+ "show_replies_text" => HTML.escape(translate(locale, "Show replies")),
+ "params" => {
+ "comments": ["youtube"]
+ },
+ "preferences" => prefs,
+ "base_url" => "/api/v1/post/#{URI.encode_www_form(id)}/comments",
+ "ucid" => ucid
+}.to_pretty_json
+%>
+</script>
+<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
+<script src="/js/post.js?v=<%= ASSET_COMMIT %>"></script> \ No newline at end of file
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 77265679..9904b4fc 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -1,5 +1,9 @@
+<%
+ locale = env.get("preferences").as(Preferences).locale
+ dark_mode = env.get("preferences").as(Preferences).dark_mode
+%>
<!DOCTYPE html>
-<html lang="<%= env.get("preferences").as(Preferences).locale %>">
+<html lang="<%= locale %>">
<head>
<meta charset="utf-8">
@@ -17,19 +21,14 @@
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
+ <link rel="stylesheet" href="/css/carousel.css?v=<%= ASSET_COMMIT %>">
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head>
-<%
- 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>
+ <span style="display:none" id="dark_mode_pref"><%= dark_mode %></span>
<div class="pure-g">
- <div class="pure-u-1 pure-u-md-2-24"></div>
- <div class="pure-u-1 pure-u-md-20-24" id="contents">
+ <div class="pure-u-1 pure-u-xl-20-24" id="contents">
<div class="pure-g navbar h-box">
<% if navbar_search %>
<div class="pure-u-1 pure-u-md-4-24">
@@ -43,8 +42,8 @@
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
- <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
- <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>
+ <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
+ <% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
@@ -81,8 +80,8 @@
</div>
<% else %>
<div class="pure-u-1-3">
- <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
- <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>
+ <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
+ <% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
@@ -156,7 +155,6 @@
</footer>
</div>
- <div class="pure-u-1 pure-u-md-2-24"></div>
</div>
<script src="/js/handlers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script>
diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr
index 27654b40..9ce42c99 100644
--- a/src/invidious/views/user/data_control.ecr
+++ b/src/invidious/views/user/data_control.ecr
@@ -27,6 +27,11 @@
</div>
<div class="pure-control-group">
+ <label for="import_youtube_wh"><%= translate(locale, "Import YouTube watch history (.json)") %></label>
+ <input type="file" id="import_youtube_wh" name="import_youtube_wh">
+ </div>
+
+ <div class="pure-control-group">
<label for="import_freetube"><%= translate(locale, "Import FreeTube subscriptions (.db)") %></label>
<input type="file" id="import_freetube" name="import_freetube">
</div>
diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr
index 55349c5a..cf8b5593 100644
--- a/src/invidious/views/user/preferences.ecr
+++ b/src/invidious/views/user/preferences.ecr
@@ -13,6 +13,11 @@
</div>
<div class="pure-control-group">
+ <label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
+ <input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
+ </div>
+
+ <div class="pure-control-group">
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
</div>
@@ -310,7 +315,7 @@
<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 %>>
+ <input name="modified_source_code_url" id="modified_source_code_url" type="url" value="<%= CONFIG.modified_source_code_url %>">
</div>
<% end %>
diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr
index c9801f09..d566e228 100644
--- a/src/invidious/views/user/subscription_manager.ecr
+++ b/src/invidious/views/user/subscription_manager.ecr
@@ -37,7 +37,7 @@
<div class="pure-u-2-5"></div>
<div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em">
- <form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/subscription_ajax?action=remove_subscriptions&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr
index a73fa048..8431deb0 100644
--- a/src/invidious/views/user/token_manager.ecr
+++ b/src/invidious/views/user/token_manager.ecr
@@ -29,7 +29,7 @@
</div>
<div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em">
- <form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
+ <form data-onsubmit="return_false" action="/token_ajax?action=revoke_token&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
</form>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 498d57a1..6f9ced6f 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -10,7 +10,7 @@
<meta property="og:site_name" content="<%= author %> | Invidious">
<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:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<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 %>">
@@ -62,9 +62,10 @@ we're going to need to do it here in order to allow for translations.
"params" => params,
"preferences" => preferences,
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
- "vr" => video.is_vr,
+ "vr" => video.vr?,
"projection_type" => video.projection_type,
- "local_disabled" => CONFIG.disabled?("local")
+ "local_disabled" => CONFIG.disabled?("local"),
+ "support_reddit" => true
}.to_pretty_json
%>
</script>
@@ -112,19 +113,36 @@ 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, "videoinfo_watch_on_youTube") %></a>
- (<a href="https://www.youtube.com/embed/<%= video.id %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
+ <%-
+ link_yt_watch = URI.new(scheme: "https", host: "www.youtube.com", path: "/watch", query: "v=#{video.id}")
+ link_yt_embed = URI.new(scheme: "https", host: "www.youtube.com", path: "/embed/#{video.id}")
+
+ if !plid.nil? && !continuation.nil?
+ link_yt_param = URI::Params{"list" => [plid], "index" => [continuation.to_s]}
+ link_yt_watch = IV::HttpServer::Utils.add_params_to_url(link_yt_watch, link_yt_param)
+ link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
+ end
+ -%>
+ <a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
+ (<a id="link-yt-embed" rel="noreferrer noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= 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 %>
+ <%- link_iv_other = IV::Frontend::Misc.redirect_url(env) -%>
+ <a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</p>
+
<p id="embed-link">
- <a href="<%= embed_link %>"><%= translate(locale, "videoinfo_invidious_embed_link") %></a>
+ <%-
+ params_iv_embed = env.params.query.dup
+ params_iv_embed.delete_all("v")
+
+ link_iv_embed = URI.new(path: "/embed/#{id}")
+ link_iv_embed = IV::HttpServer::Utils.add_params_to_url(link_iv_embed, params_iv_embed)
+ -%>
+ <a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>"><%= translate(locale, "videoinfo_invidious_embed_link") %></a>
</p>
+
<p id="annotations">
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
@@ -140,7 +158,7 @@ we're going to need to do it here in order to allow for translations.
<% if user %>
<% 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" target="_blank">
+ <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" 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">
@@ -151,7 +169,6 @@ we're going to need to do it here in order to allow for translations.
</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>
@@ -270,7 +287,7 @@ we're going to need to do it here in order to allow for translations.
<hr>
<% end %>
- <div id="comments">
+ <div id="comments" class="comments">
<% if nojs %>
<%= comment_html %>
<% else %>
@@ -328,7 +345,7 @@ we're going to need to do it here in order to allow for translations.
<h5 class="pure-g">
<div class="pure-u-14-24">
- <% if rv["ucid"]? %>
+ <% if !rv["ucid"].empty? %>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
@@ -352,4 +369,5 @@ we're going to need to do it here in order to allow for translations.
</div>
<% end %>
</div>
+<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>
diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr
index e9eb726c..c4a73aa7 100644
--- a/src/invidious/yt_backend/connection_pool.cr
+++ b/src/invidious/yt_backend/connection_pool.cr
@@ -1,18 +1,6 @@
-def add_yt_headers(request)
- if request.headers["User-Agent"] == "Crystal"
- request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
- end
-
- request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
- request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
- request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
-
- # Preserve original cookies and add new YT consent cookie for EU servers
- request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
- if !CONFIG.cookies.empty?
- request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
- end
-end
+# Mapping of subdomain => YoutubeConnectionPool
+# This is needed as we may need to access arbitrary subdomains of ytimg
+private YTIMG_POOLS = {} of String => YoutubeConnectionPool
struct YoutubeConnectionPool
property! url : URI
@@ -25,67 +13,104 @@ struct YoutubeConnectionPool
@pool = build_pool()
end
- def client(region = nil, &block)
- if region
- conn = make_client(url, region)
+ def client(&)
+ conn = pool.checkout
+ # Proxy needs to be reinstated every time we get a client from the pool
+ conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
+
+ begin
response = yield conn
- else
- conn = pool.checkout
- begin
- response = yield conn
- rescue ex
- conn.close
- conn = HTTP::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
+ rescue ex
+ conn.close
+ conn = make_client(url, force_resolve: true)
+
+ response = yield conn
+ ensure
+ pool.release(conn)
end
response
end
private def build_pool
- DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
- conn = HTTP::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"
- conn
+ options = DB::Pool::Options.new(
+ initial_pool_size: 0,
+ max_pool_size: capacity,
+ max_idle_pool_size: capacity,
+ checkout_timeout: timeout
+ )
+
+ DB::Pool(HTTP::Client).new(options) do
+ next make_client(url, force_resolve: true)
end
end
end
-def make_client(url : URI, region = nil)
- 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
+def add_yt_headers(request)
+ request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
+ request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
- 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
+ request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
+ request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+ request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
+
+ # Preserve original cookies and add new YT consent cookie for EU servers
+ request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
+ if !CONFIG.cookies.empty?
+ request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
+ end
+end
+
+def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false)
+ client = HTTP::Client.new(url)
+ client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
+
+ # Force the usage of a specific configured IP Family
+ if force_resolve
+ client.family = CONFIG.force_resolve
+ client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end
+ client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
+ client.read_timeout = 10.seconds
+ client.connect_timeout = 10.seconds
+
return client
end
-def make_client(url : URI, region = nil, &block)
- client = make_client(url, region)
+def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
+ client = make_client(url, region, force_resolve: force_resolve)
begin
yield client
ensure
client.close
end
end
+
+def make_configured_http_proxy_client
+ # This method is only called when configuration for an HTTP proxy are set
+ config_proxy = CONFIG.http_proxy.not_nil!
+
+ return HTTP::Proxy::Client.new(
+ config_proxy.host,
+ config_proxy.port,
+
+ username: config_proxy.user,
+ password: config_proxy.password,
+ )
+end
+
+# Fetches a HTTP pool for the specified subdomain of ytimg.com
+#
+# Creates a new one when the specified pool for the subdomain does not exist
+def get_ytimg_pool(subdomain)
+ if pool = YTIMG_POOLS[subdomain]?
+ return pool
+ else
+ LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
+ pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size)
+ YTIMG_POOLS[subdomain] = pool
+
+ return pool
+ end
+end
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index aaf7772e..edd7bf1b 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -21,6 +21,7 @@ private ITEM_PARSERS = {
Parsers::ItemSectionRendererParser,
Parsers::ContinuationItemRendererParser,
Parsers::HashtagRendererParser,
+ Parsers::LockupViewModelParser,
}
private alias InitialData = Hash(String, JSON::Any)
@@ -66,6 +67,8 @@ private module Parsers
author_id = author_fallback.id
end
+ author_thumbnail = item_contents.dig?("channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail", "thumbnails", 0, "url").try &.as_s
+
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
# For live videos (and possibly recently premiered videos) there is no published information.
@@ -108,22 +111,30 @@ private module Parsers
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) }
-
+ badges = VideoBadges::None
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 "LIVE"
+ badges |= VideoBadges::LiveNow
+ when "New"
+ badges |= VideoBadges::New
+ when "4K"
+ badges |= VideoBadges::FourK
+ when "8K"
+ badges |= VideoBadges::EightK
+ when "VR180"
+ badges |= VideoBadges::VR180
+ when "360°"
+ badges |= VideoBadges::VR360
+ when "3D"
+ badges |= VideoBadges::ThreeD
+ when "CC"
+ badges |= VideoBadges::ClosedCaptions
when "Premium"
# TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
- premium = true
+ badges |= VideoBadges::Premium
else nil # Ignore
end
end
@@ -137,10 +148,10 @@ private module Parsers
views: view_count,
description_html: description_html,
length_seconds: length_seconds,
- live_now: live_now,
- premium: premium,
premiere_timestamp: premiere_timestamp,
author_verified: author_verified,
+ author_thumbnail: author_thumbnail,
+ badges: badges,
})
end
@@ -175,17 +186,18 @@ private module Parsers
# Always simpleText
# TODO change default value to nil
- subscriber_count = item_contents.dig?("subscriberCountText", "simpleText")
+ subscriber_count = item_contents.dig?("subscriberCountText", "simpleText").try &.as_s
+ channel_handle = subscriber_count if (subscriber_count.try &.starts_with? "@")
# Since youtube added channel handles, `VideoCountText` holds the number of
# subscribers and `subscriberCountText` holds the handle, except when the
# channel doesn't have a handle (e.g: some topic music channels).
# See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688
- if !subscriber_count || !subscriber_count.as_s.includes? " subscriber"
- subscriber_count = item_contents.dig?("videoCountText", "simpleText")
+ if !subscriber_count || !subscriber_count.includes? " subscriber"
+ subscriber_count = item_contents.dig?("videoCountText", "simpleText").try &.as_s
end
subscriber_count = subscriber_count
- .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0
+ .try { |s| short_text_to_number(s.split(" ")[0]).to_i32 } || 0
# Auto-generated channels doesn't have videoCountText
# Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922
@@ -200,6 +212,7 @@ private module Parsers
author_thumbnail: author_thumbnail,
subscriber_count: subscriber_count,
video_count: video_count,
+ channel_handle: channel_handle,
description_html: description_html,
auto_generated: auto_generated,
author_verified: author_verified,
@@ -458,9 +471,9 @@ private module Parsers
# Parses an InnerTube richItemRenderer into a SearchVideo.
# Returns nil when the given object isn't a RichItemRenderer
#
- # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
- # by the result page for hashtags and for the podcast tab on channels.
- # It is located inside a continuationItems container for hashtags.
+ # A richItemRenderer seems to be a simple wrapper for a various other types,
+ # used on the hashtags result page and the channel podcast tab. It is located
+ # itself inside a richGridRenderer container.
#
module RichItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
@@ -473,6 +486,8 @@ private module Parsers
child = VideoRendererParser.process(item_contents, author_fallback)
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
+ child ||= LockupViewModelParser.process(item_contents, author_fallback)
+ child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback)
return child
end
@@ -487,6 +502,9 @@ private module Parsers
# reelItemRenderer items are used in the new (2022) channel layout,
# in the "shorts" tab.
#
+ # NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel
+ # TODO: Confirm that hypothesis
+ #
module ReelItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["reelItemRenderer"]?
@@ -562,10 +580,140 @@ private module Parsers
views: view_count,
description_html: "",
length_seconds: duration,
- live_now: false,
- premium: false,
premiere_timestamp: Time.unix(0),
author_verified: false,
+ author_thumbnail: nil,
+ badges: VideoBadges::None,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses an InnerTube lockupViewModel into a SearchPlaylist.
+ # Returns nil when the given object is not a lockupViewModel.
+ #
+ # This structure is present since November 2024 on the "podcasts" and
+ # "playlists" tabs of the channel page. It is usually encapsulated in either
+ # a richItemRenderer or a richGridRenderer.
+ #
+ module LockupViewModelParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["lockupViewModel"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ playlist_id = item_contents["contentId"].as_s
+
+ thumbnail_view_model = item_contents.dig(
+ "contentImage", "collectionThumbnailViewModel",
+ "primaryThumbnail", "thumbnailViewModel"
+ )
+
+ thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
+
+ # This complicated sequences tries to extract the following data structure:
+ # "overlays": [{
+ # "thumbnailOverlayBadgeViewModel": {
+ # "thumbnailBadges": [{
+ # "thumbnailBadgeViewModel": {
+ # "text": "430 episodes",
+ # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
+ # }
+ # }]
+ # }
+ # }]
+ #
+ # NOTE: this simplistic `.to_i` conversion might not work on larger
+ # playlists and hasn't been tested.
+ video_count = thumbnail_view_model.dig("overlays").as_a
+ .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
+ .flatten
+ .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
+ {"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
+ })
+ .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
+
+ metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
+ title = metadata.dig("title", "content").as_s
+
+ # TODO: Retrieve "updated" info from metadata parts
+ # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
+ # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
+ # One of these parts should contain a string like: "Updated 2 days ago"
+
+ # TODO: Maybe add a button to access the first video of the playlist?
+ # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
+ # Available fields: "videoId", "playlistId", "params"
+
+ return SearchPlaylist.new({
+ title: title,
+ id: playlist_id,
+ author: author_fallback.name,
+ ucid: author_fallback.id,
+ video_count: video_count || -1,
+ videos: [] of SearchPlaylistVideo,
+ thumbnail: thumbnail,
+ author_verified: false,
+ })
+ end
+
+ def self.parser_name
+ return {{@type.name}}
+ end
+ end
+
+ # Parses an InnerTube shortsLockupViewModel into a SearchVideo.
+ # Returns nil when the given object is not a shortsLockupViewModel.
+ #
+ # This structure is present since around October 2024 on the "shorts" tab of
+ # the channel page and likely replaces the reelItemRenderer structure. It is
+ # usually (always?) encapsulated in a richItemRenderer.
+ #
+ module ShortsLockupViewModelParser
+ def self.process(item : JSON::Any, author_fallback : AuthorFallback)
+ if item_contents = item["shortsLockupViewModel"]?
+ return self.parse(item_contents, author_fallback)
+ end
+ end
+
+ private def self.parse(item_contents, author_fallback)
+ # TODO: Maybe add support for "oardefault.jpg" thumbnails?
+ # thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
+ # Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
+
+ video_id = item_contents.dig(
+ "onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"
+ ).as_s
+
+ title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s
+
+ view_count = short_text_to_number(
+ item_contents.dig("overlayMetadata", "secondaryText", "content").as_s
+ )
+
+ # Approximate to one minute, as "shorts" generally don't exceed that.
+ # NOTE: The actual duration is not provided by Youtube anymore.
+ # TODO: Maybe use -1 as an error value and handle that on the frontend?
+ duration = 60_i32
+
+ SearchVideo.new({
+ title: title,
+ id: video_id,
+ author: author_fallback.name,
+ ucid: author_fallback.id,
+ published: Time.unix(0),
+ views: view_count,
+ description_html: "",
+ length_seconds: duration,
+ premiere_timestamp: Time.unix(0),
+ author_verified: false,
+ author_thumbnail: nil,
+ badges: VideoBadges::None,
})
end
@@ -820,9 +968,9 @@ module HelperExtractors
end
# Retrieves the ID required for querying the InnerTube browse endpoint.
- # Raises when it's unable to do so
+ # Returns an empty string when it's unable to do so
def self.get_browse_id(container)
- return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s
+ return container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
end
end
@@ -854,7 +1002,7 @@ end
#
# This function yields the container so that items can be parsed separately.
#
-def extract_items(initial_data : InitialData, &block)
+def extract_items(initial_data : InitialData, &)
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", 1).try &.as_h
@@ -881,7 +1029,7 @@ end
def extract_items(
initial_data : InitialData,
author_fallback : String? = nil,
- author_id_fallback : String? = nil
+ author_id_fallback : String? = nil,
) : {Array(SearchItem), String?}
items = [] of SearchItem
continuation = nil
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
index 11d95958..c83a2de5 100644
--- a/src/invidious/yt_backend/extractors_utils.cr
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -83,5 +83,5 @@ 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"]?.try &.as_bool)[0]["tabRenderer"]
+ return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end
diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr
deleted file mode 100644
index 2d0fd4ba..00000000
--- a/src/invidious/yt_backend/proxy.cr
+++ /dev/null
@@ -1,316 +0,0 @@
-# See https://github.com/crystal-lang/crystal/issues/2963
-class HTTPProxy
- getter proxy_host : String
- getter proxy_port : Int32
- getter options : Hash(Symbol, String)
- getter tls : OpenSSL::SSL::Context::Client?
-
- def initialize(@proxy_host, @proxy_port = 80, @options = {} of Symbol => String)
- end
-
- def open(host, port, tls = nil, connection_options = {} of Symbol => Float64 | Nil)
- dns_timeout = connection_options.fetch(:dns_timeout, nil)
- connect_timeout = connection_options.fetch(:connect_timeout, nil)
- read_timeout = connection_options.fetch(:read_timeout, nil)
-
- socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout
- socket.read_timeout = read_timeout if read_timeout
- socket.sync = true
-
- socket << "CONNECT #{host}:#{port} HTTP/1.1\r\n"
-
- if options[:user]?
- credentials = Base64.strict_encode("#{options[:user]}:#{options[:password]}")
- credentials = "#{credentials}\n".gsub(/\s/, "")
- socket << "Proxy-Authorization: Basic #{credentials}\r\n"
- end
-
- socket << "\r\n"
-
- resp = parse_response(socket)
-
- if resp[:code]? == 200
- {% if !flag?(:without_openssl) %}
- if tls
- tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
- socket = tls_socket
- end
- {% end %}
-
- return socket
- else
- socket.close
- raise IO::Error.new(resp.inspect)
- end
- end
-
- private def parse_response(socket)
- resp = {} of Symbol => Int32 | String | Hash(String, String)
-
- begin
- version, code, reason = socket.gets.as(String).chomp.split(/ /, 3)
-
- headers = {} of String => String
-
- while (line = socket.gets.as(String)) && (line.chomp != "")
- name, value = line.split(/:/, 2)
- headers[name.strip] = value.strip
- end
-
- resp[:version] = version
- resp[:code] = code.to_i
- resp[:reason] = reason
- resp[:headers] = headers
- rescue
- end
-
- return resp
- end
-end
-
-class HTTPClient < HTTP::Client
- def set_proxy(proxy : HTTPProxy)
- begin
- @io = proxy.open(host: @host, port: @port, tls: @tls, connection_options: proxy_connection_options)
- rescue IO::Error
- @io = nil
- end
- end
-
- def unset_proxy
- @io = nil
- end
-
- def proxy_connection_options
- opts = {} of Symbol => Float64 | Nil
-
- opts[:dns_timeout] = @dns_timeout
- opts[:connect_timeout] = @connect_timeout
- opts[:read_timeout] = @read_timeout
-
- return opts
- end
-end
-
-def get_proxies(country_code = "US")
- # return get_spys_proxies(country_code)
- return get_nova_proxies(country_code)
-end
-
-def filter_proxies(proxies)
- proxies.select! do |proxy|
- begin
- client = HTTPClient.new(YT_URL)
- client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
- client.read_timeout = 10.seconds
- client.connect_timeout = 10.seconds
-
- proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
- client.set_proxy(proxy)
-
- status_ok = client.head("/").status_code == 200
- client.close
- status_ok
- rescue ex
- false
- end
- end
-
- return proxies
-end
-
-def get_nova_proxies(country_code = "US")
- country_code = country_code.downcase
- client = HTTP::Client.new(URI.parse("https://www.proxynova.com"))
- client.read_timeout = 10.seconds
- client.connect_timeout = 10.seconds
-
- headers = HTTP::Headers.new
- headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
- headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
- headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
- headers["Host"] = "www.proxynova.com"
- headers["Origin"] = "https://www.proxynova.com"
- headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/"
-
- response = client.get("/proxy-server-list/country-#{country_code}/", headers)
- client.close
- document = XML.parse_html(response.body)
-
- proxies = [] of {ip: String, port: Int32, score: Float64}
- document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node|
- ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content
- ip = ip.match(/document\.write\('(?<sub1>[^']+)'.substr\(8\) \+ '(?<sub2>[^']+)'/).not_nil!
- ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}"
- port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i
-
- anchor = node.xpath_node(%q(.//td[4]/div)).not_nil!
- speed = anchor["data-value"].to_f
- latency = anchor["title"].to_f
- uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f
-
- # TODO: Tweak me
- score = (uptime*4 + speed*2 + latency)/7
- proxies << {ip: ip, port: port, score: score}
- end
-
- # proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse
- return proxies
-end
-
-def get_spys_proxies(country_code = "US")
- client = HTTP::Client.new(URI.parse("http://spys.one"))
- client.read_timeout = 10.seconds
- client.connect_timeout = 10.seconds
-
- headers = HTTP::Headers.new
- headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
- headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
- headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
- headers["Host"] = "spys.one"
- headers["Origin"] = "http://spys.one"
- headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/"
- headers["Content-Type"] = "application/x-www-form-urlencoded"
- body = {
- "xpp" => "5",
- "xf1" => "0",
- "xf2" => "0",
- "xf4" => "0",
- "xf5" => "1",
- }
-
- response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
- client.close
- 20.times do
- if response.status_code == 200
- break
- end
- response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
- end
-
- response = XML.parse_html(response.body)
-
- mapping = response.xpath_node(%q(.//body/script)).not_nil!.content
- mapping = mapping.match(/\}\('(?<p>[^']+)',\d+,\d+,'(?<x>[^']+)'/).not_nil!
- p = mapping["p"].not_nil!
- x = mapping["x"].not_nil!
- mapping = decrypt_port(p, x)
-
- proxies = [] of {ip: String, port: Int32, score: Float64}
- response = response.xpath_node(%q(//tr/td/table)).not_nil!
- response.xpath_nodes(%q(.//tr)).each do |node|
- if !node["onmouseover"]?
- next
- end
-
- ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/<font class="spy14">(?<address>[^<]+)</).not_nil!["address"]
- encrypted_port = node.xpath_node(%q(.//td[1]/font[2]/script)).not_nil!.content
- encrypted_port = encrypted_port.match(/<\\\/font>"\+(?<encrypted_port>[\d\D]+)\)$/).not_nil!["encrypted_port"]
-
- port = ""
- encrypted_port.split("+").each do |number|
- number = number.delete("()")
- left_side, right_side = number.split("^")
- result = mapping[left_side] ^ mapping[right_side]
- port = "#{port}#{result}"
- end
- port = port.to_i
-
- latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f
- speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f
- uptime = node.xpath_node(%q(.//td[8]/font/acronym)).not_nil!
-
- # Skip proxies that are down
- if uptime["title"].ends_with? "?"
- next
- end
-
- if md = uptime.content.match(/^\d+/)
- uptime = md[0].to_f
- else
- next
- end
-
- score = (uptime*4 + speed*2 + latency)/7
-
- proxies << {ip: ip, port: port, score: score}
- end
-
- proxies = proxies.sort_by!(&.[:score]).reverse!
- return proxies
-end
-
-def decrypt_port(p, x)
- x = x.split("^")
- s = {} of String => String
-
- 60.times do |i|
- if x[i]?.try &.empty?
- s[y_func(i)] = y_func(i)
- else
- s[y_func(i)] = x[i]
- end
- end
-
- x = s
- p = p.gsub(/\b\w+\b/, x)
-
- p = p.split(";")
- p = p.map(&.split("="))
-
- mapping = {} of String => Int32
- p.each do |item|
- if item == [""]
- next
- end
-
- key = item[0]
- value = item[1]
- value = value.split("^")
-
- if value.size == 1
- value = value[0].to_i
- else
- left_side = value[0].to_i?
- left_side ||= mapping[value[0]]
- right_side = value[1].to_i?
- right_side ||= mapping[value[1]]
-
- value = left_side ^ right_side
- end
-
- mapping[key] = value
- end
-
- return mapping
-end
-
-def y_func(c)
- return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36))
-end
-
-PROXY_LIST = {
- "GB" => [{ip: "147.135.206.233", port: 3128}, {ip: "167.114.180.102", port: 8080}, {ip: "176.35.250.108", port: 8080}, {ip: "5.148.128.44", port: 80}, {ip: "62.7.85.234", port: 8080}, {ip: "88.150.135.10", port: 36624}],
- "DE" => [{ip: "138.201.223.250", port: 31288}, {ip: "138.68.73.59", port: 32574}, {ip: "159.69.211.173", port: 3128}, {ip: "173.249.43.105", port: 3128}, {ip: "212.202.244.90", port: 8080}, {ip: "5.56.18.35", port: 38827}],
- "FR" => [{ip: "137.74.254.242", port: 3128}, {ip: "151.80.143.155", port: 53281}, {ip: "178.33.150.97", port: 3128}, {ip: "37.187.2.31", port: 3128}, {ip: "5.135.164.72", port: 3128}, {ip: "5.39.91.73", port: 3128}, {ip: "51.38.162.2", port: 32231}, {ip: "51.38.217.121", port: 808}, {ip: "51.75.109.81", port: 3128}, {ip: "51.75.109.82", port: 3128}, {ip: "51.75.109.83", port: 3128}, {ip: "51.75.109.84", port: 3128}, {ip: "51.75.109.86", port: 3128}, {ip: "51.75.109.88", port: 3128}, {ip: "51.75.109.90", port: 3128}, {ip: "62.210.167.3", port: 3128}, {ip: "90.63.218.232", port: 8080}, {ip: "91.134.165.198", port: 9999}],
- "IN" => [{ip: "1.186.151.206", port: 36253}, {ip: "1.186.63.130", port: 39142}, {ip: "103.105.40.1", port: 16538}, {ip: "103.105.40.153", port: 16538}, {ip: "103.106.148.203", port: 60227}, {ip: "103.106.148.207", port: 51451}, {ip: "103.12.246.12", port: 8080}, {ip: "103.14.235.109", port: 8080}, {ip: "103.14.235.26", port: 8080}, {ip: "103.198.172.4", port: 50820}, {ip: "103.205.112.1", port: 23500}, {ip: "103.209.64.19", port: 6666}, {ip: "103.211.76.5", port: 8080}, {ip: "103.216.82.19", port: 6666}, {ip: "103.216.82.190", port: 6666}, {ip: "103.216.82.209", port: 54806}, {ip: "103.216.82.214", port: 6666}, {ip: "103.216.82.37", port: 6666}, {ip: "103.216.82.44", port: 8080}, {ip: "103.216.82.50", port: 53281}, {ip: "103.22.173.230", port: 8080}, {ip: "103.224.38.2", port: 83}, {ip: "103.226.142.90", port: 41386}, {ip: "103.236.114.38", port: 49638}, {ip: "103.240.161.107", port: 6666}, {ip: "103.240.161.108", port: 6666}, {ip: "103.240.161.109", port: 6666}, {ip: "103.240.161.59", port: 48809}, {ip: "103.245.198.101", port: 8080}, {ip: "103.250.148.82", port: 6666}, {ip: "103.251.58.51", port: 61489}, {ip: "103.253.169.115", port: 32731}, {ip: "103.253.211.182", port: 8080}, {ip: "103.253.211.182", port: 80}, {ip: "103.255.234.169", port: 39847}, {ip: "103.42.161.118", port: 8080}, {ip: "103.42.162.30", port: 8080}, {ip: "103.42.162.50", port: 8080}, {ip: "103.42.162.58", port: 8080}, {ip: "103.46.233.12", port: 83}, {ip: "103.46.233.13", port: 83}, {ip: "103.46.233.16", port: 83}, {ip: "103.46.233.17", port: 83}, {ip: "103.46.233.21", port: 83}, {ip: "103.46.233.23", port: 83}, {ip: "103.46.233.29", port: 81}, {ip: "103.46.233.29", port: 83}, {ip: "103.46.233.50", port: 83}, {ip: "103.47.153.87", port: 8080}, {ip: "103.47.66.2", port: 39804}, {ip: "103.49.53.1", port: 81}, {ip: "103.52.220.1", port: 49068}, {ip: "103.56.228.166", port: 53281}, {ip: "103.56.30.128", port: 8080}, {ip: "103.65.193.17", port: 50862}, {ip: "103.65.195.1", port: 33960}, {ip: "103.69.220.14", port: 3128}, {ip: "103.70.128.84", port: 8080}, {ip: "103.70.128.86", port: 8080}, {ip: "103.70.131.74", port: 8080}, {ip: "103.70.146.250", port: 59563}, {ip: "103.72.216.194", port: 38345}, {ip: "103.75.161.38", port: 21776}, {ip: "103.76.253.155", port: 3128}, {ip: "103.87.104.137", port: 8080}, {ip: "110.235.198.3", port: 57660}, {ip: "114.69.229.161", port: 8080}, {ip: "117.196.231.201", port: 37769}, {ip: "117.211.166.214", port: 3128}, {ip: "117.240.175.51", port: 3128}, {ip: "117.240.210.155", port: 53281}, {ip: "117.240.59.115", port: 36127}, {ip: "117.242.154.73", port: 33889}, {ip: "117.244.15.243", port: 3128}, {ip: "119.235.54.3", port: 8080}, {ip: "120.138.117.102", port: 59308}, {ip: "123.108.200.185", port: 83}, {ip: "123.108.200.217", port: 82}, {ip: "123.176.43.218", port: 40524}, {ip: "125.21.43.82", port: 8080}, {ip: "125.62.192.225", port: 82}, {ip: "125.62.192.33", port: 84}, {ip: "125.62.194.1", port: 83}, {ip: "125.62.213.134", port: 82}, {ip: "125.62.213.18", port: 83}, {ip: "125.62.213.201", port: 84}, {ip: "125.62.213.242", port: 83}, {ip: "125.62.214.185", port: 84}, {ip: "139.5.26.27", port: 53281}, {ip: "14.102.67.101", port: 30337}, {ip: "14.142.122.134", port: 8080}, {ip: "150.129.114.194", port: 6666}, {ip: "150.129.151.62", port: 6666}, {ip: "150.129.171.115", port: 6666}, {ip: "150.129.201.30", port: 6666}, {ip: "157.119.207.38", port: 53281}, {ip: "175.100.185.151", port: 53281}, {ip: "182.18.177.114", port: 56173}, {ip: "182.73.194.170", port: 8080}, {ip: "182.74.85.230", port: 51214}, {ip: "183.82.116.56", port: 8080}, {ip: "183.82.32.56", port: 49551}, {ip: "183.87.14.229", port: 53281}, {ip: "183.87.14.250", port: 44915}, {ip: "202.134.160.168", port: 8080}, {ip: "202.134.166.1", port: 8080}, {ip: "202.134.180.50", port: 8080}, {ip: "202.62.84.210", port: 53281}, {ip: "203.192.193.225", port: 8080}, {ip: "203.192.195.14", port: 31062}, {ip: "203.192.217.11", port: 8080}, {ip: "223.196.83.182", port: 53281}, {ip: "27.116.20.169", port: 36630}, {ip: "27.116.20.209", port: 36630}, {ip: "27.116.51.21", port: 36033}, {ip: "43.224.8.114", port: 50333}, {ip: "43.224.8.116", port: 6666}, {ip: "43.224.8.124", port: 6666}, {ip: "43.224.8.86", port: 6666}, {ip: "43.225.20.73", port: 8080}, {ip: "43.225.23.26", port: 8080}, {ip: "43.230.196.98", port: 36569}, {ip: "43.240.5.225", port: 31777}, {ip: "43.241.28.248", port: 8080}, {ip: "43.242.209.201", port: 8080}, {ip: "43.246.139.82", port: 8080}, {ip: "43.248.73.86", port: 53281}, {ip: "43.251.170.145", port: 54059}, {ip: "45.112.57.230", port: 61222}, {ip: "45.115.171.30", port: 47949}, {ip: "45.121.29.254", port: 54858}, {ip: "45.123.26.146", port: 53281}, {ip: "45.125.61.193", port: 32804}, {ip: "45.125.61.209", port: 32804}, {ip: "45.127.121.194", port: 53281}, {ip: "45.250.226.14", port: 3128}, {ip: "45.250.226.38", port: 8080}, {ip: "45.250.226.47", port: 8080}, {ip: "45.250.226.55", port: 8080}, {ip: "49.249.251.86", port: 53281}],
- "CN" => [{ip: "182.61.170.45", port: 3128}],
- "RU" => [{ip: "109.106.139.225", port: 45689}, {ip: "109.161.48.228", port: 53281}, {ip: "109.167.224.198", port: 51919}, {ip: "109.172.57.250", port: 23500}, {ip: "109.194.2.126", port: 61822}, {ip: "109.195.150.128", port: 37564}, {ip: "109.201.96.171", port: 31773}, {ip: "109.201.97.204", port: 41258}, {ip: "109.201.97.235", port: 39125}, {ip: "109.206.140.74", port: 45991}, {ip: "109.206.148.31", port: 30797}, {ip: "109.69.75.5", port: 46347}, {ip: "109.71.181.170", port: 53983}, {ip: "109.74.132.190", port: 42663}, {ip: "109.74.143.45", port: 36529}, {ip: "109.75.140.158", port: 59916}, {ip: "109.95.84.114", port: 52125}, {ip: "130.255.12.24", port: 31004}, {ip: "134.19.147.72", port: 44812}, {ip: "134.90.181.7", port: 54353}, {ip: "145.255.6.171", port: 31252}, {ip: "146.120.227.3", port: 8080}, {ip: "149.255.112.194", port: 48968}, {ip: "158.46.127.222", port: 52574}, {ip: "158.46.43.144", port: 39120}, {ip: "158.58.130.185", port: 50016}, {ip: "158.58.132.12", port: 56962}, {ip: "158.58.133.106", port: 41258}, {ip: "158.58.133.13", port: 21213}, {ip: "176.101.0.47", port: 34471}, {ip: "176.101.89.226", port: 33470}, {ip: "176.106.12.65", port: 30120}, {ip: "176.107.80.110", port: 58901}, {ip: "176.110.121.9", port: 46322}, {ip: "176.110.121.90", port: 21776}, {ip: "176.111.97.18", port: 8080}, {ip: "176.112.106.230", port: 33996}, {ip: "176.112.110.40", port: 61142}, {ip: "176.113.116.70", port: 55589}, {ip: "176.113.27.192", port: 47337}, {ip: "176.115.197.118", port: 8080}, {ip: "176.117.255.182", port: 53100}, {ip: "176.120.200.69", port: 44331}, {ip: "176.124.123.93", port: 41258}, {ip: "176.192.124.98", port: 60787}, {ip: "176.192.5.238", port: 61227}, {ip: "176.192.8.206", port: 39422}, {ip: "176.193.15.94", port: 8080}, {ip: "176.196.195.170", port: 48129}, {ip: "176.196.198.154", port: 35252}, {ip: "176.196.238.234", port: 44648}, {ip: "176.196.239.46", port: 35656}, {ip: "176.196.246.6", port: 53281}, {ip: "176.196.84.138", port: 51336}, {ip: "176.197.145.246", port: 32649}, {ip: "176.197.99.142", port: 47278}, {ip: "176.215.1.108", port: 60339}, {ip: "176.215.170.147", port: 35604}, {ip: "176.56.23.14", port: 35340}, {ip: "176.62.185.54", port: 53883}, {ip: "176.74.13.110", port: 8080}, {ip: "178.130.29.226", port: 53295}, {ip: "178.170.254.178", port: 46788}, {ip: "178.213.13.136", port: 53281}, {ip: "178.218.104.8", port: 49707}, {ip: "178.219.183.163", port: 8080}, {ip: "178.237.180.34", port: 57307}, {ip: "178.57.101.212", port: 38020}, {ip: "178.57.101.235", port: 31309}, {ip: "178.64.190.133", port: 46688}, {ip: "178.75.1.111", port: 50411}, {ip: "178.75.27.131", port: 41879}, {ip: "185.13.35.178", port: 40654}, {ip: "185.15.189.67", port: 30215}, {ip: "185.175.119.137", port: 41258}, {ip: "185.18.111.194", port: 41258}, {ip: "185.19.176.237", port: 53281}, {ip: "185.190.40.115", port: 31747}, {ip: "185.216.195.134", port: 61287}, {ip: "185.22.172.94", port: 10010}, {ip: "185.22.172.94", port: 1448}, {ip: "185.22.174.65", port: 10010}, {ip: "185.22.174.65", port: 1448}, {ip: "185.23.64.100", port: 3130}, {ip: "185.23.82.39", port: 59248}, {ip: "185.233.94.105", port: 59288}, {ip: "185.233.94.146", port: 57736}, {ip: "185.3.68.54", port: 53500}, {ip: "185.32.120.177", port: 60724}, {ip: "185.34.20.164", port: 53700}, {ip: "185.34.23.43", port: 63238}, {ip: "185.51.60.141", port: 39935}, {ip: "185.61.92.228", port: 33060}, {ip: "185.61.93.67", port: 49107}, {ip: "185.7.233.66", port: 53504}, {ip: "185.72.225.10", port: 56285}, {ip: "185.75.5.158", port: 60819}, {ip: "185.9.86.186", port: 39345}, {ip: "188.133.136.10", port: 47113}, {ip: "188.168.75.254", port: 56899}, {ip: "188.170.41.6", port: 60332}, {ip: "188.187.189.142", port: 38264}, {ip: "188.234.151.103", port: 8080}, {ip: "188.235.11.88", port: 57143}, {ip: "188.235.137.196", port: 23500}, {ip: "188.244.175.2", port: 8080}, {ip: "188.255.82.136", port: 53281}, {ip: "188.43.4.117", port: 60577}, {ip: "188.68.95.166", port: 41258}, {ip: "188.92.242.180", port: 52048}, {ip: "188.93.242.213", port: 49774}, {ip: "192.162.193.243", port: 36910}, {ip: "192.162.214.11", port: 41258}, {ip: "193.106.170.133", port: 38591}, {ip: "193.232.113.244", port: 40412}, {ip: "193.232.234.130", port: 61932}, {ip: "193.242.177.105", port: 53281}, {ip: "193.242.178.50", port: 52376}, {ip: "193.242.178.90", port: 8080}, {ip: "193.33.101.152", port: 34611}, {ip: "194.114.128.149", port: 61213}, {ip: "194.135.15.146", port: 59328}, {ip: "194.135.216.178", port: 56805}, {ip: "194.135.75.74", port: 41258}, {ip: "194.146.201.67", port: 53281}, {ip: "194.186.18.46", port: 56408}, {ip: "194.186.20.62", port: 21231}, {ip: "194.190.171.214", port: 43960}, {ip: "194.9.27.82", port: 42720}, {ip: "195.133.232.58", port: 41733}, {ip: "195.14.114.116", port: 59530}, {ip: "195.14.114.24", port: 56897}, {ip: "195.158.250.97", port: 41582}, {ip: "195.16.48.142", port: 36083}, {ip: "195.191.183.169", port: 47238}, {ip: "195.206.45.112", port: 53281}, {ip: "195.208.172.70", port: 8080}, {ip: "195.209.141.67", port: 31927}, {ip: "195.209.176.2", port: 8080}, {ip: "195.210.144.166", port: 30088}, {ip: "195.211.160.88", port: 44464}, {ip: "195.218.144.182", port: 31705}, {ip: "195.46.168.147", port: 8080}, {ip: "195.9.188.78", port: 53281}, {ip: "195.9.209.10", port: 35242}, {ip: "195.9.223.246", port: 52098}, {ip: "195.9.237.66", port: 8080}, {ip: "195.9.91.66", port: 33199}, {ip: "195.91.132.20", port: 19600}, {ip: "195.98.183.82", port: 30953}, {ip: "212.104.82.246", port: 36495}, {ip: "212.119.229.18", port: 33852}, {ip: "212.13.97.122", port: 30466}, {ip: "212.19.21.19", port: 53264}, {ip: "212.19.5.157", port: 58442}, {ip: "212.19.8.223", port: 30281}, {ip: "212.19.8.239", port: 55602}, {ip: "212.192.202.207", port: 4550}, {ip: "212.22.80.224", port: 34822}, {ip: "212.26.247.178", port: 38418}, {ip: "212.33.228.161", port: 37971}, {ip: "212.33.243.83", port: 38605}, {ip: "212.34.53.126", port: 44369}, {ip: "212.5.107.81", port: 56481}, {ip: "212.7.230.7", port: 51405}, {ip: "212.77.138.161", port: 41258}, {ip: "213.108.221.201", port: 32800}, {ip: "213.109.7.135", port: 59918}, {ip: "213.128.9.204", port: 35549}, {ip: "213.134.196.12", port: 38723}, {ip: "213.168.37.86", port: 8080}, {ip: "213.187.118.184", port: 53281}, {ip: "213.21.23.98", port: 53281}, {ip: "213.210.67.166", port: 53281}, {ip: "213.234.0.242", port: 56503}, {ip: "213.247.192.131", port: 41258}, {ip: "213.251.226.208", port: 56900}, {ip: "213.33.155.80", port: 44387}, {ip: "213.33.199.194", port: 36411}, {ip: "213.33.224.82", port: 8080}, {ip: "213.59.153.19", port: 53281}, {ip: "217.10.45.103", port: 8080}, {ip: "217.107.197.39", port: 33628}, {ip: "217.116.60.66", port: 21231}, {ip: "217.195.87.58", port: 41258}, {ip: "217.197.239.54", port: 34463}, {ip: "217.74.161.42", port: 34175}, {ip: "217.8.84.76", port: 46378}, {ip: "31.131.67.14", port: 8080}, {ip: "31.132.127.142", port: 35432}, {ip: "31.132.218.252", port: 32423}, {ip: "31.173.17.118", port: 51317}, {ip: "31.193.124.70", port: 53281}, {ip: "31.210.211.147", port: 8080}, {ip: "31.220.183.217", port: 53281}, {ip: "31.29.212.82", port: 35066}, {ip: "31.42.254.24", port: 30912}, {ip: "31.47.189.14", port: 38473}, {ip: "37.113.129.98", port: 41665}, {ip: "37.192.103.164", port: 34835}, {ip: "37.192.194.50", port: 50165}, {ip: "37.192.99.151", port: 51417}, {ip: "37.205.83.91", port: 35888}, {ip: "37.233.85.155", port: 53281}, {ip: "37.235.167.66", port: 53281}, {ip: "37.235.65.2", port: 47816}, {ip: "37.235.67.178", port: 34450}, {ip: "37.9.134.133", port: 41262}, {ip: "46.150.174.90", port: 53281}, {ip: "46.151.156.198", port: 56013}, {ip: "46.16.226.10", port: 8080}, {ip: "46.163.131.55", port: 48306}, {ip: "46.173.191.51", port: 53281}, {ip: "46.174.222.61", port: 34977}, {ip: "46.180.96.79", port: 42319}, {ip: "46.181.151.79", port: 39386}, {ip: "46.21.74.130", port: 8080}, {ip: "46.227.162.98", port: 51558}, {ip: "46.229.187.169", port: 53281}, {ip: "46.229.67.198", port: 47437}, {ip: "46.243.179.221", port: 41598}, {ip: "46.254.217.54", port: 53281}, {ip: "46.32.68.188", port: 39707}, {ip: "46.39.224.112", port: 36765}, {ip: "46.63.162.171", port: 8080}, {ip: "46.73.33.253", port: 8080}, {ip: "5.128.32.12", port: 51959}, {ip: "5.129.155.3", port: 51390}, {ip: "5.129.16.27", port: 48935}, {ip: "5.141.81.65", port: 61853}, {ip: "5.16.15.234", port: 8080}, {ip: "5.167.51.235", port: 8080}, {ip: "5.167.96.238", port: 3128}, {ip: "5.19.165.235", port: 30793}, {ip: "5.35.93.157", port: 31773}, {ip: "5.59.137.90", port: 8888}, {ip: "5.8.207.160", port: 57192}, {ip: "62.122.97.66", port: 59143}, {ip: "62.148.151.253", port: 53570}, {ip: "62.152.85.158", port: 31156}, {ip: "62.165.54.153", port: 55522}, {ip: "62.173.140.14", port: 8080}, {ip: "62.173.155.206", port: 41258}, {ip: "62.182.206.19", port: 37715}, {ip: "62.213.14.166", port: 8080}, {ip: "62.76.123.224", port: 8080}, {ip: "77.221.220.133", port: 44331}, {ip: "77.232.153.248", port: 60950}, {ip: "77.233.10.37", port: 54210}, {ip: "77.244.27.109", port: 47554}, {ip: "77.37.142.203", port: 53281}, {ip: "77.39.29.29", port: 49243}, {ip: "77.75.6.34", port: 8080}, {ip: "77.87.102.7", port: 42601}, {ip: "77.94.121.212", port: 36896}, {ip: "77.94.121.51", port: 45293}, {ip: "78.110.154.177", port: 59888}, {ip: "78.140.201.226", port: 8090}, {ip: "78.153.4.122", port: 9001}, {ip: "78.156.225.170", port: 41258}, {ip: "78.156.243.146", port: 59730}, {ip: "78.29.14.201", port: 39001}, {ip: "78.81.24.112", port: 8080}, {ip: "78.85.36.203", port: 8080}, {ip: "79.104.219.125", port: 3128}, {ip: "79.104.55.134", port: 8080}, {ip: "79.137.181.170", port: 8080}, {ip: "79.173.124.194", port: 47832}, {ip: "79.173.124.207", port: 53281}, {ip: "79.174.186.168", port: 45710}, {ip: "79.175.51.13", port: 54853}, {ip: "79.175.57.77", port: 55477}, {ip: "80.234.107.118", port: 56952}, {ip: "80.237.6.1", port: 34880}, {ip: "80.243.14.182", port: 49320}, {ip: "80.251.48.215", port: 45157}, {ip: "80.254.121.66", port: 41055}, {ip: "80.254.125.236", port: 80}, {ip: "80.72.121.185", port: 52379}, {ip: "80.89.133.210", port: 3128}, {ip: "80.91.17.113", port: 41258}, {ip: "81.162.61.166", port: 40392}, {ip: "81.163.57.121", port: 41258}, {ip: "81.163.57.46", port: 41258}, {ip: "81.163.62.136", port: 41258}, {ip: "81.23.112.98", port: 55269}, {ip: "81.23.118.106", port: 60427}, {ip: "81.23.177.245", port: 8080}, {ip: "81.24.126.166", port: 8080}, {ip: "81.30.216.147", port: 41258}, {ip: "81.95.131.10", port: 44292}, {ip: "82.114.125.22", port: 8080}, {ip: "82.151.208.20", port: 8080}, {ip: "83.221.216.110", port: 47326}, {ip: "83.246.139.24", port: 8080}, {ip: "83.97.108.8", port: 41258}, {ip: "84.22.154.76", port: 8080}, {ip: "84.52.110.36", port: 38674}, {ip: "84.52.74.194", port: 8080}, {ip: "84.52.77.227", port: 41806}, {ip: "84.52.79.166", port: 43548}, {ip: "84.52.84.157", port: 44331}, {ip: "84.52.88.125", port: 32666}, {ip: "85.113.48.148", port: 8080}, {ip: "85.113.49.220", port: 8080}, {ip: "85.12.193.210", port: 58470}, {ip: "85.15.179.5", port: 8080}, {ip: "85.173.244.102", port: 53281}, {ip: "85.174.227.52", port: 59280}, {ip: "85.192.184.133", port: 8080}, {ip: "85.192.184.133", port: 80}, {ip: "85.21.240.193", port: 55820}, {ip: "85.21.63.219", port: 53281}, {ip: "85.235.190.18", port: 42494}, {ip: "85.237.56.193", port: 8080}, {ip: "85.91.119.6", port: 8080}, {ip: "86.102.116.30", port: 8080}, {ip: "86.110.30.146", port: 38109}, {ip: "87.117.3.129", port: 3128}, {ip: "87.225.108.195", port: 8080}, {ip: "87.228.103.111", port: 8080}, {ip: "87.228.103.43", port: 8080}, {ip: "87.229.143.10", port: 48872}, {ip: "87.249.205.103", port: 8080}, {ip: "87.249.21.193", port: 43079}, {ip: "87.255.13.217", port: 8080}, {ip: "88.147.159.167", port: 53281}, {ip: "88.200.225.32", port: 38583}, {ip: "88.204.59.177", port: 32666}, {ip: "88.84.209.69", port: 30819}, {ip: "88.87.72.72", port: 8080}, {ip: "88.87.79.20", port: 8080}, {ip: "88.87.91.163", port: 48513}, {ip: "88.87.93.20", port: 33277}, {ip: "89.109.12.82", port: 47972}, {ip: "89.109.21.43", port: 9090}, {ip: "89.109.239.183", port: 41041}, {ip: "89.109.54.137", port: 36469}, {ip: "89.17.37.218", port: 52957}, {ip: "89.189.130.103", port: 32626}, {ip: "89.189.159.214", port: 42530}, {ip: "89.189.174.121", port: 52636}, {ip: "89.23.18.29", port: 53281}, {ip: "89.249.251.21", port: 3128}, {ip: "89.250.149.114", port: 60981}, {ip: "89.250.17.209", port: 8080}, {ip: "89.250.19.173", port: 8080}, {ip: "90.150.87.172", port: 81}, {ip: "90.154.125.173", port: 33078}, {ip: "90.188.38.81", port: 60585}, {ip: "90.189.151.183", port: 32601}, {ip: "91.103.208.114", port: 57063}, {ip: "91.122.100.222", port: 44331}, {ip: "91.122.207.229", port: 8080}, {ip: "91.144.139.93", port: 3128}, {ip: "91.144.142.19", port: 44617}, {ip: "91.146.16.54", port: 57902}, {ip: "91.190.116.194", port: 38783}, {ip: "91.190.80.100", port: 31659}, {ip: "91.190.85.97", port: 34286}, {ip: "91.203.36.188", port: 8080}, {ip: "91.205.131.102", port: 8080}, {ip: "91.205.146.25", port: 37501}, {ip: "91.210.94.212", port: 52635}, {ip: "91.213.23.110", port: 8080}, {ip: "91.215.22.51", port: 53305}, {ip: "91.217.42.3", port: 8080}, {ip: "91.217.42.4", port: 8080}, {ip: "91.220.135.146", port: 41258}, {ip: "91.222.167.213", port: 38057}, {ip: "91.226.140.71", port: 33199}, {ip: "91.235.7.216", port: 59067}, {ip: "92.124.195.22", port: 3128}, {ip: "92.126.193.180", port: 8080}, {ip: "92.241.110.223", port: 53281}, {ip: "92.252.240.1", port: 53281}, {ip: "92.255.164.187", port: 3128}, {ip: "92.255.195.57", port: 53281}, {ip: "92.255.229.146", port: 55785}, {ip: "92.255.5.2", port: 41012}, {ip: "92.38.32.36", port: 56113}, {ip: "92.39.138.98", port: 31150}, {ip: "92.51.16.155", port: 46202}, {ip: "92.55.59.63", port: 33030}, {ip: "93.170.112.200", port: 47995}, {ip: "93.183.86.185", port: 53281}, {ip: "93.188.45.157", port: 8080}, {ip: "93.81.246.5", port: 53281}, {ip: "93.91.112.247", port: 41258}, {ip: "94.127.217.66", port: 40115}, {ip: "94.154.85.214", port: 8080}, {ip: "94.180.106.94", port: 32767}, {ip: "94.180.249.187", port: 38051}, {ip: "94.230.243.6", port: 8080}, {ip: "94.232.57.231", port: 51064}, {ip: "94.24.244.170", port: 48936}, {ip: "94.242.55.108", port: 10010}, {ip: "94.242.55.108", port: 1448}, {ip: "94.242.57.136", port: 10010}, {ip: "94.242.57.136", port: 1448}, {ip: "94.242.58.108", port: 10010}, {ip: "94.242.58.108", port: 1448}, {ip: "94.242.58.14", port: 10010}, {ip: "94.242.58.14", port: 1448}, {ip: "94.242.58.142", port: 10010}, {ip: "94.242.58.142", port: 1448}, {ip: "94.242.59.245", port: 10010}, {ip: "94.242.59.245", port: 1448}, {ip: "94.247.241.70", port: 53640}, {ip: "94.247.62.165", port: 33176}, {ip: "94.253.13.228", port: 54935}, {ip: "94.253.14.187", port: 55045}, {ip: "94.28.94.154", port: 46966}, {ip: "94.73.217.125", port: 40858}, {ip: "95.140.19.9", port: 8080}, {ip: "95.140.20.94", port: 33994}, {ip: "95.154.137.66", port: 41258}, {ip: "95.154.159.119", port: 44242}, {ip: "95.154.82.254", port: 52484}, {ip: "95.161.157.227", port: 43170}, {ip: "95.161.182.146", port: 33877}, {ip: "95.161.189.26", port: 61522}, {ip: "95.165.163.146", port: 8888}, {ip: "95.165.172.90", port: 60496}, {ip: "95.165.182.18", port: 38950}, {ip: "95.165.203.222", port: 33805}, {ip: "95.165.244.122", port: 58162}, {ip: "95.167.123.54", port: 58664}, {ip: "95.167.241.242", port: 49636}, {ip: "95.171.1.92", port: 35956}, {ip: "95.172.52.230", port: 35989}, {ip: "95.181.35.30", port: 40804}, {ip: "95.181.56.178", port: 39144}, {ip: "95.181.75.228", port: 53281}, {ip: "95.188.74.194", port: 57122}, {ip: "95.189.112.214", port: 35508}, {ip: "95.31.10.247", port: 30711}, {ip: "95.31.197.77", port: 41651}, {ip: "95.31.2.199", port: 33632}, {ip: "95.71.125.50", port: 49882}, {ip: "95.73.62.13", port: 32185}, {ip: "95.79.36.55", port: 44861}, {ip: "95.79.55.196", port: 53281}, {ip: "95.79.99.148", port: 3128}, {ip: "95.80.65.39", port: 43555}, {ip: "95.80.93.44", port: 41258}, {ip: "95.80.98.41", port: 8080}, {ip: "95.83.156.250", port: 58438}, {ip: "95.84.128.25", port: 33765}, {ip: "95.84.154.73", port: 57423}],
- "CA" => [{ip: "144.217.161.149", port: 8080}, {ip: "24.37.9.6", port: 54154}, {ip: "54.39.138.144", port: 3128}, {ip: "54.39.138.145", port: 3128}, {ip: "54.39.138.151", port: 3128}, {ip: "54.39.138.152", port: 3128}, {ip: "54.39.138.153", port: 3128}, {ip: "54.39.138.154", port: 3128}, {ip: "54.39.138.155", port: 3128}, {ip: "54.39.138.156", port: 3128}, {ip: "54.39.138.157", port: 3128}, {ip: "54.39.53.104", port: 3128}, {ip: "66.70.167.113", port: 3128}, {ip: "66.70.167.116", port: 3128}, {ip: "66.70.167.117", port: 3128}, {ip: "66.70.167.119", port: 3128}, {ip: "66.70.167.120", port: 3128}, {ip: "66.70.167.125", port: 3128}, {ip: "66.70.188.148", port: 3128}, {ip: "70.35.213.229", port: 36127}, {ip: "70.65.233.174", port: 8080}, {ip: "72.139.24.66", port: 38861}, {ip: "74.15.191.160", port: 41564}],
- "JP" => [{ip: "47.91.20.67", port: 8080}, {ip: "61.118.35.94", port: 55725}],
- "IT" => [{ip: "109.70.201.97", port: 53517}, {ip: "176.31.82.212", port: 8080}, {ip: "185.132.228.118", port: 55583}, {ip: "185.49.58.88", port: 56006}, {ip: "185.94.89.179", port: 41258}, {ip: "213.203.134.10", port: 41258}, {ip: "217.61.172.12", port: 41369}, {ip: "46.232.143.126", port: 41258}, {ip: "46.232.143.253", port: 41258}, {ip: "93.67.154.125", port: 8080}, {ip: "93.67.154.125", port: 80}, {ip: "95.169.95.242", port: 53803}],
- "TH" => [{ip: "1.10.184.166", port: 57330}, {ip: "1.10.186.100", port: 55011}, {ip: "1.10.186.209", port: 32431}, {ip: "1.10.186.245", port: 34360}, {ip: "1.10.186.93", port: 53711}, {ip: "1.10.187.118", port: 62000}, {ip: "1.10.187.34", port: 51635}, {ip: "1.10.187.43", port: 38715}, {ip: "1.10.188.181", port: 51093}, {ip: "1.10.188.83", port: 31940}, {ip: "1.10.188.95", port: 30593}, {ip: "1.10.189.58", port: 48564}, {ip: "1.179.157.237", port: 46178}, {ip: "1.179.164.213", port: 8080}, {ip: "1.179.198.37", port: 8080}, {ip: "1.20.100.99", port: 53794}, {ip: "1.20.101.221", port: 55707}, {ip: "1.20.101.254", port: 35394}, {ip: "1.20.101.80", port: 36234}, {ip: "1.20.102.133", port: 40296}, {ip: "1.20.103.13", port: 40544}, {ip: "1.20.103.56", port: 55422}, {ip: "1.20.96.234", port: 53142}, {ip: "1.20.97.54", port: 60122}, {ip: "1.20.99.63", port: 32123}, {ip: "101.108.92.20", port: 8080}, {ip: "101.109.143.71", port: 36127}, {ip: "101.51.141.110", port: 42860}, {ip: "101.51.141.60", port: 60417}, {ip: "103.246.17.237", port: 3128}, {ip: "110.164.73.131", port: 8080}, {ip: "110.164.87.80", port: 35844}, {ip: "110.77.134.106", port: 8080}, {ip: "113.53.29.92", port: 47297}, {ip: "113.53.83.192", port: 32780}, {ip: "113.53.83.195", port: 35686}, {ip: "113.53.91.214", port: 8080}, {ip: "115.87.27.0", port: 53276}, {ip: "118.172.211.3", port: 58535}, {ip: "118.172.211.40", port: 30430}, {ip: "118.174.196.174", port: 23500}, {ip: "118.174.196.203", port: 23500}, {ip: "118.174.220.107", port: 41222}, {ip: "118.174.220.110", port: 39025}, {ip: "118.174.220.115", port: 41011}, {ip: "118.174.220.118", port: 59556}, {ip: "118.174.220.136", port: 55041}, {ip: "118.174.220.163", port: 31561}, {ip: "118.174.220.168", port: 47455}, {ip: "118.174.220.231", port: 40924}, {ip: "118.174.220.238", port: 46326}, {ip: "118.174.234.13", port: 53084}, {ip: "118.174.234.26", port: 41926}, {ip: "118.174.234.32", port: 57403}, {ip: "118.174.234.59", port: 59149}, {ip: "118.174.234.68", port: 42626}, {ip: "118.174.234.83", port: 38006}, {ip: "118.175.207.104", port: 38959}, {ip: "118.175.244.111", port: 8080}, {ip: "118.175.93.207", port: 50738}, {ip: "122.154.38.53", port: 8080}, {ip: "122.154.59.6", port: 8080}, {ip: "122.154.72.102", port: 8080}, {ip: "122.155.222.98", port: 3128}, {ip: "124.121.22.121", port: 61699}, {ip: "125.24.156.16", port: 44321}, {ip: "125.25.165.105", port: 33850}, {ip: "125.25.165.111", port: 40808}, {ip: "125.25.165.42", port: 47221}, {ip: "125.25.201.14", port: 30100}, {ip: "125.26.99.135", port: 55637}, {ip: "125.26.99.141", port: 38537}, {ip: "125.26.99.148", port: 31818}, {ip: "134.236.247.137", port: 8080}, {ip: "159.192.98.224", port: 3128}, {ip: "171.100.2.154", port: 8080}, {ip: "171.100.9.126", port: 49163}, {ip: "180.180.156.116", port: 48431}, {ip: "180.180.156.46", port: 48507}, {ip: "180.180.156.87", port: 36628}, {ip: "180.180.218.204", port: 51565}, {ip: "180.180.8.34", port: 8080}, {ip: "182.52.238.125", port: 58861}, {ip: "182.52.74.73", port: 36286}, {ip: "182.52.74.76", port: 34084}, {ip: "182.52.74.77", port: 34825}, {ip: "182.52.74.78", port: 48708}, {ip: "182.52.90.45", port: 53799}, {ip: "182.53.206.155", port: 34307}, {ip: "182.53.206.43", port: 45330}, {ip: "182.53.206.49", port: 54228}, {ip: "183.88.212.141", port: 8080}, {ip: "183.88.212.184", port: 8080}, {ip: "183.88.213.85", port: 8080}, {ip: "183.88.214.47", port: 8080}, {ip: "184.82.128.211", port: 8080}, {ip: "202.183.201.13", port: 8081}, {ip: "202.29.20.151", port: 43083}, {ip: "203.150.172.151", port: 8080}, {ip: "27.131.157.94", port: 8080}, {ip: "27.145.100.22", port: 8080}, {ip: "27.145.100.243", port: 8080}, {ip: "49.231.196.114", port: 53281}, {ip: "58.97.72.83", port: 8080}, {ip: "61.19.145.66", port: 8080}],
- "ES" => [{ip: "185.198.184.14", port: 48122}, {ip: "185.26.226.241", port: 36012}, {ip: "194.224.188.82", port: 3128}, {ip: "195.235.68.61", port: 3128}, {ip: "195.53.237.122", port: 3128}, {ip: "195.53.86.82", port: 3128}, {ip: "213.96.245.47", port: 8080}, {ip: "217.125.71.214", port: 33950}, {ip: "62.14.178.72", port: 53281}, {ip: "80.35.254.42", port: 53281}, {ip: "81.33.4.214", port: 61711}, {ip: "83.175.238.170", port: 53281}, {ip: "85.217.137.77", port: 3128}, {ip: "90.170.205.178", port: 33680}, {ip: "93.156.177.91", port: 53281}, {ip: "95.60.152.139", port: 37995}],
- "AE" => [{ip: "178.32.5.90", port: 36159}],
- "KR" => [{ip: "112.217.219.179", port: 3128}, {ip: "114.141.229.2", port: 58115}, {ip: "121.139.218.165", port: 31409}, {ip: "122.49.112.2", port: 38592}, {ip: "61.42.18.132", port: 53281}],
- "BR" => [{ip: "128.201.97.157", port: 53281}, {ip: "128.201.97.158", port: 53281}, {ip: "131.0.246.157", port: 35252}, {ip: "131.161.26.90", port: 8080}, {ip: "131.72.143.100", port: 41396}, {ip: "138.0.24.66", port: 53281}, {ip: "138.121.130.50", port: 50600}, {ip: "138.121.155.127", port: 61932}, {ip: "138.121.32.133", port: 23492}, {ip: "138.185.176.63", port: 53281}, {ip: "138.204.233.190", port: 53281}, {ip: "138.204.233.242", port: 53281}, {ip: "138.219.71.74", port: 52688}, {ip: "138.36.107.24", port: 41184}, {ip: "138.94.115.166", port: 8080}, {ip: "143.0.188.161", port: 53281}, {ip: "143.202.218.135", port: 8080}, {ip: "143.208.2.42", port: 53281}, {ip: "143.208.79.223", port: 8080}, {ip: "143.255.52.102", port: 40687}, {ip: "143.255.52.116", port: 57856}, {ip: "143.255.52.117", port: 37279}, {ip: "144.217.22.128", port: 8080}, {ip: "168.0.8.225", port: 8080}, {ip: "168.0.8.55", port: 8080}, {ip: "168.121.139.54", port: 40056}, {ip: "168.181.168.23", port: 53281}, {ip: "168.181.170.198", port: 31935}, {ip: "168.232.198.25", port: 32009}, {ip: "168.232.198.35", port: 42267}, {ip: "168.232.207.145", port: 46342}, {ip: "170.0.104.107", port: 60337}, {ip: "170.0.112.2", port: 50359}, {ip: "170.0.112.229", port: 50359}, {ip: "170.238.118.107", port: 34314}, {ip: "170.239.144.9", port: 3128}, {ip: "170.247.29.138", port: 8080}, {ip: "170.81.237.36", port: 37124}, {ip: "170.84.51.74", port: 53281}, {ip: "170.84.60.222", port: 42981}, {ip: "177.10.202.67", port: 8080}, {ip: "177.101.60.86", port: 80}, {ip: "177.103.231.211", port: 55091}, {ip: "177.12.80.50", port: 50556}, {ip: "177.131.13.9", port: 20183}, {ip: "177.135.178.115", port: 42510}, {ip: "177.135.248.75", port: 20183}, {ip: "177.184.206.238", port: 39508}, {ip: "177.185.148.46", port: 58623}, {ip: "177.200.83.238", port: 8080}, {ip: "177.21.24.146", port: 666}, {ip: "177.220.188.120", port: 47556}, {ip: "177.220.188.213", port: 8080}, {ip: "177.222.229.243", port: 23500}, {ip: "177.234.161.42", port: 8080}, {ip: "177.36.11.241", port: 3128}, {ip: "177.36.12.193", port: 23500}, {ip: "177.37.199.175", port: 49608}, {ip: "177.39.187.70", port: 37315}, {ip: "177.44.175.199", port: 8080}, {ip: "177.46.148.126", port: 3128}, {ip: "177.46.148.142", port: 3128}, {ip: "177.47.194.98", port: 21231}, {ip: "177.5.98.58", port: 20183}, {ip: "177.52.55.19", port: 60901}, {ip: "177.54.200.66", port: 57526}, {ip: "177.55.255.74", port: 37147}, {ip: "177.67.217.94", port: 53281}, {ip: "177.73.248.6", port: 54381}, {ip: "177.73.4.234", port: 23500}, {ip: "177.75.143.211", port: 35955}, {ip: "177.75.161.206", port: 3128}, {ip: "177.75.86.49", port: 20183}, {ip: "177.8.216.106", port: 8080}, {ip: "177.8.216.114", port: 8080}, {ip: "177.8.37.247", port: 56052}, {ip: "177.84.216.17", port: 50569}, {ip: "177.85.200.254", port: 53095}, {ip: "177.87.169.1", port: 53281}, {ip: "179.107.97.178", port: 3128}, {ip: "179.109.144.25", port: 8080}, {ip: "179.109.193.137", port: 53281}, {ip: "179.189.125.206", port: 8080}, {ip: "179.97.30.46", port: 53100}, {ip: "186.192.195.220", port: 38983}, {ip: "186.193.11.226", port: 48999}, {ip: "186.193.26.106", port: 3128}, {ip: "186.208.220.248", port: 3128}, {ip: "186.209.243.142", port: 3128}, {ip: "186.209.243.233", port: 3128}, {ip: "186.211.106.227", port: 34334}, {ip: "186.211.160.178", port: 36756}, {ip: "186.215.133.170", port: 20183}, {ip: "186.216.81.21", port: 31773}, {ip: "186.219.214.13", port: 32708}, {ip: "186.224.94.6", port: 48957}, {ip: "186.225.97.246", port: 43082}, {ip: "186.226.171.163", port: 48698}, {ip: "186.226.179.2", port: 56089}, {ip: "186.226.234.67", port: 33834}, {ip: "186.228.147.58", port: 20183}, {ip: "186.233.97.163", port: 8888}, {ip: "186.248.170.82", port: 53281}, {ip: "186.249.213.101", port: 53482}, {ip: "186.249.213.65", port: 52018}, {ip: "186.250.213.225", port: 60774}, {ip: "186.250.96.70", port: 8080}, {ip: "186.250.96.77", port: 8080}, {ip: "187.1.43.246", port: 53396}, {ip: "187.108.36.250", port: 20183}, {ip: "187.108.38.10", port: 20183}, {ip: "187.109.36.251", port: 20183}, {ip: "187.109.40.9", port: 20183}, {ip: "187.109.56.101", port: 20183}, {ip: "187.111.90.89", port: 53281}, {ip: "187.115.10.50", port: 20183}, {ip: "187.19.62.7", port: 59010}, {ip: "187.33.79.61", port: 33469}, {ip: "187.35.158.150", port: 38872}, {ip: "187.44.1.167", port: 8080}, {ip: "187.45.127.87", port: 20183}, {ip: "187.45.156.109", port: 8080}, {ip: "187.5.218.215", port: 20183}, {ip: "187.58.65.225", port: 3128}, {ip: "187.63.111.37", port: 3128}, {ip: "187.72.166.10", port: 8080}, {ip: "187.73.68.14", port: 53281}, {ip: "187.84.177.6", port: 45903}, {ip: "187.84.191.170", port: 43936}, {ip: "187.87.204.210", port: 45597}, {ip: "187.87.39.247", port: 31793}, {ip: "189.1.16.162", port: 23500}, {ip: "189.113.124.162", port: 8080}, {ip: "189.124.195.185", port: 37318}, {ip: "189.3.196.18", port: 61595}, {ip: "189.37.33.59", port: 35532}, {ip: "189.7.49.66", port: 42700}, {ip: "189.90.194.35", port: 30843}, {ip: "189.90.248.75", port: 8080}, {ip: "189.91.231.43", port: 3128}, {ip: "191.239.243.156", port: 3128}, {ip: "191.240.154.246", port: 23500}, {ip: "191.240.156.154", port: 36127}, {ip: "191.240.99.142", port: 9090}, {ip: "191.241.226.230", port: 53281}, {ip: "191.241.228.74", port: 20183}, {ip: "191.241.228.78", port: 20183}, {ip: "191.241.33.238", port: 39188}, {ip: "191.241.36.170", port: 8080}, {ip: "191.241.36.218", port: 3128}, {ip: "191.242.182.132", port: 8081}, {ip: "191.243.221.130", port: 3128}, {ip: "191.255.207.231", port: 20183}, {ip: "191.36.192.196", port: 3128}, {ip: "191.36.244.230", port: 51377}, {ip: "191.5.0.79", port: 53281}, {ip: "191.6.228.6", port: 53281}, {ip: "191.7.193.18", port: 38133}, {ip: "191.7.20.134", port: 3128}, {ip: "192.140.91.173", port: 20183}, {ip: "200.150.86.138", port: 44677}, {ip: "200.155.36.185", port: 3128}, {ip: "200.155.36.188", port: 3128}, {ip: "200.155.39.41", port: 3128}, {ip: "200.174.158.26", port: 34112}, {ip: "200.187.177.105", port: 20183}, {ip: "200.187.87.138", port: 20183}, {ip: "200.192.252.201", port: 8080}, {ip: "200.192.255.102", port: 8080}, {ip: "200.203.144.2", port: 50262}, {ip: "200.229.238.42", port: 20183}, {ip: "200.233.134.85", port: 43172}, {ip: "200.233.136.177", port: 20183}, {ip: "200.241.44.3", port: 20183}, {ip: "200.255.122.170", port: 8080}, {ip: "200.255.122.174", port: 8080}, {ip: "201.12.21.57", port: 8080}, {ip: "201.131.224.21", port: 56200}, {ip: "201.182.223.16", port: 37492}, {ip: "201.20.89.126", port: 8080}, {ip: "201.22.95.10", port: 8080}, {ip: "201.57.167.34", port: 8080}, {ip: "201.59.200.246", port: 80}, {ip: "201.6.167.178", port: 3128}, {ip: "201.90.36.194", port: 3128}, {ip: "45.226.20.6", port: 8080}, {ip: "45.234.139.129", port: 20183}, {ip: "45.234.200.18", port: 53281}, {ip: "45.235.87.4", port: 51996}, {ip: "45.6.136.38", port: 53281}, {ip: "45.6.80.131", port: 52080}, {ip: "45.6.93.10", port: 8080}, {ip: "45.71.108.162", port: 53281}],
- "PK" => [{ip: "103.18.243.154", port: 8080}, {ip: "110.36.218.126", port: 36651}, {ip: "110.36.234.210", port: 8080}, {ip: "110.39.162.74", port: 53281}, {ip: "110.39.174.58", port: 8080}, {ip: "111.68.108.34", port: 8080}, {ip: "125.209.116.182", port: 31653}, {ip: "125.209.78.21", port: 8080}, {ip: "125.209.82.78", port: 35087}, {ip: "180.92.156.150", port: 8080}, {ip: "202.142.158.114", port: 8080}, {ip: "202.147.173.10", port: 8080}, {ip: "202.147.173.10", port: 80}, {ip: "202.69.38.82", port: 8080}, {ip: "203.128.16.126", port: 59538}, {ip: "203.128.16.154", port: 33002}, {ip: "27.255.4.170", port: 8080}],
- "ID" => [{ip: "101.128.68.113", port: 8080}, {ip: "101.255.116.113", port: 53281}, {ip: "101.255.120.170", port: 6969}, {ip: "101.255.121.74", port: 8080}, {ip: "101.255.124.242", port: 8080}, {ip: "101.255.124.242", port: 80}, {ip: "101.255.56.138", port: 53560}, {ip: "103.10.171.132", port: 41043}, {ip: "103.10.81.172", port: 80}, {ip: "103.108.158.3", port: 48196}, {ip: "103.111.219.159", port: 53281}, {ip: "103.111.54.26", port: 49781}, {ip: "103.111.54.74", port: 8080}, {ip: "103.19.110.177", port: 8080}, {ip: "103.2.146.66", port: 49089}, {ip: "103.206.168.177", port: 53281}, {ip: "103.206.253.58", port: 49573}, {ip: "103.21.92.254", port: 33929}, {ip: "103.226.49.83", port: 23500}, {ip: "103.227.147.142", port: 37581}, {ip: "103.23.101.58", port: 8080}, {ip: "103.24.107.2", port: 8181}, {ip: "103.245.19.222", port: 53281}, {ip: "103.247.122.38", port: 8080}, {ip: "103.247.218.166", port: 3128}, {ip: "103.248.219.26", port: 53634}, {ip: "103.253.2.165", port: 33543}, {ip: "103.253.2.168", port: 51229}, {ip: "103.253.2.174", port: 30827}, {ip: "103.28.114.134", port: 8080}, {ip: "103.28.220.73", port: 53281}, {ip: "103.30.246.47", port: 3128}, {ip: "103.31.45.169", port: 57655}, {ip: "103.41.122.14", port: 53281}, {ip: "103.75.101.97", port: 8080}, {ip: "103.76.17.151", port: 23500}, {ip: "103.76.50.181", port: 8080}, {ip: "103.76.50.181", port: 80}, {ip: "103.76.50.182", port: 8080}, {ip: "103.78.74.170", port: 3128}, {ip: "103.78.80.194", port: 33442}, {ip: "103.8.122.5", port: 53297}, {ip: "103.80.236.107", port: 53281}, {ip: "103.80.238.203", port: 53281}, {ip: "103.86.140.74", port: 59538}, {ip: "103.94.122.254", port: 8080}, {ip: "103.94.125.244", port: 41508}, {ip: "103.94.169.19", port: 8080}, {ip: "103.94.7.254", port: 53281}, {ip: "106.0.51.50", port: 17385}, {ip: "110.93.13.202", port: 34881}, {ip: "112.78.37.6", port: 54791}, {ip: "114.199.110.58", port: 55898}, {ip: "114.199.112.170", port: 23500}, {ip: "114.199.123.194", port: 8080}, {ip: "114.57.33.162", port: 46935}, {ip: "114.57.33.214", port: 8080}, {ip: "114.6.197.254", port: 8080}, {ip: "114.7.15.146", port: 8080}, {ip: "114.7.162.254", port: 53281}, {ip: "115.124.75.226", port: 53990}, {ip: "115.124.75.228", port: 3128}, {ip: "117.102.78.42", port: 8080}, {ip: "117.102.93.251", port: 8080}, {ip: "117.102.94.186", port: 8080}, {ip: "117.102.94.186", port: 80}, {ip: "117.103.2.249", port: 58276}, {ip: "117.54.13.174", port: 34190}, {ip: "117.74.124.129", port: 8088}, {ip: "118.97.100.83", port: 35220}, {ip: "118.97.191.162", port: 80}, {ip: "118.97.191.203", port: 8080}, {ip: "118.97.36.18", port: 8080}, {ip: "118.97.73.85", port: 53281}, {ip: "118.99.105.226", port: 8080}, {ip: "119.252.168.53", port: 53281}, {ip: "122.248.45.35", port: 53281}, {ip: "122.50.6.186", port: 8080}, {ip: "122.50.6.186", port: 80}, {ip: "123.231.226.114", port: 47562}, {ip: "123.255.202.83", port: 32523}, {ip: "124.158.164.195", port: 8080}, {ip: "124.81.99.30", port: 3128}, {ip: "137.59.162.10", port: 3128}, {ip: "139.0.29.20", port: 59532}, {ip: "139.255.123.194", port: 4550}, {ip: "139.255.16.171", port: 31773}, {ip: "139.255.17.2", port: 47421}, {ip: "139.255.19.162", port: 42371}, {ip: "139.255.7.81", port: 53281}, {ip: "139.255.91.115", port: 8080}, {ip: "139.255.92.26", port: 53281}, {ip: "158.140.181.140", port: 54041}, {ip: "160.202.40.20", port: 55655}, {ip: "175.103.42.147", port: 8080}, {ip: "180.178.98.198", port: 8080}, {ip: "180.250.101.146", port: 8080}, {ip: "182.23.107.212", port: 3128}, {ip: "182.23.2.101", port: 49833}, {ip: "182.23.7.226", port: 8080}, {ip: "182.253.209.203", port: 3128}, {ip: "183.91.66.210", port: 80}, {ip: "202.137.10.179", port: 57338}, {ip: "202.137.25.53", port: 3128}, {ip: "202.137.25.8", port: 8080}, {ip: "202.138.242.76", port: 4550}, {ip: "202.138.249.202", port: 43108}, {ip: "202.148.2.254", port: 8000}, {ip: "202.162.201.94", port: 53281}, {ip: "202.165.47.26", port: 8080}, {ip: "202.43.167.130", port: 8080}, {ip: "202.51.126.10", port: 53281}, {ip: "202.59.171.164", port: 58567}, {ip: "202.93.128.98", port: 3128}, {ip: "203.142.72.114", port: 808}, {ip: "203.153.117.65", port: 54144}, {ip: "203.189.89.1", port: 53281}, {ip: "203.77.239.18", port: 37002}, {ip: "203.99.123.25", port: 61502}, {ip: "220.247.168.163", port: 53281}, {ip: "220.247.173.154", port: 53281}, {ip: "220.247.174.206", port: 53445}, {ip: "222.124.131.211", port: 47343}, {ip: "222.124.173.146", port: 53281}, {ip: "222.124.2.131", port: 8080}, {ip: "222.124.2.186", port: 8080}, {ip: "222.124.215.187", port: 38913}, {ip: "222.124.221.179", port: 53281}, {ip: "223.25.101.242", port: 59504}, {ip: "223.25.97.62", port: 8080}, {ip: "223.25.99.38", port: 80}, {ip: "27.111.44.202", port: 80}, {ip: "27.111.47.3", port: 51144}, {ip: "36.37.124.234", port: 36179}, {ip: "36.37.124.235", port: 36179}, {ip: "36.37.81.135", port: 8080}, {ip: "36.37.89.98", port: 32323}, {ip: "36.66.217.179", port: 8080}, {ip: "36.66.98.6", port: 53281}, {ip: "36.67.143.183", port: 48746}, {ip: "36.67.206.187", port: 8080}, {ip: "36.67.32.87", port: 8080}, {ip: "36.67.93.220", port: 3128}, {ip: "36.67.93.220", port: 80}, {ip: "36.89.10.51", port: 34115}, {ip: "36.89.119.149", port: 8080}, {ip: "36.89.157.23", port: 37728}, {ip: "36.89.181.155", port: 60165}, {ip: "36.89.188.11", port: 39507}, {ip: "36.89.194.113", port: 37811}, {ip: "36.89.226.254", port: 8081}, {ip: "36.89.232.138", port: 23500}, {ip: "36.89.39.10", port: 3128}, {ip: "36.89.65.253", port: 60997}, {ip: "43.243.141.114", port: 8080}, {ip: "43.245.184.202", port: 41102}, {ip: "43.245.184.238", port: 80}, {ip: "66.96.233.225", port: 35053}, {ip: "66.96.237.253", port: 8080}],
- "BD" => [{ip: "103.103.88.91", port: 8080}, {ip: "103.106.119.154", port: 8080}, {ip: "103.106.236.1", port: 8080}, {ip: "103.106.236.41", port: 8080}, {ip: "103.108.144.139", port: 53281}, {ip: "103.109.57.218", port: 8080}, {ip: "103.109.58.242", port: 8080}, {ip: "103.112.129.106", port: 31094}, {ip: "103.112.129.82", port: 53281}, {ip: "103.114.10.177", port: 8080}, {ip: "103.114.10.250", port: 8080}, {ip: "103.15.245.26", port: 8080}, {ip: "103.195.204.73", port: 21776}, {ip: "103.197.49.106", port: 49688}, {ip: "103.198.168.29", port: 21776}, {ip: "103.214.200.6", port: 59008}, {ip: "103.218.25.161", port: 8080}, {ip: "103.218.25.41", port: 8080}, {ip: "103.218.26.204", port: 8080}, {ip: "103.218.27.221", port: 8080}, {ip: "103.231.229.90", port: 53281}, {ip: "103.239.252.233", port: 8080}, {ip: "103.239.252.50", port: 8080}, {ip: "103.239.253.193", port: 8080}, {ip: "103.250.68.193", port: 51370}, {ip: "103.5.232.146", port: 8080}, {ip: "103.73.224.53", port: 23500}, {ip: "103.9.134.73", port: 65301}, {ip: "113.11.47.242", port: 40071}, {ip: "113.11.5.67", port: 40071}, {ip: "114.31.5.34", port: 52606}, {ip: "115.127.51.226", port: 42764}, {ip: "115.127.64.62", port: 39611}, {ip: "115.127.91.106", port: 8080}, {ip: "119.40.85.198", port: 36899}, {ip: "123.200.29.110", port: 23500}, {ip: "123.49.51.42", port: 55124}, {ip: "163.47.36.90", port: 3128}, {ip: "180.211.134.158", port: 23500}, {ip: "180.211.193.74", port: 40536}, {ip: "180.92.238.226", port: 53451}, {ip: "182.160.104.213", port: 8080}, {ip: "202.191.126.58", port: 23500}, {ip: "202.4.126.170", port: 8080}, {ip: "202.5.37.241", port: 33623}, {ip: "202.5.57.5", port: 61729}, {ip: "202.79.17.65", port: 60122}, {ip: "203.188.248.52", port: 23500}, {ip: "27.147.146.78", port: 52220}, {ip: "27.147.164.10", port: 52344}, {ip: "27.147.212.38", port: 53281}, {ip: "27.147.217.154", port: 43252}, {ip: "27.147.219.102", port: 49464}, {ip: "43.239.74.137", port: 8080}, {ip: "43.240.103.252", port: 8080}, {ip: "45.125.223.57", port: 8080}, {ip: "45.125.223.81", port: 8080}, {ip: "45.251.228.122", port: 41418}, {ip: "45.64.132.137", port: 8080}, {ip: "45.64.132.137", port: 80}, {ip: "61.247.186.137", port: 8080}],
- "MX" => [{ip: "148.217.94.54", port: 3128}, {ip: "177.244.28.77", port: 53281}, {ip: "187.141.73.147", port: 53281}, {ip: "187.185.15.35", port: 53281}, {ip: "187.188.46.172", port: 53455}, {ip: "187.216.83.185", port: 8080}, {ip: "187.216.90.46", port: 53281}, {ip: "187.243.253.182", port: 33796}, {ip: "189.195.132.86", port: 43286}, {ip: "189.204.158.161", port: 8080}, {ip: "200.79.180.115", port: 8080}, {ip: "201.140.113.90", port: 37193}, {ip: "201.144.14.229", port: 53281}, {ip: "201.163.73.93", port: 53281}],
- "PH" => [{ip: "103.86.187.242", port: 23500}, {ip: "122.54.101.69", port: 8080}, {ip: "122.54.65.150", port: 8080}, {ip: "125.5.20.134", port: 53281}, {ip: "146.88.77.51", port: 8080}, {ip: "182.18.200.92", port: 8080}, {ip: "219.90.87.91", port: 53281}, {ip: "58.69.12.210", port: 8080}],
- "EG" => [{ip: "41.65.0.167", port: 8080}],
- "VN" => [{ip: "1.55.240.156", port: 53281}, {ip: "101.99.23.136", port: 3128}, {ip: "103.15.51.160", port: 8080}, {ip: "113.161.128.169", port: 60427}, {ip: "113.161.161.143", port: 57967}, {ip: "113.161.173.10", port: 3128}, {ip: "113.161.35.108", port: 30028}, {ip: "113.164.79.177", port: 46281}, {ip: "113.190.235.50", port: 34619}, {ip: "115.78.160.247", port: 8080}, {ip: "117.2.155.29", port: 47228}, {ip: "117.2.17.26", port: 53281}, {ip: "117.2.22.41", port: 41973}, {ip: "117.4.145.16", port: 51487}, {ip: "118.69.219.185", port: 55184}, {ip: "118.69.61.212", port: 53281}, {ip: "118.70.116.227", port: 61651}, {ip: "118.70.219.124", port: 53281}, {ip: "221.121.12.238", port: 36077}, {ip: "27.2.7.59", port: 52148}],
- "CD" => [{ip: "41.79.233.45", port: 8080}],
- "TR" => [{ip: "151.80.65.175", port: 3128}, {ip: "176.235.186.242", port: 37043}, {ip: "178.250.92.18", port: 8080}, {ip: "185.203.170.92", port: 8080}, {ip: "185.203.170.94", port: 8080}, {ip: "185.203.170.95", port: 8080}, {ip: "185.51.36.152", port: 41258}, {ip: "195.137.223.50", port: 41336}, {ip: "195.155.98.70", port: 52598}, {ip: "212.156.146.22", port: 40080}, {ip: "213.14.31.122", port: 44621}, {ip: "31.145.137.139", port: 31871}, {ip: "31.145.138.129", port: 31871}, {ip: "31.145.138.146", port: 34159}, {ip: "31.145.187.172", port: 30636}, {ip: "78.188.4.124", port: 34514}, {ip: "88.248.23.216", port: 36426}, {ip: "93.182.72.36", port: 8080}, {ip: "95.0.194.241", port: 9090}],
-}
diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr
new file mode 100644
index 00000000..d539dadb
--- /dev/null
+++ b/src/invidious/yt_backend/url_sanitizer.cr
@@ -0,0 +1,121 @@
+require "uri"
+
+module UrlSanitizer
+ extend self
+
+ ALLOWED_QUERY_PARAMS = {
+ channel: ["u", "user", "lb"],
+ playlist: ["list"],
+ search: ["q", "search_query", "sp"],
+ watch: [
+ "v", # Video ID
+ "list", "index", # Playlist-related
+ "playlist", # Unnamed playlist (id,id,id,...) (embed-only?)
+ "t", "time_continue", "start", "end", # Timestamp
+ "lc", # Highlighted comment (watch page only)
+ ],
+ }
+
+ # Returns whether the given string is an ASCII word. This is the same as
+ # running the following regex in US-ASCII locale: /^[\w-]+$/
+ private def ascii_word?(str : String) : Bool
+ return false if str.bytesize != str.size
+
+ str.each_byte do |byte|
+ next if 'a'.ord <= byte <= 'z'.ord
+ next if 'A'.ord <= byte <= 'Z'.ord
+ next if '0'.ord <= byte <= '9'.ord
+ next if byte == '-'.ord || byte == '_'.ord
+
+ return false
+ end
+
+ return true
+ end
+
+ # Return which kind of parameters are allowed based on the
+ # first path component (breadcrumb 0).
+ private def determine_allowed(path_root : String)
+ case path_root
+ when "watch", "w", "v", "embed", "e", "shorts", "clip"
+ return :watch
+ when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link"
+ return :channel
+ when "playlist", "mix"
+ return :playlist
+ when "results", "search"
+ return :search
+ else # hashtag, post, trending, brand URLs, etc..
+ return nil
+ end
+ end
+
+ # Create a new URI::Param containing only the allowed parameters
+ private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params
+ new_params = URI::Params.new
+
+ ALLOWED_QUERY_PARAMS[allowed_type].each do |name|
+ if unsafe_params[name]?
+ # Only copy the last parameter, in case there is more than one
+ new_params[name] = unsafe_params.fetch_all(name)[-1]
+ end
+ end
+
+ return new_params
+ end
+
+ # Transform any user-supplied youtube URL into something we can trust
+ # and use across the code.
+ def process(str : String) : URI
+ # Because URI follows RFC3986 specifications, URL without a scheme
+ # will be parsed as a relative path. So we have to add a scheme ourselves.
+ str = "https://#{str}" if !str.starts_with?(/https?:\/\//)
+
+ unsafe_uri = URI.parse(str)
+ unsafe_host = unsafe_uri.host
+ unsafe_path = unsafe_uri.path
+
+ new_uri = URI.new(path: "/")
+
+ # Redirect to homepage for bogus URLs
+ return new_uri if (unsafe_host.nil? || unsafe_path.nil?)
+
+ breadcrumbs = unsafe_path
+ .split('/', remove_empty: true)
+ .compact_map do |bc|
+ # Exclude attempts at path trasversal
+ next if bc == "." || bc == ".."
+
+ # Non-alnum characters are unlikely in a genuine URL
+ next if !ascii_word?(bc)
+
+ bc
+ end
+
+ # If nothing remains, it's either a legit URL to the homepage
+ # (who does that!?) or because we filtered some junk earlier.
+ return new_uri if breadcrumbs.empty?
+
+ # Replace the original query parameters with the sanitized ones
+ case unsafe_host
+ when .ends_with?("youtube.com")
+ # Use our sanitized path (not forgetting the leading '/')
+ new_uri.path = "/#{breadcrumbs.join('/')}"
+
+ # Then determine which params are allowed, and copy them over
+ if allowed = determine_allowed(breadcrumbs[0])
+ new_uri.query_params = copy_params(unsafe_uri.query_params, allowed)
+ end
+ when "youtu.be"
+ # Always redirect to the watch page
+ new_uri.path = "/watch"
+
+ new_params = copy_params(unsafe_uri.query_params, :watch)
+ new_params["v"] = breadcrumbs[0]
+
+ new_uri.query_params = new_params
+ end
+
+ return new_uri
+ end
+end
diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index a5e621f2..ec080d8c 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -5,19 +5,21 @@
module YoutubeAPI
extend self
- private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
-
- private ANDROID_APP_VERSION = "18.20.38"
- # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308
- private ANDROID_USER_AGENT = "com.google.android.youtube/18.20.38 (Linux; U; Android 12; US) gzip"
- private ANDROID_SDK_VERSION = 31_i64
+ # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
+ private ANDROID_APP_VERSION = "19.32.34"
private ANDROID_VERSION = "12"
+ private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip"
+ private ANDROID_SDK_VERSION = 31_i64
+
+ private ANDROID_TS_APP_VERSION = "1.9"
+ private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
- private IOS_APP_VERSION = "18.21.3"
- # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330
- private IOS_USER_AGENT = "com.google.ios.youtube/18.21.3 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)"
- # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224
- private IOS_VERSION = "15.6.0.19G71"
+ # For Apple device names, see https://gist.github.com/adamawolf/3048717
+ # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
+ # then go to the dedicated article of the major version you want.
+ private IOS_APP_VERSION = "19.32.8"
+ private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"
+ private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build
private WINDOWS_VERSION = "10.0"
@@ -27,10 +29,12 @@ module YoutubeAPI
WebEmbeddedPlayer
WebMobile
WebScreenEmbed
+ WebCreator
Android
AndroidEmbeddedPlayer
AndroidScreenEmbed
+ AndroidTestSuite
IOS
IOSEmbedded
@@ -45,8 +49,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
- version: "2.20230602.01.00",
- api_key: DEFAULT_API_KEY,
+ version: "2.20240814.00.00",
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -55,8 +58,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER",
name_proto: "56",
- version: "1.20220803.01.00",
- api_key: DEFAULT_API_KEY,
+ version: "1.20240812.01.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -65,8 +67,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
- version: "2.20230531.05.00",
- api_key: DEFAULT_API_KEY,
+ version: "2.20240813.02.00",
os_name: "Android",
os_version: ANDROID_VERSION,
platform: "MOBILE",
@@ -74,13 +75,20 @@ module YoutubeAPI
ClientType::WebScreenEmbed => {
name: "WEB",
name_proto: "1",
- version: "2.20220804.00.00",
- api_key: DEFAULT_API_KEY,
+ version: "2.20240814.00.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
platform: "DESKTOP",
},
+ ClientType::WebCreator => {
+ name: "WEB_CREATOR",
+ name_proto: "62",
+ version: "1.20240918.03.00",
+ os_name: "Windows",
+ os_version: WINDOWS_VERSION,
+ platform: "DESKTOP",
+ },
# Android
@@ -88,7 +96,6 @@ module YoutubeAPI
name: "ANDROID",
name_proto: "3",
version: ANDROID_APP_VERSION,
- api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT,
os_name: "Android",
@@ -99,13 +106,11 @@ module YoutubeAPI
name: "ANDROID_EMBEDDED_PLAYER",
name_proto: "55",
version: ANDROID_APP_VERSION,
- api_key: DEFAULT_API_KEY,
},
ClientType::AndroidScreenEmbed => {
name: "ANDROID",
name_proto: "3",
version: ANDROID_APP_VERSION,
- api_key: DEFAULT_API_KEY,
screen: "EMBED",
android_sdk_version: ANDROID_SDK_VERSION,
user_agent: ANDROID_USER_AGENT,
@@ -113,6 +118,16 @@ module YoutubeAPI
os_version: ANDROID_VERSION,
platform: "MOBILE",
},
+ ClientType::AndroidTestSuite => {
+ name: "ANDROID_TESTSUITE",
+ name_proto: "30",
+ version: ANDROID_TS_APP_VERSION,
+ android_sdk_version: ANDROID_SDK_VERSION,
+ user_agent: ANDROID_TS_USER_AGENT,
+ os_name: "Android",
+ os_version: ANDROID_VERSION,
+ platform: "MOBILE",
+ },
# IOS
@@ -120,7 +135,6 @@ module YoutubeAPI
name: "IOS",
name_proto: "5",
version: IOS_APP_VERSION,
- api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc",
user_agent: IOS_USER_AGENT,
device_make: "Apple",
device_model: "iPhone14,5",
@@ -132,7 +146,6 @@ module YoutubeAPI
name: "IOS_MESSAGES_EXTENSION",
name_proto: "66",
version: IOS_APP_VERSION,
- api_key: DEFAULT_API_KEY,
user_agent: IOS_USER_AGENT,
device_make: "Apple",
device_model: "iPhone14,5",
@@ -143,9 +156,8 @@ module YoutubeAPI
ClientType::IOSMusic => {
name: "IOS_MUSIC",
name_proto: "26",
- version: "5.21",
- api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s",
- user_agent: "com.google.ios.youtubemusic/5.21 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)",
+ version: "7.14",
+ user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)",
device_make: "Apple",
device_model: "iPhone14,5",
os_name: "iPhone",
@@ -158,14 +170,12 @@ module YoutubeAPI
ClientType::TvHtml5 => {
name: "TVHTML5",
name_proto: "7",
- version: "7.20220325",
- api_key: DEFAULT_API_KEY,
+ version: "7.20240813.07.00",
},
ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
name_proto: "85",
version: "2.0",
- api_key: DEFAULT_API_KEY,
screen: "EMBED",
},
}
@@ -187,10 +197,6 @@ module YoutubeAPI
# conf_2 = ClientConfig.new(client_type: ClientType::Android)
# YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2)
#
- # # Proxy request through russian proxies
- # conf_3 = ClientConfig.new(proxy_region: "RU")
- # YoutubeAPI::next({video_id: "dQw4w9WgXcQ"}, client_config: conf_3)
- # ```
#
struct ClientConfig
# Type of client to emulate.
@@ -201,16 +207,11 @@ module YoutubeAPI
# (this is passed as the `gl` parameter).
property region : String | Nil
- # ISO code of country where the proxy is located.
- # Used in case of geo-restricted videos.
- property proxy_region : String | Nil
-
# Initialization function
def initialize(
*,
@client_type = ClientType::Web,
@region = "US",
- @proxy_region = nil
)
end
@@ -230,11 +231,6 @@ module YoutubeAPI
end
# :ditto:
- def api_key : String
- HARDCODED_CLIENTS[@client_type][:api_key]
- end
-
- # :ditto:
def screen : String
HARDCODED_CLIENTS[@client_type][:screen]? || ""
end
@@ -270,9 +266,8 @@ module YoutubeAPI
# Convert to string, for logging purposes
def to_s
return {
- client_type: self.name,
- region: @region,
- proxy_region: @proxy_region,
+ client_type: self.name,
+ region: @region,
}.to_s
end
end
@@ -286,7 +281,7 @@ module YoutubeAPI
# Return, as a Hash, the "context" data required to request the
# youtube API endpoints.
#
- private def make_context(client_config : ClientConfig | Nil) : Hash
+ private def make_context(client_config : ClientConfig | Nil, video_id = "dQw4w9WgXcQ") : Hash
# Use the default client config if nil is passed
client_config ||= DEFAULT_CLIENT_CONFIG
@@ -306,7 +301,7 @@ module YoutubeAPI
if client_config.screen == "EMBED"
client_context["thirdParty"] = {
- "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ "embedUrl" => "https://www.youtube.com/embed/#{video_id}",
} of String => String | Int64
end
@@ -334,6 +329,10 @@ module YoutubeAPI
client_context["client"]["platform"] = platform
end
+ if CONFIG.visitor_data.is_a?(String)
+ client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String)
+ end
+
return client_context
end
@@ -371,7 +370,7 @@ module YoutubeAPI
browse_id : String,
*, # Force the following parameters to be passed by name
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
# JSON Request data, required by the API
data = {
@@ -465,21 +464,34 @@ module YoutubeAPI
video_id : String,
*, # Force the following parameters to be passed by name
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
+ # Playback context, separate because it can be different between clients
+ playback_ctx = {
+ "html5Preference" => "HTML5_PREF_WANTS",
+ "referer" => "https://www.youtube.com/watch?v=#{video_id}",
+ } of String => String | Int64
+
+ if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s }
+ if sts = DECRYPT_FUNCTION.try &.get_sts
+ playback_ctx["signatureTimestamp"] = sts.to_i64
+ end
+ end
+
# JSON Request data, required by the API
data = {
"contentCheckOk" => true,
"videoId" => video_id,
- "context" => self.make_context(client_config),
+ "context" => self.make_context(client_config, video_id),
"racyCheckOk" => true,
"user" => {
"lockedSafetyMode" => false,
},
"playbackContext" => {
- "contentPlaybackContext" => {
- "html5Preference": "HTML5_PREF_WANTS",
- },
+ "contentPlaybackContext" => playback_ctx,
+ },
+ "serviceIntegrityDimensions" => {
+ "poToken" => CONFIG.po_token,
},
}
@@ -545,7 +557,7 @@ module YoutubeAPI
def search(
search_query : String,
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
)
# JSON Request data, required by the API
data = {
@@ -571,7 +583,7 @@ module YoutubeAPI
def get_transcript(
params : String,
- client_config : ClientConfig | Nil = nil
+ client_config : ClientConfig | Nil = nil,
) : Hash(String, JSON::Any)
data = {
"context" => self.make_context(client_config),
@@ -593,13 +605,13 @@ module YoutubeAPI
def _post_json(
endpoint : String,
data : Hash,
- client_config : ClientConfig | Nil
+ client_config : ClientConfig | Nil,
) : Hash(String, JSON::Any)
# Use the default client config if nil is passed
client_config ||= DEFAULT_CLIENT_CONFIG
# Query parameters
- url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false"
+ url = "#{endpoint}?prettyPrint=false"
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
@@ -613,14 +625,23 @@ module YoutubeAPI
headers["User-Agent"] = user_agent
end
+ if CONFIG.visitor_data.is_a?(String)
+ headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String)
+ end
+
# Logging
LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}")
LOGGER.trace("YoutubeAPI: POST data: #{data}")
# Send the POST request
- body = YT_POOL.client(client_config.proxy_region) do |client|
+ body = YT_POOL.client() do |client|
client.post(url, headers: headers, body: data.to_json) do |response|
+ if response.status_code != 200
+ raise InfoException.new("Error: non 200 status code. Youtube API returned \
+ status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
+ https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
+ end
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end
end