summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.ameba.yml22
-rw-r--r--.github/CODEOWNERS2
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md6
-rw-r--r--.github/workflows/build-nightly-container.yml13
-rw-r--r--.github/workflows/build-stable-container.yml21
-rw-r--r--.github/workflows/ci.yml49
-rw-r--r--.github/workflows/stale.yml13
-rw-r--r--CHANGELOG.md329
-rw-r--r--Makefile9
-rw-r--r--assets/css/default.css19
-rw-r--r--assets/css/player.css1
-rw-r--r--assets/js/handlers.js4
-rw-r--r--assets/js/pagination.js93
-rw-r--r--assets/js/player.js38
-rw-r--r--assets/js/playlist_widget.js6
-rw-r--r--assets/js/subscribe_widget.js4
-rw-r--r--assets/js/watch.js4
-rw-r--r--assets/js/watched_widget.js4
-rw-r--r--config/config.example.yml80
-rw-r--r--docker/Dockerfile6
-rw-r--r--docker/Dockerfile.arm649
-rw-r--r--locales/ar.json2
-rw-r--r--locales/bg.json8
-rw-r--r--locales/ca.json4
-rw-r--r--locales/cs.json2
-rw-r--r--locales/cy.json385
-rw-r--r--locales/de.json13
-rw-r--r--locales/el.json10
-rw-r--r--locales/en-US.json9
-rw-r--r--locales/es.json2
-rw-r--r--locales/fa.json24
-rw-r--r--locales/fi.json120
-rw-r--r--locales/fr.json17
-rw-r--r--locales/hr.json26
-rw-r--r--locales/hu-HU.json20
-rw-r--r--locales/ia.json4
-rw-r--r--locales/is.json293
-rw-r--r--locales/it.json4
-rw-r--r--locales/ja.json6
-rw-r--r--locales/ko.json16
-rw-r--r--locales/nb-NO.json19
-rw-r--r--locales/nl.json42
-rw-r--r--locales/pl.json2
-rw-r--r--locales/pt-BR.json4
-rw-r--r--locales/pt.json6
-rw-r--r--locales/ru.json14
-rw-r--r--locales/sq.json18
-rw-r--r--locales/sr.json6
-rw-r--r--locales/sr_Cyrl.json6
-rw-r--r--locales/sv-SE.json8
-rw-r--r--locales/tr.json6
-rw-r--r--locales/uk.json6
-rw-r--r--locales/zh-CN.json2
-rw-r--r--locales/zh-TW.json6
m---------mocks0
-rw-r--r--shard.lock14
-rw-r--r--shard.yml24
-rw-r--r--spec/invidious/hashtag_spec.cr16
-rw-r--r--spec/invidious/search/iv_filters_spec.cr1
-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.cr31
-rw-r--r--src/invidious/channels/about.cr175
-rw-r--r--src/invidious/channels/channels.cr18
-rw-r--r--src/invidious/channels/videos.cr192
-rw-r--r--src/invidious/comments/content.cr36
-rw-r--r--src/invidious/config.cr37
-rw-r--r--src/invidious/database/playlists.cr1
-rw-r--r--src/invidious/database/users.cr10
-rw-r--r--src/invidious/frontend/comments_youtube.cr4
-rw-r--r--src/invidious/frontend/misc.cr4
-rw-r--r--src/invidious/frontend/pagination.cr32
-rw-r--r--src/invidious/frontend/watch_page.cr2
-rw-r--r--src/invidious/helpers/crystal_class_overrides.cr8
-rw-r--r--src/invidious/helpers/errors.cr8
-rw-r--r--src/invidious/helpers/handlers.cr3
-rw-r--r--src/invidious/helpers/i18n.cr15
-rw-r--r--src/invidious/helpers/i18next.cr17
-rw-r--r--src/invidious/helpers/logger.cr29
-rw-r--r--src/invidious/helpers/serialized_yt_data.cr52
-rw-r--r--src/invidious/helpers/sig_helper.cr349
-rw-r--r--src/invidious/helpers/signatures.cr100
-rw-r--r--src/invidious/helpers/utils.cr68
-rw-r--r--src/invidious/http_server/utils.cr5
-rw-r--r--src/invidious/jobs/instance_refresh_job.cr97
-rw-r--r--src/invidious/jobs/notification_job.cr90
-rw-r--r--src/invidious/jobs/update_decrypt_function_job.cr14
-rw-r--r--src/invidious/jsonify/api_v1/video_json.cr107
-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.cr40
-rw-r--r--src/invidious/routes/api/v1/channels.cr90
-rw-r--r--src/invidious/routes/api/v1/misc.cr24
-rw-r--r--src/invidious/routes/api/v1/search.cr4
-rw-r--r--src/invidious/routes/api/v1/videos.cr74
-rw-r--r--src/invidious/routes/channels.cr78
-rw-r--r--src/invidious/routes/embed.cr6
-rw-r--r--src/invidious/routes/feeds.cr71
-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.cr7
-rw-r--r--src/invidious/routes/search.cr6
-rw-r--r--src/invidious/routes/subscriptions.cr14
-rw-r--r--src/invidious/routes/video_playback.cr19
-rw-r--r--src/invidious/routes/watch.cr20
-rw-r--r--src/invidious/routing.cr16
-rw-r--r--src/invidious/search/filters.cr2
-rw-r--r--src/invidious/search/query.cr40
-rw-r--r--src/invidious/user/imports.cr67
-rw-r--r--src/invidious/user/preferences.cr1
-rw-r--r--src/invidious/videos.cr142
-rw-r--r--src/invidious/videos/caption.cr1
-rw-r--r--src/invidious/videos/description.cr8
-rw-r--r--src/invidious/videos/parser.cr85
-rw-r--r--src/invidious/videos/storyboard.cr122
-rw-r--r--src/invidious/videos/transcript.cr4
-rw-r--r--src/invidious/videos/video_preferences.cr6
-rw-r--r--src/invidious/views/channel.ecr6
-rw-r--r--src/invidious/views/components/item.ecr6
-rw-r--r--src/invidious/views/components/items_paginated.ecr10
-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/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.ecr9
-rw-r--r--src/invidious/yt_backend/connection_pool.cr95
-rw-r--r--src/invidious/yt_backend/extractors.cr184
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr2
-rw-r--r--src/invidious/yt_backend/url_sanitizer.cr121
-rw-r--r--src/invidious/yt_backend/youtube_api.cr85
137 files changed, 3747 insertions, 1377 deletions
diff --git a/.ameba.yml b/.ameba.yml
index c7629dcb..36d7c48f 100644
--- a/.ameba.yml
+++ b/.ameba.yml
@@ -23,6 +23,10 @@ Lint/ShadowingOuterLocalVar:
Lint/NotNil:
Enabled: false
+Lint/SpecFilename:
+ Excluded:
+ - spec/parsers_helper.cr
+
#
# Style
@@ -34,11 +38,27 @@ Style/RedundantBegin:
Style/RedundantReturn:
Enabled: false
+Style/RedundantNext:
+ Enabled: false
+
Style/ParenthesesAroundCondition:
Enabled: false
# This requires a rewrite of most data structs (and their usage) in Invidious.
-Style/QueryBoolMethods:
+Naming/QueryBoolMethods:
+ Enabled: false
+
+Naming/AccessorMethodName:
+ Enabled: false
+
+Naming/BlockParameterName:
+ Enabled: false
+
+# Hides TODO comment warnings.
+#
+# Call `bin/ameba --only Documentation/DocumentationAdmonition` to
+# list them
+Documentation/DocumentationAdmonition:
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
index bee27600..5ff3322f 100644
--- a/.github/workflows/build-nightly-container.yml
+++ b/.github/workflows/build-nightly-container.yml
@@ -23,19 +23,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Install Crystal
- uses: crystal-lang/install-crystal@v1.8.2
- with:
- crystal: 1.12.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@v3
with:
diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml
index b5fbc705..25571ed6 100644
--- a/.github/workflows/build-stable-container.yml
+++ b/.github/workflows/build-stable-container.yml
@@ -1,6 +1,7 @@
name: Build and release container
on:
+ workflow_dispatch:
push:
tags:
- "v*"
@@ -13,19 +14,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- - name: Install Crystal
- uses: crystal-lang/install-crystal@v1.8.2
- with:
- crystal: 1.12.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@v3
with:
@@ -46,9 +34,11 @@ jobs:
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
+ flavor: |
+ latest=false
tags: |
type=semver,pattern={{version}}
- type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ type=raw,value=latest
labels: |
quay.expires-after=12w
@@ -70,10 +60,11 @@ jobs:
with:
images: quay.io/invidious/invidious
flavor: |
+ latest=false
suffix=-arm64
tags: |
type=semver,pattern={{version}}
- type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
+ type=raw,value=latest
labels: |
quay.expires-after=12w
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 925a8fc7..5f859613 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,10 +38,10 @@ jobs:
matrix:
stable: [true]
crystal:
- - 1.9.2
- - 1.10.1
- - 1.11.2
- 1.12.1
+ - 1.13.2
+ - 1.14.0
+ - 1.15.0
include:
- crystal: nightly
stable: false
@@ -51,6 +51,11 @@ jobs:
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
@@ -90,10 +89,10 @@ jobs:
- 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
@@ -124,14 +123,19 @@ jobs:
- name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done
- ameba_lint:
+ 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
@@ -142,10 +146,21 @@ jobs:
path: |
./lib
./bin
- key: shards-${{ hashFiles('shard.lock') }}
+ key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }}
- name: Install Shards
- run: shards install
+ 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/stale.yml b/.github/workflows/stale.yml
index 16d3269b..498a2c1b 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -13,14 +13,11 @@ jobs:
- 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,exempt-stale
+ 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 f6f67160..5af38003 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,333 @@
# CHANGELOG
-## 2024-04-26
+## 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)
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/assets/css/default.css b/assets/css/default.css
index a47762ec..2cedcf0c 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -278,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%;
@@ -310,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;
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/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/pagination.js b/assets/js/pagination.js
new file mode 100644
index 00000000..2e560a34
--- /dev/null
+++ b/assets/js/pagination.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const CURRENT_CONTINUATION = (new URL(document.location)).searchParams.get("continuation");
+const CONT_CACHE_KEY = `continuation_cache_${encodeURIComponent(window.location.pathname)}`;
+
+function get_data(){
+ return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
+}
+
+function save_data(){
+ const prev_data = get_data();
+ prev_data.push(CURRENT_CONTINUATION);
+
+ sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
+}
+
+function button_press(){
+ let prev_data = get_data();
+ if (!prev_data.length) return null;
+
+ // Sanity check. Nowhere should the current continuation token exist in the cache
+ // but it can happen when using the browser's back feature. As such we'd need to travel
+ // back to the point where the current continuation token first appears in order to
+ // account for the rewind.
+ const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
+ if (conflict_at != -1) {
+ prev_data.length = conflict_at;
+ }
+
+ const prev_ctoken = prev_data.pop();
+
+ // On the first page, the stored continuation token is null.
+ if (prev_ctoken === null) {
+ sessionStorage.removeItem(CONT_CACHE_KEY);
+ let url = set_continuation();
+ window.location.href = url;
+
+ return;
+ }
+
+ sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
+ let url = set_continuation(prev_ctoken);
+
+ window.location.href = url;
+};
+
+// Method to set the current page's continuation token
+// Removes the continuation parameter when a continuation token is not given
+function set_continuation(prev_ctoken = null){
+ let url = window.location.href.split('?')[0];
+ let params = window.location.href.split('?')[1];
+ let url_params = new URLSearchParams(params);
+
+ if (prev_ctoken) {
+ url_params.set("continuation", prev_ctoken);
+ } else {
+ url_params.delete('continuation');
+ };
+
+ if(Array.from(url_params).length > 0){
+ return `${url}?${url_params.toString()}`;
+ } else {
+ return url;
+ }
+}
+
+addEventListener('DOMContentLoaded', function(){
+ const pagination_data = JSON.parse(document.getElementById('pagination-data').textContent);
+ const next_page_containers = document.getElementsByClassName("page-next-container");
+
+ for (let container of next_page_containers){
+ const next_page_button = container.getElementsByClassName("pure-button")
+
+ // exists?
+ if (next_page_button.length > 0){
+ next_page_button[0].addEventListener("click", save_data);
+ }
+ }
+
+ // Only add previous page buttons when not on the first page
+ if (CURRENT_CONTINUATION) {
+ const prev_page_containers = document.getElementsByClassName("page-prev-container")
+
+ for (let container of prev_page_containers) {
+ if (pagination_data.is_rtl) {
+ container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page}&nbsp;&nbsp;<i class="icon ion-ios-arrow-forward"></i></button>`
+ } else {
+ container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i>&nbsp;&nbsp;${pagination_data.prev_page}</button>`
+ }
+ container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
+ }
+ }
+});
diff --git a/assets/js/player.js b/assets/js/player.js
index 71c5e7da..f32c9b56 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: {
@@ -135,26 +134,32 @@ player.on('timeupdate', function () {
// YouTube links
let elem_yt_watch = document.getElementById('link-yt-watch');
+ if (elem_yt_watch) {
+ let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
+ elem_yt_watch.href = addCurrentTimeToURL(base_url_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);
+ if (elem_yt_embed) {
+ let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
+ 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');
+ if (elem_iv_embed) {
+ let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
+ elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
+ }
+
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);
+ if (elem_iv_other) {
+ let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
+ elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
+ }
});
@@ -351,7 +356,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();
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/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 26ad138f..d869d40d 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -67,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;
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/config/config.example.yml b/config/config.example.yml
index 38085a20..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:
@@ -698,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/Dockerfile b/docker/Dockerfile
index 3d9323fd..900c9e74 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,4 @@
-FROM crystallang/crystal:1.12.1-alpine AS builder
+FROM crystallang/crystal:1.12.2-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static
@@ -32,8 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \
fi
-FROM alpine:3.18
-RUN apk add --no-cache rsvg-convert 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 f054b326..ce9bab08 100644
--- a/docker/Dockerfile.arm64
+++ b/docker/Dockerfile.arm64
@@ -1,5 +1,6 @@
-FROM alpine:3.19 AS builder
-RUN apk add --no-cache 'crystal=1.10.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
+FROM alpine:3.20 AS builder
+RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \
+ zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release
@@ -32,8 +33,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \
fi
-FROM alpine:3.18
-RUN apk add --no-cache rsvg-convert 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/locales/ar.json b/locales/ar.json
index 5d8b230f..b6bab59b 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -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": "نقطتان",
diff --git a/locales/bg.json b/locales/bg.json
index bcce6a7a..baa683c9 100644
--- a/locales/bg.json
+++ b/locales/bg.json
@@ -487,5 +487,11 @@
"generic_views_count": "{{count}} гледане",
"generic_views_count_plural": "{{count}} гледания",
"Next page": "Следваща страница",
- "Import YouTube watch history (.json)": "Импортиране на историята на гледане от YouTube (.json)"
+ "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/ca.json b/locales/ca.json
index 4ae55804..bbcadf89 100644
--- a/locales/ca.json
+++ b/locales/ca.json
@@ -487,5 +487,7 @@
"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)"
+ "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 1350f146..6e66178d 100644
--- a/locales/cs.json
+++ b/locales/cs.json
@@ -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",
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/de.json b/locales/de.json
index 46327f57..a9a62619 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -11,6 +11,7 @@
"last": "neueste",
"Next page": "Nächste Seite",
"Previous page": "Vorherige Seite",
+ "First page": "Erste Seite",
"Clear watch history?": "Verlauf löschen?",
"New password": "Neues Passwort",
"New passwords must match": "Neue Passwörter müssen übereinstimmen",
@@ -21,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)",
@@ -47,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: ",
@@ -322,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",
@@ -454,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)",
@@ -493,5 +495,8 @@
"Add to playlist": "Einer Wiedergabeliste hinzufügen",
"Search for videos": "Nach Videos suchen",
"toggle_theme": "Thema wechseln",
- "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: "
+ "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 1d827eba..38550458 100644
--- a/locales/el.json
+++ b/locales/el.json
@@ -486,5 +486,13 @@
"Switch Invidious Instance": "Αλλαγή Instance Invidious",
"Standard YouTube license": "Τυπική άδεια YouTube",
"search_filters_duration_option_medium": "Μεσαία (4 - 20 λεπτά)",
- "search_filters_date_label": "Ημερομηνία αναφόρτωσης"
+ "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 3987f796..381bcab5 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -33,6 +33,7 @@
"last": "last",
"Next page": "Next page",
"Previous page": "Previous page",
+ "First page": "First page",
"Clear watch history?": "Clear watch history?",
"New password": "New password",
"New passwords must match": "New passwords must match",
@@ -71,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: ",
@@ -190,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: ",
@@ -285,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)",
@@ -422,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",
@@ -454,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: ",
diff --git a/locales/es.json b/locales/es.json
index 1d082e60..fda29198 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -478,7 +478,7 @@
"tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokens",
"tokens_count_2": "{{count}} tokens",
- "search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.",
+ "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",
diff --git a/locales/fa.json b/locales/fa.json
index d0251201..b146385e 100644
--- a/locales/fa.json
+++ b/locales/fa.json
@@ -17,7 +17,7 @@
"View playlist on YouTube": "دیدن فهرست پخش در یوتیوب",
"newest": "تازه‌ترین",
"oldest": "کهنه‌ترین",
- "popular": "محبوب",
+ "popular": "پرطرفدار",
"last": "آخرین",
"Next page": "صفحه بعد",
"Previous page": "صفحه قبل",
@@ -31,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)",
@@ -328,7 +328,7 @@
"generic_count_seconds": "{{count}} ثانیه",
"generic_count_seconds_plural": "{{count}} ثانیه",
"Fallback comments: ": "نظرات عقب گرد: ",
- "Popular": "محبوب",
+ "Popular": "پربیننده",
"Search": "جست و جو",
"Top": "بالا",
"About": "درباره",
@@ -360,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": "این ماه",
@@ -461,7 +461,7 @@
"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": "افزودن ویدیو",
@@ -484,5 +484,17 @@
"channel_tab_shorts_label": "Shortها",
"channel_tab_playlists_label": "فهرست‌های پخش",
"channel_tab_channels_label": "کانال‌ها",
- "error_video_not_in_playlist": "ویدیوی درخواستی معلق به این فهرست پخش نیست. <a href=\"`x`\">کلیک کنید تا به صفحهٔ اصلی فهرست پخش بروید.</a>"
+ "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 14c2b0fc..b0df1e46 100644
--- a/locales/fi.json
+++ b/locales/fi.json
@@ -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 251e88bc..6147a159 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -18,7 +18,7 @@
"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",
@@ -44,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)",
@@ -484,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",
@@ -504,5 +504,14 @@
"Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)",
"channel_tab_releases_label": "Parutions",
"channel_tab_podcasts_label": "Émissions audio",
- "Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)"
+ "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/hr.json b/locales/hr.json
index 91425248..7b76a41f 100644
--- a/locales/hr.json
+++ b/locales/hr.json
@@ -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",
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
index 2c8cb2b0..236ec4b4 100644
--- a/locales/ia.json
+++ b/locales/ia.json
@@ -7,7 +7,7 @@
"invidious": "Invidious",
"Image CAPTCHA": "Imagine CAPTCHA",
"newest": "plus nove",
- "generic_button_save": "Salvar",
+ "generic_button_save": "Salveguardar",
"Dark mode: ": "Modo obscur: ",
"preferences_dark_mode_label": "Thema: ",
"preferences_category_subscription": "Preferentias de subscription",
@@ -23,7 +23,7 @@
"light": "clar",
"No": "Non",
"youtube": "YouTube",
- "LIVE": "IN DIRECTE",
+ "LIVE": "IN DIRECTO",
"reddit": "Reddit",
"preferences_category_player": "Preferentias de reproductor",
"Preferences": "Preferentias",
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 79aa6c16..309adb13 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -30,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)",
@@ -449,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)",
diff --git a/locales/ja.json b/locales/ja.json
index d430b2a4..7fc9d604 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -363,7 +363,7 @@
"search_filters_features_option_location": "場所",
"search_filters_features_option_hdr": "HDR",
"Current version: ": "現在のバージョン: ",
- "next_steps_error_message": "以下をお試してください: ",
+ "next_steps_error_message": "以下をお試しください: ",
"next_steps_error_message_refresh": "再読み込み",
"next_steps_error_message_go_to_youtube": "YouTubeを開く",
"search_filters_duration_option_short": "4分未満",
@@ -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 のバグのようです!",
diff --git a/locales/ko.json b/locales/ko.json
index 7611e8e7..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: ": "대체 자막: ",
@@ -48,7 +48,7 @@
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
"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` 업로드",
@@ -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)": "중국어 (중국)",
diff --git a/locales/nb-NO.json b/locales/nb-NO.json
index cf0ee286..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",
@@ -487,5 +487,14 @@
"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)"
+ "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 d495a2d1..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",
@@ -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": "Daarna moet u 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",
@@ -477,7 +477,7 @@
"Song: ": "Lied: ",
"generic_channels_count": "{{count}} kanaal",
"generic_channels_count_plural": "{{count}} kanalen",
- "Popular enabled: ": "Populair geactiveerd: ",
+ "Popular enabled: ": "Populair ingeschakeld: ",
"channel_tab_playlists_label": "Afspeellijsten",
"generic_button_edit": "Bewerken",
"Music in this video": "Muziek in deze video",
diff --git a/locales/pl.json b/locales/pl.json
index f24e9766..73d65647 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -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)",
diff --git a/locales/pt-BR.json b/locales/pt-BR.json
index 1637b5d8..1d29d2fe 100644
--- a/locales/pt-BR.json
+++ b/locales/pt-BR.json
@@ -41,7 +41,7 @@
"Time (h:mm:ss):": "Hora (h:mm:ss):",
"Text CAPTCHA": "Mudar para um desafio de texto",
"Image CAPTCHA": "Mudar para um desafio visual",
- "Sign In": "Entrar",
+ "Sign In": "Fazer login",
"Register": "Criar conta",
"E-mail": "E-mail",
"Preferences": "Preferências",
@@ -474,7 +474,7 @@
"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)",
diff --git a/locales/pt.json b/locales/pt.json
index 463dbf3a..0bb1be66 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -253,7 +253,7 @@
"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 via 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",
@@ -448,7 +448,7 @@
"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)",
@@ -508,7 +508,7 @@
"toggle_theme": "Trocar tema",
"Add to playlist": "Adicionar à lista de reprodução",
"Add to playlist: ": "Adicionar à lista de reprodução: ",
- "Answer": "Resposta",
+ "Answer": "Responder",
"Search for videos": "Procurar vídeos",
"carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel",
diff --git a/locales/ru.json b/locales/ru.json
index 61bf9e92..31ef1a33 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -11,6 +11,7 @@
"last": "последние",
"Next page": "Следующая страница",
"Previous page": "Предыдущая страница",
+ "First page": "Первая страница",
"Clear watch history?": "Очистить историю просмотров?",
"New password": "Новый пароль",
"New passwords must match": "Новые пароли не совпадают",
@@ -21,7 +22,7 @@
"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)",
@@ -504,5 +505,14 @@
"generic_channels_count_0": "{{count}} канал",
"generic_channels_count_1": "{{count}} канала",
"generic_channels_count_2": "{{count}} каналов",
- "Import YouTube watch history (.json)": "Импортировать историю просмотра из YouTube (.json)"
+ "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/sq.json b/locales/sq.json
index 363a70b0..ea20ce56 100644
--- a/locales/sq.json
+++ b/locales/sq.json
@@ -257,13 +257,13 @@
"Video mode": "Mënyrë video",
"channel_tab_videos_label": "Video",
"search_filters_sort_option_rating": "Vlerësim",
- "search_filters_sort_option_date": "Datë Ngarkimi",
+ "search_filters_sort_option_date": "Datë ngarkimi",
"search_filters_sort_option_views": "Numër parjesh",
"search_filters_type_label": "Lloj",
"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",
@@ -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: ",
@@ -484,5 +484,13 @@
"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"
+ "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 4b24e7c0..d28b2459 100644
--- a/locales/sr.json
+++ b/locales/sr.json
@@ -174,7 +174,7 @@
"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]|$)": "Pogledaj `x` komentar",
- "": "Pogledaj`x` komentare"
+ "": "Pogledaj`x` komentara"
},
"View Reddit comments": "Pogledaj Reddit komentare",
"CAPTCHA is a required field": "CAPTCHA je obavezno polje",
@@ -211,7 +211,7 @@
"About": "O sajtu",
"footer_source_code": "Izvorni kôd",
"footer_original_source_code": "Originalni izvorni kôd",
- "preferences_related_videos_label": "Prikaži povezane video snimke: ",
+ "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): ",
@@ -404,7 +404,7 @@
"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>.",
+ "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",
diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json
index 57c6de9c..483e7fc4 100644
--- a/locales/sr_Cyrl.json
+++ b/locales/sr_Cyrl.json
@@ -60,7 +60,7 @@
"reddit": "Reddit",
"preferences_captions_label": "Подразумевани титлови: ",
"Fallback captions: ": "Резервни титлови: ",
- "preferences_related_videos_label": "Прикажи повезане видео снимке: ",
+ "preferences_related_videos_label": "Прикажи сродне видео снимке: ",
"preferences_annotations_label": "Подразумевано прикажи напомене: ",
"preferences_category_visual": "Визуелна подешавања",
"preferences_player_style_label": "Стил плејера: ",
@@ -246,7 +246,7 @@
"preferences_locale_label": "Језик: ",
"Persian": "Персијски",
"View `x` comments": {
- "": "Погледај `x` коментаре",
+ "": "Погледај `x` коментара",
"([^.,0-9]|^)1([^.,0-9]|$)": "Погледај `x` коментар"
},
"search_filters_type_option_channel": "Канал",
@@ -404,7 +404,7 @@
"generic_count_months_0": "{{count}} месец",
"generic_count_months_1": "{{count}} месеца",
"generic_count_months_2": "{{count}} месеци",
- "search_message_use_another_instance": " Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
+ "search_message_use_another_instance": "Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
"generic_subscribers_count_0": "{{count}} пратилац",
"generic_subscribers_count_1": "{{count}} пратиоца",
"generic_subscribers_count_2": "{{count}} пратилаца",
diff --git a/locales/sv-SE.json b/locales/sv-SE.json
index 76edc341..f1313a4d 100644
--- a/locales/sv-SE.json
+++ b/locales/sv-SE.json
@@ -21,7 +21,7 @@
"Import and Export Data": "Importera och exportera data",
"Import": "Importera",
"Import Invidious data": "Importera Invidious JSON data",
- "Import YouTube subscriptions": "Importera YouTube/OPML prenumerationer",
+ "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)",
@@ -320,13 +320,13 @@
"channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "Relevans",
"search_filters_sort_option_rating": "Rankning",
- "search_filters_sort_option_date": "Uppladdnings 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": "Senaste Timmen",
+ "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",
@@ -393,7 +393,7 @@
"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>.",
+ "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)",
diff --git a/locales/tr.json b/locales/tr.json
index 3b7bf3a4..282cbf88 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -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ı.",
diff --git a/locales/uk.json b/locales/uk.json
index 223772d9..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)",
@@ -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": "Придбано",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 756645f4..776c5ddb 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -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": "应用所选过滤器",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 2584db9c..1e17deb6 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -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": "任何類型",
diff --git a/mocks b/mocks
-Subproject 11ec372f72747c09d48ffef04843f72be67d5b5
+Subproject b55d58dea94f7144ff0205857dfa70ec14eaa87
diff --git a/shard.lock b/shard.lock
index 397bd8bc..a097b081 100644
--- a/shard.lock
+++ b/shard.lock
@@ -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 367f7c73..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:
@@ -37,6 +39,10 @@ development_dependencies:
github: crystal-ameba/ameba
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/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/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/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..566d4dc9 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
@@ -180,11 +192,14 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end
-CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
-Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
+NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32)
+CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
+Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
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 b5a27667..13909527 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -15,7 +15,8 @@ record AboutChannel,
allowed_regions : Array(String),
tabs : Array(String),
tags : Array(String),
- verified : Bool
+ verified : Bool,
+ is_age_gated : Bool
def get_about_info(ucid, locale) : AboutChannel
begin
@@ -45,45 +46,102 @@ def get_about_info(ucid, locale) : AboutChannel
end
tags = [] of String
+ tab_names = [] of String
+ total_views = 0_i64
+ joined = Time.unix(0)
- 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?
+ 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
+ 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"))
- 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
+ ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
- 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"))
+ # 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?
- ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
+ # if banner.includes? "channels/c4/default_banner"
+ # banner = nil
+ # end
- # Raises a KeyError on failure.
- banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
- banner = banners.try &.[-1]?.try &.["url"].as_s?
+ description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
+ tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
+ end
- # if banner.includes? "channels/c4/default_banner"
- # banner = nil
- # end
+ 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"
+ )
- description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
- tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
+ 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
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
-
allowed_regions = initdata
.dig?("microformat", "microformatDataRenderer", "availableCountries")
.try &.as_a.map(&.as_s) || [] of String
@@ -101,56 +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" ||
- channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "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,
@@ -168,6 +188,7 @@ def get_about_info(ucid, locale) : AboutChannel
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 be739673..65982325 100644
--- a/src/invidious/channels/channels.cr
+++ b/src/invidious/channels/channels.cr
@@ -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,
@@ -249,11 +249,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end
@@ -275,7 +271,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,
})
@@ -285,11 +281,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if Time.utc - video.published > 1.minute
was_insert = Invidious::Database::ChannelVideos.insert(video)
if was_insert
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
end
end
end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index 6cc30142..96400f47 100644
--- a/src/invidious/channels/videos.cr
+++ b/src/invidious/channels/videos.cr
@@ -1,78 +1,3 @@
-def produce_channel_content_continuation(ucid, content_type, 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) }
-
- content_type_numerical =
- case content_type
- when "videos" then 15
- when "livestreams" then 14
- else 15 # Fallback to "videos"
- end
-
- 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" => {
- "#{content_type_numerical}: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
-
-def make_initial_content_ctoken(ucid, content_type, sort_by) : String
- return produce_channel_content_continuation(ucid, content_type, sort_by: sort_by)
-end
-
module Invidious::Channel::Tabs
extend self
@@ -101,7 +26,7 @@ module Invidious::Channel::Tabs
end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
- continuation ||= make_initial_content_ctoken(ucid, "videos", sort_by)
+ continuation ||= make_initial_videos_ctoken(ucid, sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid)
@@ -130,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
@@ -145,9 +66,8 @@ module Invidious::Channel::Tabs
# Livestreams
# -------------------
- def get_livestreams(channel : AboutChannel, continuation : String? = nil, sort_by = "newest")
- continuation ||= make_initial_content_ctoken(channel.ucid, "livestreams", sort_by)
-
+ 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)
@@ -171,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 beefd9ad..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"]?
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index 09c2168b..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", ""]
@@ -54,6 +55,15 @@ struct ConfigPreferences
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,16 +130,27 @@ 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
@@ -163,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}" %}
@@ -199,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
@@ -206,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/users.cr b/src/invidious/database/users.cr
index d54e6a76..4a3056ea 100644
--- a/src/invidious/database/users.cr
+++ b/src/invidious/database/users.cr
@@ -119,15 +119,15 @@ module Invidious::Database::Users
# Update (notifs)
# -------------------
- def add_notification(video : ChannelVideo)
+ def add_multiple_notifications(channel_id : String, video_ids : Array(String))
request = <<-SQL
UPDATE users
- SET notifications = array_append(notifications, $1),
+ SET notifications = array_cat(notifications, $1),
feed_needs_update = true
WHERE $2 = ANY(subscriptions)
SQL
- PG_DB.exec(request, video.id, video.ucid)
+ PG_DB.exec(request, video_ids, channel_id)
end
def remove_notification(user : User, vid : String)
@@ -154,14 +154,14 @@ module Invidious::Database::Users
# Update (misc)
# -------------------
- def feed_needs_update(video : ChannelVideo)
+ def feed_needs_update(channel_id : String)
request = <<-SQL
UPDATE users
SET feed_needs_update = true
WHERE $1 = ANY(subscriptions)
SQL
- PG_DB.exec(request, video.ucid)
+ PG_DB.exec(request, channel_id)
end
def update_preferences(user : User)
diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr
index aecac87f..a0e1d783 100644
--- a/src/invidious/frontend/comments_youtube.cr
+++ b/src/invidious/frontend/comments_youtube.cr
@@ -149,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/pagination.cr b/src/invidious/frontend/pagination.cr
index 3f931f4e..a29f5936 100644
--- a/src/invidious/frontend/pagination.cr
+++ b/src/invidious/frontend/pagination.cr
@@ -3,6 +3,24 @@ require "uri"
module Invidious::Frontend::Pagination
extend self
+ private def first_page(str : String::Builder, locale : String?, url : String)
+ str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("first" points to the right)
+ str << translate(locale, "First page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ else
+ # Regular arrow ("first" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "First page")
+ end
+
+ str << "</a>"
+ end
+
private def previous_page(str : String::Builder, locale : String?, url : String)
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
@@ -72,18 +90,24 @@ module Invidious::Frontend::Pagination
end
end
- def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
+ def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params)
return String.build do |str|
str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n)
- str << %(<div class="page-prev-container flex-left"></div>\n)
+ str << %(<div class="page-prev-container flex-left">)
+
+ if !first_page
+ self.first_page(str, locale, base_url.to_s)
+ end
+
+ str << %(</div>\n)
str << %(<div class="page-next-container flex-right">)
if !ctoken.nil?
- params_next = URI::Params{"continuation" => ctoken}
- url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+ params["continuation"] = ctoken
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params)
self.next_page(str, locale, url_next.to_s)
end
diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr
index c8cb7110..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
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 21b789bc..900cb0c6 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -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
@@ -128,7 +130,7 @@ 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 174f620d..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)
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index 23a1aafc..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
diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr
index 9f4077e1..684e6d14 100644
--- a/src/invidious/helpers/i18next.cr
+++ b/src/invidious/helpers/i18next.cr
@@ -95,7 +95,6 @@ module I18next::Plurals
"hr" => PluralForms::Special_Hungarian_Serbian,
"it" => PluralForms::Special_Spanish_Italian,
"pt" => PluralForms::Special_French_Portuguese,
- "pt" => PluralForms::Special_French_Portuguese,
"sr" => PluralForms::Special_Hungarian_Serbian,
}
@@ -189,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
@@ -211,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
@@ -262,9 +261,9 @@ module I18next::Plurals
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)
+ 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
@@ -535,7 +534,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
- def self.special_cldr_Spanish_Italian(count : Int) : UInt8
+ 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
@@ -545,7 +544,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
- def self.special_cldr_French_Portuguese(count : Int) : UInt8
+ 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
@@ -555,7 +554,7 @@ module I18next::Plurals
#
# This rule is mostly compliant to CLDR v42
#
- def self.special_cldr_Hungarian_Serbian(count : Int) : UInt8
+ def self.special_cldr_hungarian_serbian(count : Int) : UInt8
n_mod_10 = count % 10
n_mod_100 = count % 100
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 31a3cf44..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
@@ -204,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
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 e438e3b9..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
@@ -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/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/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/notification_job.cr b/src/invidious/jobs/notification_job.cr
index b445107b..f2c9d4be 100644
--- a/src/invidious/jobs/notification_job.cr
+++ b/src/invidious/jobs/notification_job.cr
@@ -1,8 +1,32 @@
+struct VideoNotification
+ getter video_id : String
+ getter channel_id : String
+ getter published : Time
+
+ def_hash @channel_id, @video_id
+
+ def ==(other)
+ video_id == other.video_id
+ end
+
+ def self.from_video(video : ChannelVideo) : self
+ VideoNotification.new(video.id, video.ucid, video.published)
+ end
+
+ def initialize(@video_id, @channel_id, @published)
+ end
+
+ def clone : VideoNotification
+ VideoNotification.new(video_id.clone, channel_id.clone, published.clone)
+ end
+end
+
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
+ private getter notification_channel : ::Channel(VideoNotification)
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
private getter pg_url : URI
- def initialize(@connection_channel, @pg_url)
+ def initialize(@notification_channel, @connection_channel, @pg_url)
end
def begin
@@ -10,6 +34,70 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
+ # hash of channels to their videos (id+published) that need notifying
+ to_notify = Hash(String, Set(VideoNotification)).new(
+ ->(hash : Hash(String, Set(VideoNotification)), key : String) {
+ hash[key] = Set(VideoNotification).new
+ }
+ )
+ notify_mutex = Mutex.new()
+
+ # fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job)
+ spawn do
+ begin
+ loop do
+ notification = notification_channel.receive
+ notify_mutex.synchronize do
+ to_notify[notification.channel_id] << notification
+ end
+ end
+ end
+ end
+ # fiber to regularly persist all cached notifications
+ spawn do
+ loop do
+ begin
+ LOGGER.debug("NotificationJob: waking up")
+ cloned = {} of String => Set(VideoNotification)
+ notify_mutex.synchronize do
+ cloned = to_notify.clone
+ to_notify.clear
+ end
+
+ cloned.each do |channel_id, notifications|
+ if notifications.empty?
+ next
+ end
+
+ LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications")
+ if CONFIG.enable_user_notifications
+ video_ids = notifications.map { |n| n.video_id }
+ Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids)
+ PG_DB.using_connection do |conn|
+ notifications.each do |n|
+ # Deliver notifications to `/api/v1/auth/notifications`
+ payload = {
+ "topic" => n.channel_id,
+ "videoId" => n.video_id,
+ "published" => n.published.to_unix,
+ }.to_json
+ conn.exec("NOTIFY notifications, E'#{payload}'")
+ end
+ end
+ else
+ Invidious::Database::Users.feed_needs_update(channel_id)
+ end
+ end
+
+ LOGGER.trace("NotificationJob: Done, sleeping")
+ rescue ex
+ LOGGER.error("NotificationJob: #{ex.message}")
+ end
+ sleep 1.minute
+ Fiber.yield
+ end
+ end
+
loop do
action, connection = connection_channel.receive
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 0dced80b..3439ae60 100644
--- a/src/invidious/jsonify/api_v1/video_json.cr
+++ b/src/invidious/jsonify/api_v1/video_json.cr
@@ -63,7 +63,7 @@ module Invidious::JSONify::APIv1
json.field "isListed", video.is_listed
json.field "liveNow", video.live_now
json.field "isPostLiveDvr", video.post_live_dvr
- json.field "isUpcoming", video.is_upcoming
+ json.field "isUpcoming", video.upcoming?
if video.premiere_timestamp
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
@@ -109,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
@@ -156,33 +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"]
json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
- 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
+ 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
@@ -250,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
@@ -260,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 d89e752c..6c4225e5 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -27,28 +27,21 @@ module Invidious::Routes::API::Manifest
haltf env, status_code: response.status_code
end
- manifest = response.body
-
- 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
-
+ # 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
@@ -70,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",
@@ -177,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("/")
@@ -207,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 43a5c35b..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,6 +95,7 @@ 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
@@ -142,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|
@@ -174,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|
@@ -211,12 +246,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_livestreams(
- 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", "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|
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index 12942906..4f5b58da 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -42,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
@@ -74,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)
@@ -83,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 = {
@@ -109,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)
@@ -139,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
@@ -155,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 = {
@@ -177,8 +183,8 @@ 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 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
@@ -194,7 +200,7 @@ module Invidious::Routes::API::V1::Misc
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", pageType
+ 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 2922b060..59a30745 100644
--- a/src/invidious/routes/api/v1/search.cr
+++ b/src/invidious/routes/api/v1/search.cr
@@ -31,9 +31,7 @@ module Invidious::Routes::API::V1::Search
query = env.params.query["q"]? || ""
begin
- client = HTTP::Client.new("suggestqueries-clients6.youtube.com")
- client.before_request { |r| add_yt_headers(r) }
-
+ 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
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index 85a208c7..6a3eb8ae 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
@@ -116,7 +118,7 @@ module Invidious::Routes::API::V1::Videos
else
caption_xml = XML.parse(caption_xml)
- webvtt = WebVTT.build(settings_field) do |webvtt|
+ 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
@@ -136,12 +138,16 @@ module Invidious::Routes::API::V1::Videos
text = "<v #{md["name"]}>#{md["text"]}</v>"
end
- webvtt.cue(start_time, end_time, text)
+ builder.cue(start_time, end_time, text)
end
end
end
else
- webvtt = YT_POOL.client &.get("#{url}&fmt=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)
@@ -183,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
@@ -201,35 +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]
- WebVTT.build do |vtt|
- start_time = 0.milliseconds
- end_time = storyboard[:interval].milliseconds
+ # 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
- 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}"
+ # 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_height].times do |j|
- storyboard[:storyboard_width].times do |k|
- current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}"
- vtt.cue(start_time, end_time, current_cue_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)
- start_time += storyboard[:interval].milliseconds
- end_time += storyboard[:interval].milliseconds
+ 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)
+
+ 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)
@@ -250,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
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 360af2cd..7d634cbb 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -20,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|
@@ -36,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
@@ -58,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"
@@ -81,13 +108,26 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
- sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
- sort_options = {"newest", "oldest", "popular"}
+ 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, sort_by: sort_by
- )
+ # 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"
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 e20a7139..7f9a0edb 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -143,32 +143,25 @@ module Invidious::Routes::Feeds
# RSS feeds
def self.rss_channel(env)
- locale = env.get("preferences").as(Preferences).locale
-
env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml"
- ucid = env.params.url["ucid"]
+ if env.params.url["ucid"].matches?(/^[\w-]+$/)
+ ucid = env.params.url["ucid"]
+ else
+ return error_atom(400, InfoException.new("Invalid channel ucid provided."))
+ end
params = HTTP::Params.parse(env.params.query["params"]? || "")
- begin
- channel = get_about_info(ucid, locale)
- rescue ex : ChannelRedirect
- return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
- rescue ex : NotFoundException
- return error_atom(404, ex)
- rescue ex
- return error_atom(500, ex)
- end
-
namespaces = {
"yt" => "http://www.youtube.com/xml/schemas/2015",
"media" => "http://search.yahoo.com/mrss/",
"default" => "http://www.w3.org/2005/Atom",
}
- response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
+ response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
+ return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@@ -179,7 +172,7 @@ module Invidious::Routes::Feeds
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
- ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
+ video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s
views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64
@@ -187,43 +180,44 @@ module Invidious::Routes::Feeds
title: title,
id: video_id,
author: author,
- ucid: ucid,
+ ucid: video_ucid,
published: published,
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
+ author = ""
+ author = videos[0].author if videos.size > 0
+
XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
"xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
"xml:lang": "en-US") do
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
- xml.element("id") { xml.text "yt:channel:#{channel.ucid}" }
- xml.element("yt:channelId") { xml.text channel.ucid }
- xml.element("icon") { xml.text channel.author_thumbnail }
- xml.element("title") { xml.text channel.author }
- xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
+ xml.element("id") { xml.text "yt:channel:#{ucid}" }
+ xml.element("yt:channelId") { xml.text ucid }
+ xml.element("title") { author }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}")
xml.element("author") do
- xml.element("name") { xml.text channel.author }
- xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
+ xml.element("name") { xml.text author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
end
xml.element("image") do
- xml.element("url") { xml.text channel.author_thumbnail }
- xml.element("title") { xml.text channel.author }
+ xml.element("url") { xml.text "" }
+ xml.element("title") { xml.text author }
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
end
videos.each do |video|
- video.to_xml(channel.auto_generated, params, xml)
+ video.to_xml(false, params, xml)
end
end
end
@@ -311,8 +305,9 @@ module Invidious::Routes::Feeds
end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
- document = XML.parse(response.body)
+ return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
+ document = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
node.attributes.each do |attribute|
case attribute.name
@@ -425,16 +420,6 @@ module Invidious::Routes::Feeds
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`
- payload = {
- "topic" => video.ucid,
- "videoId" => video.id,
- "published" => published.to_unix,
- }.to_json
- PG_DB.exec("NOTIFY notifications, E'#{payload}'")
- end
-
video = ChannelVideo.new({
id: id,
title: video.title,
@@ -450,11 +435,7 @@ module Invidious::Routes::Feeds
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
if was_insert
- if CONFIG.enable_user_notifications
- Invidious::Database::Users.add_notification(video)
- else
- Invidious::Database::Users.feed_needs_update(video)
- end
+ NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
end
end
end
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 112535bd..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
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 ec18f3b8..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, force_resolve = true)
+ 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, force_resolve = true)
+ 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, force_resolve = true)
+ client = make_client(URI.parse(host), region, force_resolve: true)
rescue ex
error = ex.message
end
@@ -131,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
@@ -164,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)
- env.redirect location
+ if title = query_params["title"]?
+ url = "#{url}&title=#{URI.encode_www_form(title)}"
+ end
+
+ env.redirect url
break
end
@@ -196,7 +199,7 @@ module Invidious::Routes::VideoPlayback
break
else
client.close
- client = make_client(URI.parse(host), region, force_resolve = true)
+ 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 aabe8dfc..1f384546 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -121,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
@@ -241,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}")
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 125bfefc..902e0a30 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -244,17 +244,16 @@ 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
-
- {% 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 %}
+ get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search
# Posts
get "/api/v1/post/:id", {{namespace}}::Channels, :post
@@ -272,11 +271,6 @@ module Invidious::Routing
# 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
-
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 bf968734..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
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/user/imports.cr b/src/invidious/user/imports.cr
index 108f2ccc..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)
@@ -290,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::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)
- 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)
-
- 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 c218b4ef..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
@@ -98,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
@@ -150,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
@@ -250,10 +166,10 @@ struct Video
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
@@ -334,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
@@ -355,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)
@@ -394,10 +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
-
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 484e61d2..c811cfe1 100644
--- a/src/invidious/videos/caption.cr
+++ b/src/invidious/videos/caption.cr
@@ -123,6 +123,7 @@ module Invidious::Videos
"Esperanto",
"Estonian",
"Filipino",
+ "Filipino (auto-generated)",
"Finnish",
"French",
"French (auto-generated)",
diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr
index c7191dec..1371bebb 100644
--- a/src/invidious/videos/description.cr
+++ b/src/invidious/videos/description.cr
@@ -36,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 0e1a947c..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,6 +54,7 @@ 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
@@ -55,7 +63,7 @@ def extract_video_info(video_id : String)
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
@@ -102,7 +110,9 @@ def extract_video_info(video_id : String)
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:
@@ -111,12 +121,6 @@ def extract_video_info(video_id : String)
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
- new_player_response = try_fetch_streaming_data(video_id, client_config)
- end
-
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
@@ -127,10 +131,21 @@ def extract_video_info(video_id : String)
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)
@@ -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,8 +224,17 @@ 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 || false
+ .try &.as_bool
+ live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
@@ -220,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
@@ -424,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),
@@ -438,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 95965446..ee1272d1 100644
--- a/src/invidious/videos/transcript.cr
+++ b/src/invidious/videos/transcript.cr
@@ -110,13 +110,13 @@ module Invidious::Videos
"Language" => @language_code,
}
- vtt = WebVTT.build(settings_field) do |vtt|
+ 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
- vtt.cue(line.start_ms, line.end_ms, line.line)
+ builder.cue(line.start_ms, line.end_ms, line.line)
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 a84e44bc..1fe8ab7e 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -20,7 +20,9 @@
page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale,
base_url: relative_url,
- ctoken: next_continuation
+ ctoken: next_continuation,
+ first_page: continuation.nil?,
+ params: env.params.query,
)
%>
@@ -40,6 +42,8 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%>
+<script src="/js/pagination.js?v=<%= ASSET_COMMIT %>"></script>
+
<link rel="alternate" href="<%= youtube_url %>">
<title><%= author %> - Invidious</title>
<% end %>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 6d227cfc..c966a926 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -128,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 %>">
@@ -138,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"
diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr
index 4534a0a3..f69df3fe 100644
--- a/src/invidious/views/components/items_paginated.ecr
+++ b/src/invidious/views/components/items_paginated.ecr
@@ -8,4 +8,14 @@
<%= page_nav_html %>
+<script id="pagination-data" type="application/json">
+<%=
+{
+ "next_page" => translate(locale, "Next page"),
+ "prev_page" => translate(locale, "Previous page"),
+ "is_rtl" => locale_is_rtl?(locale)
+}.to_pretty_json
+%>
+</script>
+
<script src="/js/watched_indicator.js"></script>
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/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 9e7467dd..6f9ced6f 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -62,7 +62,7 @@ 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"),
"support_reddit" => true
@@ -123,8 +123,8 @@ we're going to need to do it here in order to allow for translations.
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
end
-%>
- <a id="link-yt-watch" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
- (<a id="link-yt-embed" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
+ <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">
@@ -158,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">
@@ -169,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>
diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr
index d3dbcc0e..c4a73aa7 100644
--- a/src/invidious/yt_backend/connection_pool.cr
+++ b/src/invidious/yt_backend/connection_pool.cr
@@ -1,17 +1,6 @@
-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/114.0.0.0 Safari/537.36"
-
- request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
- request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
- request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
-
- # 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
@@ -24,17 +13,17 @@ struct YoutubeConnectionPool
@pool = build_pool()
end
- def client(&block)
+ 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
rescue ex
conn.close
- conn = HTTP::Client.new(url)
+ conn = make_client(url, force_resolve: true)
- conn.family = CONFIG.force_resolve
- 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)
@@ -44,36 +33,84 @@ struct YoutubeConnectionPool
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 = CONFIG.force_resolve
- 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, force_resolve : Bool = false)
+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"
+
+ 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 == "www.youtube.com"
+ 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, force_resolve : Bool = false, &block)
- client = make_client(url, region, force_resolve)
+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 0e72957e..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
@@ -460,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)
@@ -475,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
@@ -489,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"]?
@@ -564,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
@@ -856,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
@@ -883,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/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 c8b037c8..ec080d8c 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -6,10 +6,10 @@ module YoutubeAPI
extend self
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
- private ANDROID_APP_VERSION = "19.14.42"
- private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
- private ANDROID_SDK_VERSION = 31_i64
+ 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"
@@ -17,9 +17,9 @@ module YoutubeAPI
# 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.16.3"
- private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)"
- private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build
+ 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"
@@ -29,6 +29,7 @@ module YoutubeAPI
WebEmbeddedPlayer
WebMobile
WebScreenEmbed
+ WebCreator
Android
AndroidEmbeddedPlayer
@@ -48,7 +49,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
- version: "2.20240304.00.00",
+ version: "2.20240814.00.00",
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -57,7 +58,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER",
name_proto: "56",
- version: "1.20240303.00.00",
+ version: "1.20240812.01.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@@ -66,7 +67,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
- version: "2.20240304.08.00",
+ version: "2.20240813.02.00",
os_name: "Android",
os_version: ANDROID_VERSION,
platform: "MOBILE",
@@ -74,12 +75,20 @@ module YoutubeAPI
ClientType::WebScreenEmbed => {
name: "WEB",
name_proto: "1",
- version: "2.20240304.00.00",
+ 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
@@ -147,8 +156,8 @@ module YoutubeAPI
ClientType::IOSMusic => {
name: "IOS_MUSIC",
name_proto: "26",
- version: "6.42",
- user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 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",
@@ -161,7 +170,7 @@ module YoutubeAPI
ClientType::TvHtml5 => {
name: "TVHTML5",
name_proto: "7",
- version: "7.20240304.10.00",
+ version: "7.20240813.07.00",
},
ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
@@ -202,7 +211,7 @@ module YoutubeAPI
def initialize(
*,
@client_type = ClientType::Web,
- @region = "US"
+ @region = "US",
)
end
@@ -272,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
@@ -292,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
@@ -320,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
@@ -357,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 = {
@@ -451,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,
},
}
@@ -531,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 = {
@@ -557,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),
@@ -579,7 +605,7 @@ 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
@@ -599,6 +625,10 @@ 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}")
@@ -607,6 +637,11 @@ module YoutubeAPI
# Send the POST request
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