Compare commits

..

1 commit

Author SHA1 Message Date
dfdc9e5189
Merge remote-tracking branch 'upstream/master' into testing3 2024-10-31 20:22:59 -03:00
144 changed files with 1568 additions and 3457 deletions

View file

@ -1,56 +1,50 @@
name: "Invidious CI" name: 'Invidious CI'
on: on:
workflow_dispatch: # workflow_dispatch:
# schedule: # inputs: {}
# - cron: '0 7 * * 0' schedule:
- cron: '0 7 * * 0'
push: push:
branches: branches:
- "master" - "master"
paths-ignore: - "experimental"
- "*.md" - "experimental2"
- LICENCE
- TRANSLATION
- invidious.service
- .git*
- .editorconfig
- screenshots/*
- .github/ISSUE_TEMPLATE/*
- kubernetes/**
jobs: jobs:
build: build:
runs-on: runner runs-on: runner
steps: steps:
- uses: https://code.forgejo.org/actions/checkout@v4 - uses: https://code.forgejo.org/actions/checkout@v2
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3 - uses: https://code.forgejo.org/docker/setup-buildx-action@v3
name: Setup Docker BuildX system name: Setup Docker BuildX system
- name: Login to Docker Container Registry - name: Login to Docker Container Registry
uses: https://code.forgejo.org/docker/login-action@v3.1.0 uses: https://code.forgejo.org/docker/login-action@v3.1.0
with: with:
registry: git.nadeko.net registry: git.nadeko.net
username: ${{ secrets.USERNAME }} username: ${{ secrets.USERNAME }}
password: ${{ secrets.TOKEN }} password: ${{ secrets.TOKEN }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: https://github.com/docker/metadata-action@v5 uses: https://github.com/docker/metadata-action@v5
with: with:
images: git.nadeko.net/fijxu/invidious images: git.nadeko.net/fijxu/invidious
tags: | tags: |
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- uses: https://code.forgejo.org/docker/build-push-action@v5
name: Build images
with:
context: .
file: docker/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64
push: true
build-args: |
"release=1"
- uses: https://code.forgejo.org/docker/build-push-action@v6
name: Build images
with:
context: .
file: docker/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -10,10 +10,8 @@ assignees: ''
<!-- <!--
BEFORE TRYING TO REPORT A BUG: BEFORE TRYING TO REPORT A BUG:
* Read the FAQ: https://docs.invidious.io/faq/! * Read the 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! * Use the search function to check if there is already an issue open for your problem!
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 a new feature please use "Feature request" instead
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead If you want to suggest an enhancement to an existing feature please use "Enhancement" instead

View file

@ -23,6 +23,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
with: with:

View file

@ -14,6 +14,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
with: with:

View file

@ -38,10 +38,11 @@ jobs:
matrix: matrix:
stable: [true] stable: [true]
crystal: crystal:
- 1.10.1
- 1.11.2
- 1.12.1 - 1.12.1
- 1.13.2 - 1.13.2
- 1.14.0 - 1.14.0
- 1.15.0
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false
@ -64,9 +65,7 @@ jobs:
- name: Cache Shards - name: Cache Shards
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: ./lib
./lib
./bin
key: shards-${{ hashFiles('shard.lock') }} key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards - name: Install Shards
@ -78,6 +77,14 @@ jobs:
- name: Run tests - name: Run tests
run: crystal spec run: crystal spec
- name: Run lint
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Build - name: Build
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
@ -123,19 +130,14 @@ jobs:
- name: Test Docker - name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done run: while curl -Isf http://localhost:3000; do sleep 1; done
lint: ameba_lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Install Crystal - name: Install Crystal
id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.8.0 uses: crystal-lang/install-crystal@v1.8.0
with: with:
crystal: latest crystal: latest
@ -146,21 +148,10 @@ jobs:
path: | path: |
./lib ./lib
./bin ./bin
key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }} key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards - name: Install Shards
run: | run: shards install
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 - name: Run Ameba linter
run: bin/ameba run: bin/ameba

View file

@ -13,11 +13,14 @@ jobs:
- uses: actions/stale@v8 - uses: actions/stale@v8
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 730 days-before-stale: 365
days-before-pr-stale: -1 days-before-pr-stale: 90
days-before-close: 60 days-before-close: 30
exempt-pr-labels: blocked,exempt-stale
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-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-issue-label: "stale"
stale-pr-label: "stale"
ascending: true ascending: true
# Exempt the following types of issues from being staled # Never mark feature requests/enhancements as stale
exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale" exempt-issue-labels: "feature-request,enhancement,exempt-stale"

View file

@ -2,188 +2,9 @@
## vX.Y.0 (future) ## vX.Y.0 (future)
## v2.20250314.0
### Wrap-up
This release brings the long awaited feature of supporting multiple audio tracks in a video, some bug fixes and UX improvements, and many other things primarily oriented to self-hosting instances, and developers using the API.
The `Community` channel tab has been replaced by `Posts` in light of YouTube changes, but the URL remains the same.
Tamil is now available as an interface language
Automatic instance redirects will no longer have the chance to annoyingly redirect to the same instance you're on.
Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
Invidious is now able to listen through a UNIX socket
User notifications are now batched for each channel
**The minimum Crystal version supported by Invidious now `1.12.0`**
### New features & important changes
#### For users
* Invidious now supports videos with multiple audio tracks allowing you to select which one you want to hear with!
* Channel pages now have a proper previous page button
* RSS feeds for channels will no longer contain the channel's profile picture
* Support for channel `courses` page has been added
* `Community` tabs has been replaced with `Posts` to comply with YouTube changes
* Tamil is now an available interface language.
#### For instance owners
* Invidious is now able to listen on a UNIX socket
* User notifications are now batched by channels, significantly reducing database load.
* **`1.12.0` is now the oldest Crystal version that Invidious supports**
* The example config will no longer force an http proxy to be configured
* Invidious will now warn when any top-level config option must be set to a custom value, instead of just `HMAC_KEY`
* Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
#### For developers
* Invidious is now compliant to Crystal 1.15 formatting rules, which are incompatible with earlier versions.
* `/api/v1/transcripts/{id}` has been added to the API to allow for fetching the transcripts for a video. The arguments are the same as the captions endpoint.
* `author_thumbnail` field has been added to videos in the various paged api endpoints
* `published` field has been added to the API response for a video's related videos.
* Docker builds now uses the Crystal compiler cache, reducing build times on repeated builds significantly.
* Invidious ajax action handlers has undergone a clean up and may face compatibility issues with code that depends on these endpoints.
* The versions of Crystal that we test in CI/CD are now: `1.12.1`, `1.13.2`, `1.14.0`, `1.15.0`
### Bugs fixed
#### User-side
* Local video listen mode is now preserved when clicking on a video in the sidebar playlist widget
* Automatic instance redirects will no longer redirect to the same instance the user is on
* Fix some thumbnails responses returning 404
* Videos: Fix missing host parameter on playback URLs when `local=true`
* Fix HLS being used for non-livestream videos
* Fix timeupdate event errors when required elements are missing
* User: Ensure IO is properly closed when importing NewPipe subscriptions
#### For instance owners
* Fix http proxy configuration being forced by the standard example config
#### API
* `/api/v1/videos/{id}` will no longer return an occasional empty JSON response
### Full list of pull requests merged since the last release (newest first)
* Make Invidious compliant to Crystal 1.15 formatting rules (https://github.com/iv-org/invidious/pull/5014, by @syeopite)
* Remove formatter check on container workflows (https://github.com/iv-org/invidious/pull/5153, by @syeopite)
* Videos: Fix missing host parameter on playback URLs when `local=true` (https://github.com/iv-org/invidious/pull/4992, by @SamantazFox)
* Remove stdlib override for proxy initialization (https://github.com/iv-org/invidious/pull/5065, by @syeopite)
* Add support for author thumbnails in search api for videos (https://github.com/iv-org/invidious/pull/5072, thanks @ChunkyProgrammer)
* Skip route if resp got closed by before handlers (https://github.com/iv-org/invidious/pull/5073, by @syeopite)
* Fix video thumbnails in mixes (https://github.com/iv-org/invidious/pull/5116, thanks @iBicha)
* CI: Drop support for versions prior to 1.12 and add 1.15.0 (https://github.com/iv-org/invidious/pull/5148, by @syeopite)
* [Continuing #5094] Set language info for dash audio streams and sort (https://github.com/iv-org/invidious/pull/5149, thanks @giuliano-macedo)
* Warn when any top-level config is "CHANGE_ME!!" (https://github.com/iv-org/invidious/pull/5150, by @syeopite)
* Comment out http_proxy in example config (https://github.com/iv-org/invidious/pull/5151, by @syeopite)
* API: Add a 'published' video parameter for related videos (https://github.com/iv-org/invidious/pull/4149, thanks @RadoslavL)
* Ensure IO is properly closed when importing NewPipe subscriptions (https://github.com/iv-org/invidious/pull/4346, thanks @ChunkyProgrammer)
* Carry over audio-only mode in playlist links (https://github.com/iv-org/invidious/pull/4784, thanks @krystof1119)
* Routes: Clean ajax actions handlers (https://github.com/iv-org/invidious/pull/5036, by @SamantazFox)
* Frontend: Add a first page and previous page buttons for channel navigation (https://github.com/iv-org/invidious/pull/4123, thanks @RadoslavL)
* RSS: Channel + Playlist improvements (https://github.com/iv-org/invidious/pull/4298, thanks @ChunkyProgrammer)
* Batch user notifications together (https://github.com/iv-org/invidious/pull/4486, thanks @999eagle)
* JS: Update timeupdate event making it more defensive to prevent errors (https://github.com/iv-org/invidious/pull/4782, thanks @PMK)
* Add API endpoint for fetching transcripts from YouTube by (https://github.com/iv-org/invidious/pull/4788, by @syeopite)
* Translations update from Hosted Weblate by (https://github.com/iv-org/invidious/pull/4989, thanks to our many translators)
* Add the ability to listen on UNIX sockets (https://github.com/iv-org/invidious/pull/5112, thanks @Caian)
* Pick a different instance upon redirect (https://github.com/iv-org/invidious/pull/5154, thanks @epicsam123)
* Add Courses to channel page and channel API (https://github.com/iv-org/invidious/pull/5158, thanks @ChunkyProgrammer)
* fix /api/v1/videos/:id returns 200 with no content (https://github.com/iv-org/invidious/pull/5162, thanks @Drikanis)
* Use Crystal compiler cache in docker builds (https://github.com/iv-org/invidious/pull/5163, by @syeopite)
* Channels: Fix community tab by (https://github.com/iv-org/invidious/pull/5183, thanks @Fijxu)
* Fix typo in `src/invidious/routes/images.cr` (https://github.com/iv-org/invidious/pull/5184, by @syeopite)
* Fix an issue with the HLS manifest check for livestream videos (https://github.com/iv-org/invidious/pull/5189, thanks @alexmaras)
* Warn when `po_token`, `visitor_data` and/or `inv-sig-helper` is not configured (https://github.com/iv-org/invidious/pull/5202, by @syeopite)
## 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) ### 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) * 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) * Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox)
* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu) * SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu)
@ -210,9 +31,7 @@ An HTTP proxy can be configured directly in Invidious, if needed. \
[#4270]: https://github.com/iv-org/invidious/pull/4270 [#4270]: https://github.com/iv-org/invidious/pull/4270
[#4326]: https://github.com/iv-org/invidious/pull/4326 [#4326]: https://github.com/iv-org/invidious/pull/4326
[#4652]: https://github.com/iv-org/invidious/pull/4652 [#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 [#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 [#4850]: https://github.com/iv-org/invidious/pull/4850
[#4862]: https://github.com/iv-org/invidious/pull/4862 [#4862]: https://github.com/iv-org/invidious/pull/4862
[#4863]: https://github.com/iv-org/invidious/pull/4863 [#4863]: https://github.com/iv-org/invidious/pull/4863
@ -222,22 +41,10 @@ An HTTP proxy can be configured directly in Invidious, if needed. \
[#4923]: https://github.com/iv-org/invidious/pull/4923 [#4923]: https://github.com/iv-org/invidious/pull/4923
[#4928]: https://github.com/iv-org/invidious/pull/4928 [#4928]: https://github.com/iv-org/invidious/pull/4928
[#4930]: https://github.com/iv-org/invidious/pull/4930 [#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 [#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 [#4991]: https://github.com/iv-org/invidious/pull/4991
[#4993]: https://github.com/iv-org/invidious/pull/4993 [#4993]: https://github.com/iv-org/invidious/pull/4993
[#4995]: https://github.com/iv-org/invidious/pull/4995 [#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) ## v2.20240825.2 (2024-08-26)

224
README.md
View file

@ -1,62 +1,170 @@
# nadeko.net Invidious fork <div align="center">
<img src="assets/invidious-colored-vector.svg" width="192" height="192" alt="Invidious logo">
<h1>Invidious</h1>
This is a fork of Invidious with features that I have done for my own instance. If you want to maintain an instance, feel free to use this fork and it's container images (they are also compatible with Podman, not just docker!) <a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
<img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
</a>
<a href="https://github.com/iv-org/invidious/actions">
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
</a>
<a href="https://github.com/iv-org/invidious/commits/master">
<img alt="GitHub commits" src="https://img.shields.io/github/commit-activity/y/iv-org/invidious?color=red&label=commits">
</a>
<a href="https://github.com/iv-org/invidious/issues">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/iv-org/invidious?color=important">
</a>
<a href="https://github.com/iv-org/invidious/pulls">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/iv-org/invidious?color=blueviolet">
</a>
<a href="https://hosted.weblate.org/engage/invidious/">
<img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg">
</a>
https://git.nadeko.net/Fijxu/-/packages/container/invidious/latest <a href="https://github.com/humanetech-community/awesome-humane-tech">
<img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true">
</a>
> [!CAUTION] <h3>An open source alternative front-end to YouTube</h3>
> If you already have an Invidious instance running the upstream code, moving it to this fork will not work for you!
> This is due to the "Removal of materialized views on PostgreSQL" pull request that requires a migration of the database <a href="https://invidious.io/">Website</a>
> using it. &nbsp;&nbsp;
<a href="https://instances.invidious.io/">Instances list</a>
&nbsp;&nbsp;
<a href="https://docs.invidious.io/faq/">FAQ</a>
&nbsp;&nbsp;
<a href="https://docs.invidious.io/">Documentation</a>
&nbsp;&nbsp;
<a href="#contribute">Contribute</a>
&nbsp;&nbsp;
<a href="https://invidious.io/donate/">Donate</a>
<h5>Chat with us:</h5>
<a href="https://matrix.to/#/#invidious:matrix.org">
<img alt="Matrix" src="https://img.shields.io/matrix/invidious:matrix.org?label=Matrix&color=darkgreen">
</a>
<a href="https://web.libera.chat/?channel=#invidious">
<img alt="Libera.chat (IRC)" src="https://img.shields.io/badge/IRC%20%28Libera.chat%29-%23invidious-darkgreen">
</a>
<br>
<a rel="me" href="https://social.tchncs.de/@invidious">
<img alt="Fediverse: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Fediverse-%40invidious%40social.tchncs.de-darkgreen">
</a>
<br>
<a href="https://invidious.io/contact/">
<img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
</a>
</div>
## Screenshots
| Player | Preferences | Subscriptions |
|-------------------------------------|-------------------------------------|---------------------------------------|
| ![](screenshots/01_player.png) | ![](screenshots/02_preferences.png) | ![](screenshots/03_subscriptions.png) |
| ![](screenshots/04_description.png) | ![](screenshots/05_preferences.png) | ![](screenshots/06_subscriptions.png) |
## Features
**User features**
- Lightweight
- No ads
- No tracking
- No JavaScript required
- Light/Dark themes
- Customizable homepage
- Subscriptions independent from Google
- Notifications for all subscribed channels
- Audio-only mode (with background play on mobile)
- Support for Reddit comments
- [Available in many languages](locales/), thanks to [our translators](#contribute)
**Data import/export**
- Import subscriptions from YouTube, NewPipe and Freetube
- Import watch history from YouTube and NewPipe
- Export subscriptions to NewPipe and Freetube
- Import/Export Invidious user data
**Technical features**
- Embedded video support
- [Developer API](https://docs.invidious.io/api/)
- Does not use official YouTube APIs
- No Contributor License Agreement (CLA)
## Quick start
**Using invidious:**
- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now!
**Hosting invidious:**
- [Follow the installation instructions](https://docs.invidious.io/installation/)
## Documentation
The full documentation can be accessed online at https://docs.invidious.io/
The documentation's source code is available in this repository:
https://github.com/iv-org/documentation
### Extensions
We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get),
a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
embedded youtube videos on other websites with invidious.
The documentation contains a list of browser extensions that we recommended to use along with Invidious.
You can read more here: https://docs.invidious.io/applications/
## Contribute
### Code
1. Fork it ( https://github.com/iv-org/invidious/fork ).
1. Create your feature branch (`git checkout -b my-new-feature`).
1. Stage your files (`git add .`).
1. Commit your changes (`git commit -am 'Add some feature'`).
1. Push to the branch (`git push origin my-new-feature`).
1. Create a new pull request ( https://github.com/iv-org/invidious/compare ).
### Translations
We use [Weblate](https://weblate.org) to manage Invidious translations.
You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/.
Creating an account is not required, but recommended, especially if you want to contribute regularly.
Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ...
## Projects using Invidious
A list of projects and extensions for or utilizing Invidious can be found in the documentation: https://docs.invidious.io/applications/
## Liability
We take no responsibility for the use of our tool, or external instances
provided by third parties. We strongly recommend you abide by the valid
official regulations in your country. Furthermore, we refuse liability
for any inappropriate use of Invidious, such as illegal downloading.
This tool is provided to you in the spirit of free, open software.
You may view the LICENSE in which this software is provided to you [here](./LICENSE).
> 16. Limitation of Liability.
> >
> If you don't have an instance already, you can use this fork safely, but you will not be able to switch to upstream Invidious. > IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
## Features and changes of this fork: THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
- ~~[Use a Redis compatible DB for video cache instead of just PostgreSQL](https://git.nadeko.net/Fijxu/invidious/commit/bbc5913b8dacaed4d466bcc466a0782d5e3f5edc): Invidious by default caches the video information for some hours in PostgreSQL. Since the data is accessed a lot, it is better off using an in memory database instead, it's faster and it will not wear out your SSD (due to constant writes to the database).~~ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
~~It can be set using this on `config.yml`:~~ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
```yaml EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
redis_url: tcp://127.0.0.1:6379 SUCH DAMAGES.
```
- [Ability to use different video caching backends](https://git.nadeko.net/Fijxu/invidious/commit/e76867aaba022d64ebab73648a37a0c63b788e0f): If you want, you can the PostgreSQL video cache the Redis one or the built-in in memory one that uses the LRU algorithm. Redis and LRU are recommended for public instances, but since Invidious has memory leaks, the LRU cache is lost if Invidious crashes or it's restarted, so because of this, redis is the default option.
```yaml
video_cache:
enabled: true
backend: 1 # 0 is PSQL, 1 Redis, 2 Built-in LRU
lru_max_size: 18000 # ~500MB (ignored if backend is 0 or 1)
```
If you choose to use Redis, make sure to set the `redis_url` config property:
```yaml
redis_url: tcp://127.0.0.1:6379
```
- [Removal of materialized views on PostgreSQL](github.com/iv-org/invidious/pull/2469): If you don't have this on your Invidious public instance, your SSD will suffer and it will catch on fire https://github.com/iv-org/invidious/pull/2469#issuecomment-2012623454
- Limit the DASH resolution sent to the clients: It can be set using `max_dash_resolution` on the config. Example: `max_dash_resolution: 1080`
- [Limit requests made to Youtube API when pulling subscriptions (feeds)](https://git.nadeko.net/Fijxu/invidious/commit/df94f1c0b82d95846574487231ea251530838ef0): Due to the recent changes of Youtube ("This helps protect out community", "Sign in to confirm you are not a bot"), subscriptions now have limited information, this is because Invidious by default, makes a video request to youtube to be able to get more information about the video, like `length_seconds`, `live_now`, `premiere_timestamp`, and `views`. If you have a lot of users with a ton of subscriptions, Invidious will basically spam youtube API all the time, resulting in a block from youtube.
It can be set using this on `config.yml`:
```yaml
use_innertube_for_feeds: false
```
- Autoreload configuration: If you are hosting Invidious on Linux without docker, this may be useful for you if you want to change the banner without restarting Invidious.
```yaml
reload_config_automatically: true
```
## Development features
- Option to disable CSP: Useful for local development, set `csp: false` on the config and done
---
There is more things that I added to this fork, but those are the most important ones. I also regularly merge unmerged pull requests from https://github.com/iv-org/invidious and random fixes as well. Is not the most stable codebase, but you can't really make something stable when youtube is trying to destroy every third party client out there.

View file

@ -1 +0,0 @@
minified

View file

@ -91,7 +91,7 @@
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/token_ajax?action=revoke_token&redirect=false' + var url = '/token_ajax?action_revoke_token=1&redirect=false' +
'&referer=' + encodeURIComponent(location.href) + '&referer=' + encodeURIComponent(location.href) +
'&session=' + target.getAttribute('data-session'); '&session=' + target.getAttribute('data-session');
@ -111,7 +111,7 @@
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&referer=' + encodeURIComponent(location.href) + '&referer=' + encodeURIComponent(location.href) +
'&c=' + target.getAttribute('data-ucid'); '&c=' + target.getAttribute('data-ucid');

View file

@ -1,93 +0,0 @@
'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);
}
}
});

View file

@ -56,7 +56,6 @@ videojs.Vhs.MAX_GOAL_BUFFER_LENGTH = 80;
var player = videojs('player', options); var player = videojs('player', options);
player.on('error', function () { player.on('error', function () {
console.debug(`[VideoJS Debug] Playback cannot continue, error: ${player.error().code}`)
if (video_data.params.quality === 'dash') return; if (video_data.params.quality === 'dash') return;
var localNotDisabled = ( var localNotDisabled = (
@ -138,32 +137,26 @@ player.on('timeupdate', function () {
// YouTube links // YouTube links
let elem_yt_watch = document.getElementById('link-yt-watch'); 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 elem_yt_embed = document.getElementById('link-yt-embed');
if (elem_yt_embed) {
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed); let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
}
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
// Invidious links // Invidious links
let domain = window.location.origin; let domain = window.location.origin;
let elem_iv_embed = document.getElementById('link-iv-embed'); 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 elem_iv_other = document.getElementById('link-iv-other');
if (elem_iv_other) {
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url'); let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain); 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);
}); });

View file

@ -6,7 +6,7 @@ function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1]; var select = target.parentNode.children[0].children[1];
var option = select.children[select.selectedIndex]; var option = select.children[select.selectedIndex];
var url = '/playlist_ajax?action=add_video&redirect=false' + var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') + '&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid'); '&playlist_id=' + option.getAttribute('data-plid');
@ -21,7 +21,7 @@ function add_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/playlist_ajax?action=add_video&redirect=false' + var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') + '&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + target.getAttribute('data-plid'); '&playlist_id=' + target.getAttribute('data-plid');
@ -36,7 +36,7 @@ function remove_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/playlist_ajax?action=remove_video&redirect=false' + var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') + '&set_video_id=' + target.getAttribute('data-index') +
'&playlist_id=' + target.getAttribute('data-plid'); '&playlist_id=' + target.getAttribute('data-plid');

View file

@ -16,7 +16,7 @@ function subscribe() {
subscribe_button.onclick = unsubscribe; subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' + var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
'&c=' + subscribe_data.ucid; '&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, { helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
@ -32,7 +32,7 @@ function unsubscribe() {
subscribe_button.onclick = subscribe; subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&c=' + subscribe_data.ucid; '&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {

View file

@ -67,10 +67,6 @@ function get_playlist(plid) {
'&format=html&hl=' + video_data.preferences.locale; '&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'}, { helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) { on200: function (response) {
playlist.innerHTML = response.playlistHtml; playlist.innerHTML = response.playlistHtml;

View file

@ -6,7 +6,7 @@ function mark_watched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/watch_ajax?action=mark_watched&redirect=false' + var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
'&id=' + target.getAttribute('data-id'); '&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, { helpers.xhr('POST', url, {payload: payload}, {
@ -22,7 +22,7 @@ function mark_unwatched(target) {
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/watch_ajax?action=mark_unwatched&redirect=false' + var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
'&id=' + target.getAttribute('data-id'); '&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, { helpers.xhr('POST', url, {payload: payload}, {

View file

@ -54,53 +54,6 @@ db:
## ##
#signature_server: #signature_server:
##
## Invidious companion is an external program
## for loading the video streams from YouTube servers.
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
## Note: multiple URL can be configured. In this case, invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
## to work. Once said cache has expired, requesting that video's data
## again will cause a new companion URL to be picked.
##
## The parameter private_url needs to be configured for the internal
## communication between the companion and Invidious.
## And public_url is the public URL from which companion is listening
## to the requests from the user(s).
##
## If you are using a reverse proxy then you will probably need to
## configure the public_url to be the same as the domain used for Invidious.
## Also apply when used from an external IP address (without a domain).
## Examples: https://MYINVIDIOUSDOMAIN or http://192.168.1.100:8282
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"
##
## API key for Invidious companion, used for securing the communication
## between Invidious and Invidious companion.
## The size of the key needs to be more or equal to 16.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"
######################################### #########################################
# #
@ -177,20 +130,6 @@ https_only: false
## ##
#hsts: true #hsts: true
##
## Path and permissions of a UNIX socket to listen on for incoming connections.
##
## Note: Enabling socket will make invidious stop listening on the address
## specified by 'host_binding' and 'port'.
##
## Accepted values: Any path to a new file (that doesn't exist yet) and its
## permissions following the UNIX octal convention.
## Default: <none>
##
#socket_binding:
# path: /tmp/invidious.sock
# permissions: 777
# ----------------------------- # -----------------------------
# Network (outbound) # Network (outbound)
@ -239,11 +178,11 @@ https_only: false
## ##
## If unset, then no HTTP proxy will be used. ## If unset, then no HTTP proxy will be used.
## ##
#http_proxy: http_proxy:
# user: user:
# password: password:
# host: host:
# port: port:
## ##
@ -298,11 +237,9 @@ https_only: false
## Enables colors in logs. Useful for debugging purposes ## Enables colors in logs. Useful for debugging purposes
## This is overridden if "-k" or "--colorize" ## This is overridden if "-k" or "--colorize"
## are passed on the command line. ## 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 ## Accepted values: true, false
## Default: true ## Default: false
## ##
#colorize_logs: false #colorize_logs: false
@ -1072,7 +1009,8 @@ default_user_preferences:
## ##
#extend_desc: false #extend_desc: false
# redis_url: redis://127.0.0.1:6379/0?initial_pool_size=1&max_pool_size=10&checkout_timeout=10&retry_attempts=2&retry_delay=0.5&max_idle_pool_size=50 # redis_url: 127.0.0.1:6379
# redis_socket: /var/run/valkey/valkey.sock
# donation_url: "https://example.com/donate" # donation_url: "https://example.com/donate"
# contact_url: "https://example.com/contact" # contact_url: "https://example.com/contact"
# home_domain: "https://example.com/ # home_domain: "https://example.com/

View file

@ -1,4 +1,4 @@
FROM mirror.gcr.io/crystallang/crystal:1.16.0-alpine AS builder FROM crystallang/crystal:1.12.1-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static RUN apk add --no-cache sqlite-static yaml-static
@ -7,7 +7,6 @@ ARG release
WORKDIR /invidious WORKDIR /invidious
COPY ./shard.yml ./shard.yml COPY ./shard.yml ./shard.yml
COPY ./shard.lock ./shard.lock COPY ./shard.lock ./shard.lock
RUN shards install --production RUN shards install --production
COPY ./src/ ./src/ COPY ./src/ ./src/
@ -20,14 +19,21 @@ COPY ./scripts/ ./scripts/
COPY ./assets/ ./assets/ COPY ./assets/ ./assets/
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN --mount=type=cache,target=/root/.cache/crystal \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma"
RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release --mcpu=x86-64-v2 \ --release --mcpu=x86-64-v3 \
--static --warnings all \ --static --warnings all \
--link-flags "-lxml2 -llzma"; --link-flags "-lxml2 -llzma"; \
else \
crystal build ./src/invidious.cr \
--static --warnings all \
--link-flags "-lxml2 -llzma"; \
fi
FROM mirror.gcr.io/alpine:3.20 FROM alpine:3.18
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious

View file

@ -1,6 +1,5 @@
FROM alpine:3.20 AS builder FROM alpine:3.19 AS builder
RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \ 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
zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release ARG release
@ -22,7 +21,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release \ --release \
--static --warnings all \ --static --warnings all \
@ -33,8 +32,8 @@ RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ;
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM alpine:3.20 FROM alpine:3.18
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious

View file

@ -559,12 +559,10 @@
"toggle_theme": "تبديل الموضوع", "toggle_theme": "تبديل الموضوع",
"Add to playlist": "أضف إلى قائمة التشغيل", "Add to playlist": "أضف إلى قائمة التشغيل",
"Add to playlist: ": "أضف إلى قائمة التشغيل: ", "Add to playlist: ": "أضف إلى قائمة التشغيل: ",
"Answer": "اجابة", "Answer": "الرد",
"Search for videos": "ابحث عن مقاطع الفيديو", "Search for videos": "ابحث عن مقاطع الفيديو",
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.", "The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
"carousel_slide": "الشريحة {{current}} من {{total}}", "carousel_slide": "الشريحة {{current}} من {{total}}",
"carousel_skip": "تخطي الكاروسيل", "carousel_skip": "تخطي الكاروسيل",
"carousel_go_to": "انتقل إلى الشريحة `x`", "carousel_go_to": "انتقل إلى الشريحة `x`"
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
} }

View file

@ -137,7 +137,7 @@
"Family friendly? ": "Vhodné pro rodiny? ", "Family friendly? ": "Vhodné pro rodiny? ",
"Engagement: ": "Zapojení: ", "Engagement: ": "Zapojení: ",
"English": "Angličtina", "English": "Angličtina",
"English (auto-generated)": "Angličtina (vytvořeno automaticky)", "English (auto-generated)": "Angličtina (automaticky generováno)",
"Afrikaans": "Afrikánština", "Afrikaans": "Afrikánština",
"Albanian": "Albánština", "Albanian": "Albánština",
"Amharic": "Amharština", "Amharic": "Amharština",
@ -294,8 +294,8 @@
"Chinese (China)": "Čínština (Čína)", "Chinese (China)": "Čínština (Čína)",
"Chinese (Hong Kong)": "Čínština (Hong Kong)", "Chinese (Hong Kong)": "Čínština (Hong Kong)",
"Chinese (Taiwan)": "Čínština (Taiwan)", "Chinese (Taiwan)": "Čínština (Taiwan)",
"Portuguese (auto-generated)": "Portugalština (vytvořeno automaticky)", "Portuguese (auto-generated)": "Portugalština (automaticky generováno)",
"Spanish (auto-generated)": "Španělština (vytvořeno automaticky)", "Spanish (auto-generated)": "Španělština (automaticky generováno)",
"Spanish (Mexico)": "Španělština (Mexiko)", "Spanish (Mexico)": "Španělština (Mexiko)",
"Spanish (Spain)": "Španělština (Španělsko)", "Spanish (Spain)": "Španělština (Španělsko)",
"generic_count_years_0": "{{count}} rokem", "generic_count_years_0": "{{count}} rokem",
@ -352,13 +352,13 @@
"comments_points_count_0": "{{count}} bod", "comments_points_count_0": "{{count}} bod",
"comments_points_count_1": "{{count}} body", "comments_points_count_1": "{{count}} body",
"comments_points_count_2": "{{count}} bodů", "comments_points_count_2": "{{count}} bodů",
"German (auto-generated)": "Němčina (vytvořeno automaticky)", "German (auto-generated)": "Němčina (automaticky generováno)",
"Indonesian (auto-generated)": "Indonéština (vytvořeno automaticky)", "Indonesian (auto-generated)": "Indonéština (automaticky generováno)",
"Interlingue": "Interlingue", "Interlingue": "Interlingue",
"Italian (auto-generated)": "Italština (vytvořeno automaticky)", "Italian (auto-generated)": "Italština (automaticky generováno)",
"Japanese (auto-generated)": "Japonština (vytvořeno automaticky)", "Japanese (auto-generated)": "Japonština (automaticky generováno)",
"Korean (auto-generated)": "Korejština (vytvořeno automaticky)", "Korean (auto-generated)": "Korejština (automaticky generováno)",
"Russian (auto-generated)": "Ruština (vytvořeno automaticky)", "Russian (auto-generated)": "Ruština (automaticky generováno)",
"generic_count_months_0": "{{count}} měsícem", "generic_count_months_0": "{{count}} měsícem",
"generic_count_months_1": "{{count}} měsíci", "generic_count_months_1": "{{count}} měsíci",
"generic_count_months_2": "{{count}} měsíci", "generic_count_months_2": "{{count}} měsíci",
@ -371,7 +371,7 @@
"footer_documentation": "Dokumentace", "footer_documentation": "Dokumentace",
"next_steps_error_message_refresh": "Obnovit stránku", "next_steps_error_message_refresh": "Obnovit stránku",
"Chinese": "Čínština", "Chinese": "Čínština",
"Dutch (auto-generated)": "Nizozemština (vytvořeno automaticky)", "Dutch (auto-generated)": "Nizozemština (automaticky generováno)",
"Erroneous token": "Chybný token", "Erroneous token": "Chybný token",
"tokens_count_0": "{{count}} token", "tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokeny", "tokens_count_1": "{{count}} tokeny",
@ -380,9 +380,9 @@
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu", "Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
"English (United States)": "Angličtina (Spojené státy)", "English (United States)": "Angličtina (Spojené státy)",
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)", "Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
"French (auto-generated)": "Francouzština (vytvořeno automaticky)", "French (auto-generated)": "Francouzština (automaticky generováno)",
"Turkish (auto-generated)": "Turečtina (vytvořeno automaticky)", "Turkish (auto-generated)": "Turečtina (automaticky generováno)",
"Vietnamese (auto-generated)": "Vietnamština (vytvořeno automaticky)", "Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)",
"Current version: ": "Aktuální verze: ", "Current version: ": "Aktuální verze: ",
"next_steps_error_message": "Měli byste zkusit: ", "next_steps_error_message": "Měli byste zkusit: ",
"footer_donate_page": "Přispět", "footer_donate_page": "Přispět",
@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.", "The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
"carousel_slide": "Snímek {{current}} z {{total}}", "carousel_slide": "Snímek {{current}} z {{total}}",
"carousel_skip": "Přeskočit galerii", "carousel_skip": "Přeskočit galerii",
"carousel_go_to": "Přejít na snímek `x`", "carousel_go_to": "Přejít na snímek `x`"
"preferences_preload_label": "Předem načíst data videa: ",
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
} }

View file

@ -11,7 +11,6 @@
"last": "neueste", "last": "neueste",
"Next page": "Nächste Seite", "Next page": "Nächste Seite",
"Previous page": "Vorherige Seite", "Previous page": "Vorherige Seite",
"First page": "Erste Seite",
"Clear watch history?": "Verlauf löschen?", "Clear watch history?": "Verlauf löschen?",
"New password": "Neues Passwort", "New password": "Neues Passwort",
"New passwords must match": "Neue Passwörter müssen übereinstimmen", "New passwords must match": "Neue Passwörter müssen übereinstimmen",
@ -491,13 +490,12 @@
"generic_channels_count_plural": "{{count}} Kanäle", "generic_channels_count_plural": "{{count}} Kanäle",
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)", "Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
"Answer": "Antwort", "Answer": "Antwort",
"The Popular feed has been disabled by the administrator.": "Der Feed für beliebte Inhalte wurde vom Administrator deaktiviert.", "The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.",
"Add to playlist": "Einer Wiedergabeliste hinzufügen", "Add to playlist": "Einer Wiedergabeliste hinzufügen",
"Search for videos": "Nach Videos suchen", "Search for videos": "Nach Videos suchen",
"toggle_theme": "Thema wechseln", "toggle_theme": "Thema wechseln",
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ", "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
"carousel_go_to": "Zu Element `x` springen", "carousel_go_to": "Zu Folie `x` gehen",
"carousel_slide": "Seite {{current}} von {{total}}", "carousel_slide": "Folie {{current}} von {{total}}",
"carousel_skip": "Galerie überspringen", "carousel_skip": "Karussell überspringen"
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
} }

View file

@ -21,7 +21,7 @@
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων", "Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
"Import": "Εισαγωγή", "Import": "Εισαγωγή",
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON", "Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube απο CVS/OPML", "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)", "Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
@ -455,7 +455,7 @@
"channel_tab_streams_label": "Ζωντανή μετάδοση", "channel_tab_streams_label": "Ζωντανή μετάδοση",
"playlist_button_add_items": "Προσθήκη βίντεο", "playlist_button_add_items": "Προσθήκη βίντεο",
"Artist: ": "Καλλιτέχνης: ", "Artist: ": "Καλλιτέχνης: ",
"search_message_use_another_instance": "Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.", "search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
"generic_button_save": "Αποθήκευση", "generic_button_save": "Αποθήκευση",
"generic_button_cancel": "Ακύρωση", "generic_button_cancel": "Ακύρωση",
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση", "subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
@ -490,13 +490,9 @@
"Search for videos": "Αναζήτηση βίντεο", "Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.", "The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση", "Answer": "Απάντηση",
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής", "Add to playlist": "Λίιστα αναπαραγωγής",
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ", "Add to playlist: ": "Λίστα αναπαραγωγής: ",
"carousel_slide": "Εικόνα {{current}}απο {{total}}", "carousel_slide": "Εικόνα {{current}}απο {{total}}",
"carousel_go_to": "Πήγαινε στην εικόνα`x`", "carousel_go_to": "Πήγαινε στην εικόνα`x`",
"toggle_theme": "Αλλαγή θέματος", "toggle_theme": "Αλλαγή θέματος"
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
} }

View file

@ -33,7 +33,6 @@
"last": "last", "last": "last",
"Next page": "Next page", "Next page": "Next page",
"Previous page": "Previous page", "Previous page": "Previous page",
"First page": "First page",
"Clear watch history?": "Clear watch history?", "Clear watch history?": "Clear watch history?",
"New password": "New password", "New password": "New password",
"New passwords must match": "New passwords must match", "New passwords must match": "New passwords must match",
@ -513,21 +512,13 @@
"channel_tab_streams_label": "Livestreams", "channel_tab_streams_label": "Livestreams",
"channel_tab_podcasts_label": "Podcasts", "channel_tab_podcasts_label": "Podcasts",
"channel_tab_releases_label": "Releases", "channel_tab_releases_label": "Releases",
"channel_tab_courses_label": "Courses",
"channel_tab_playlists_label": "Playlists", "channel_tab_playlists_label": "Playlists",
"channel_tab_community_label": "Community", "channel_tab_community_label": "Community",
"channel_tab_posts_label": "Posts",
"channel_tab_channels_label": "Channels", "channel_tab_channels_label": "Channels",
"toggle_theme": "Toggle Theme", "toggle_theme": "Toggle Theme",
"carousel_slide": "Slide {{current}} of {{total}}", "carousel_slide": "Slide {{current}} of {{total}}",
"carousel_skip": "Skip the Carousel", "carousel_skip": "Skip the Carousel",
"carousel_go_to": "Go to slide `x`", "carousel_go_to": "Go to slide `x`",
"footer_contact_url": "Contact the Administrator", "footer_contact_url": "Contact the Administrator"
"new_username": "New username",
"change_username": "Change username",
"username_required_field": "Username is a required field",
"username_empty": "Username cannot be empty",
"username_is_the_same": "This is your username, use another one",
"username_taken": "Username is already taken, use another one",
"backend_unavailable": "The backend you selected is unavailable. You have been redirected to the next one"
} }

View file

@ -516,14 +516,5 @@
"carousel_slide": "Diapositiva {{current}} de {{total}}", "carousel_slide": "Diapositiva {{current}} de {{total}}",
"carousel_skip": "Saltar el carrusel", "carousel_skip": "Saltar el carrusel",
"carousel_go_to": "Ir a la diapositiva `x`", "carousel_go_to": "Ir a la diapositiva `x`",
"footer_contact_url": "Contactar al Administrador", "footer_contact_url": "Contactar al Administrador"
"preferences_preload_label": "Precargar datos del vídeo: ",
"Filipino (auto-generated)": "Filipino (generado automáticamente)",
"new_username": "Nuevo nombre de usuario",
"change_username": "Cambiar nombre de usuario",
"username_required_field": "El nombre de usuario es un campo obligatorio",
"username_empty": "El nombre de usuario no puede estar vacío",
"username_is_the_same": "Este es tu nombre de usuario, usa otro",
"username_taken": "El nombre de usuario ya está en uso, usa otro",
"backend_unavailable": "El backend seleccionado no está disponible. Has sido redireccionado al siguiente"
} }

View file

@ -496,6 +496,5 @@
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>", "crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:", "crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
"channel_tab_releases_label": "آثار", "channel_tab_releases_label": "آثار",
"toggle_theme": "تغییر وضعیت تم", "toggle_theme": "تغییر وضعیت تم"
"preferences_preload_label": "پیش بار کردن داده‌های ویدیو: "
} }

View file

@ -460,7 +460,7 @@
"search_filters_apply_button": "Ota valitut suodattimet käyttöön", "search_filters_apply_button": "Ota valitut suodattimet käyttöön",
"search_filters_date_label": "Latausaika", "search_filters_date_label": "Latausaika",
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)", "search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
"search_message_use_another_instance": "Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.", "search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
"search_filters_date_option_none": "Milloin tahansa", "search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi", "search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: ", "Popular enabled: ": "Suosittu käytössä: ",
@ -496,6 +496,5 @@
"generic_channels_count_plural": "{{count}} kanavaa", "generic_channels_count_plural": "{{count}} kanavaa",
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.", "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)", "Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
"toggle_theme": "Vaihda teemaa", "toggle_theme": "Vaihda teemaa"
"preferences_preload_label": "Esilataa video data. "
} }

View file

@ -505,7 +505,7 @@
"channel_tab_releases_label": "Parutions", "channel_tab_releases_label": "Parutions",
"channel_tab_podcasts_label": "Émissions audio", "channel_tab_podcasts_label": "Émissions audio",
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)", "Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
"Add to playlist: ": "Ajouter à la playlist : ", "Add to playlist: ": "Ajouter à la playlist: ",
"Add to playlist": "Ajouter à la playlist", "Add to playlist": "Ajouter à la playlist",
"Answer": "Répondre", "Answer": "Répondre",
"Search for videos": "Rechercher des vidéos", "Search for videos": "Rechercher des vidéos",
@ -513,7 +513,5 @@
"carousel_skip": "Passez le carrousel", "carousel_skip": "Passez le carrousel",
"carousel_slide": "Diapositive {{current}} sur {{total}}", "carousel_slide": "Diapositive {{current}} sur {{total}}",
"carousel_go_to": "Aller à la diapositive `x`", "carousel_go_to": "Aller à la diapositive `x`",
"toggle_theme": "Changer le Thème", "toggle_theme": "Changer le Thème"
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
"preferences_preload_label": "Précharger les données de la vidéo : "
} }

View file

@ -513,7 +513,5 @@
"toggle_theme": "Uklj./Isklj. temu", "toggle_theme": "Uklj./Isklj. temu",
"carousel_slide": "Kadar {{current}} od {{total}}", "carousel_slide": "Kadar {{current}} od {{total}}",
"carousel_go_to": "Idi na kadar `x`", "carousel_go_to": "Idi na kadar `x`",
"carousel_skip": "Preskoči vrtuljak", "carousel_skip": "Preskoči vrtuljak"
"Filipino (auto-generated)": "Filipinski (automatski generirano)",
"preferences_preload_label": "Unaprijed učitaj podatke videa: "
} }

View file

@ -496,7 +496,5 @@
"footer_documentation": "Leiðbeiningar", "footer_documentation": "Leiðbeiningar",
"channel_tab_channels_label": "Rásir", "channel_tab_channels_label": "Rásir",
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)", "Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)", "preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
} }

View file

@ -469,8 +469,8 @@
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)", "Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
"Spanish (Mexico)": "Spagnolo (Messico)", "Spanish (Mexico)": "Spagnolo (Messico)",
"Spanish (Spain)": "Spagnolo (Spagna)", "Spanish (Spain)": "Spagnolo (Spagna)",
"Turkish (auto-generated)": "Turco (generati automaticamente)", "Turkish (auto-generated)": "Turco (auto-generato)",
"Vietnamese (auto-generated)": "Vietnamita (generati automaticamente)", "Vietnamese (auto-generated)": "Vietnamita (auto-generato)",
"search_filters_date_label": "Data caricamento", "search_filters_date_label": "Data caricamento",
"search_filters_date_option_none": "Qualunque data", "search_filters_date_option_none": "Qualunque data",
"search_filters_type_option_all": "Qualunque tipo", "search_filters_type_option_all": "Qualunque tipo",
@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.", "The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
"carousel_slide": "Fotogramma {{current}} di {{total}}", "carousel_slide": "Fotogramma {{current}} di {{total}}",
"carousel_skip": "Salta la galleria", "carousel_skip": "Salta la galleria",
"carousel_go_to": "Vai al fotogramma `x`", "carousel_go_to": "Vai al fotogramma `x`"
"preferences_preload_label": "Precarica dati video: ",
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
} }

View file

@ -479,7 +479,5 @@
"carousel_go_to": "スライド`x`を表示", "carousel_go_to": "スライド`x`を表示",
"carousel_slide": "スライド{{current}} / 全{{total}}個中", "carousel_slide": "スライド{{current}} / 全{{total}}個中",
"carousel_skip": "画像のスライド表示をスキップ", "carousel_skip": "画像のスライド表示をスキップ",
"toggle_theme": "テーマの切り替え", "toggle_theme": "テーマの切り替え"
"preferences_preload_label": "動画データを事前に読み込む: ",
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
} }

View file

@ -70,7 +70,7 @@
"Next page": "다음 페이지", "Next page": "다음 페이지",
"last": "마지막", "last": "마지막",
"Shared `x` ago": "`x` 전", "Shared `x` ago": "`x` 전",
"popular": "인기", "popular": "인기",
"oldest": "과거순", "oldest": "과거순",
"newest": "최신순", "newest": "최신순",
"View playlist on YouTube": "유튜브에서 재생목록 보기", "View playlist on YouTube": "유튜브에서 재생목록 보기",
@ -479,6 +479,5 @@
"carousel_go_to": "`x` 슬라이드로 이동", "carousel_go_to": "`x` 슬라이드로 이동",
"Search for videos": "비디오 검색", "Search for videos": "비디오 검색",
"toggle_theme": "테마 전환", "toggle_theme": "테마 전환",
"carousel_slide": "{{total}}의 슬라이드 {{current}}", "carousel_slide": "{{total}}의 슬라이드 {{current}}"
"preferences_preload_label": "비디오 데이터 사전 로드: "
} }

View file

@ -496,6 +496,5 @@
"Add to playlist": "Legg til i spilleliste", "Add to playlist": "Legg til i spilleliste",
"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.", "The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
"toggle_theme": "Endre utseende", "toggle_theme": "Endre utseende"
"preferences_preload_label": "Last videodata på forhånd: "
} }

View file

@ -496,7 +496,5 @@
"Answer": "Antwoorden", "Answer": "Antwoorden",
"Search for videos": "Naar video's zoeken", "Search for videos": "Naar video's zoeken",
"carousel_skip": "Carousel overslaan", "carousel_skip": "Carousel overslaan",
"toggle_theme": "Thema omschakelen", "toggle_theme": "Thema omschakelen"
"preferences_preload_label": "Videogegevens vooraf laden: ",
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
} }

View file

@ -513,7 +513,5 @@
"Add to playlist: ": "Dodaj do playlisty: ", "Add to playlist: ": "Dodaj do playlisty: ",
"carousel_slide": "Slajd {{current}} z {{total}}", "carousel_slide": "Slajd {{current}} z {{total}}",
"carousel_skip": "Pomiń karuzelę", "carousel_skip": "Pomiń karuzelę",
"carousel_go_to": "Przejdź do slajdu `x`", "carousel_go_to": "Przejdź do slajdu `x`"
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
} }

View file

@ -513,7 +513,5 @@
"Answer": "Resposta", "Answer": "Resposta",
"carousel_slide": "Slide {{current}} de {{total}}", "carousel_slide": "Slide {{current}} de {{total}}",
"carousel_skip": "Ignorar carrossel", "carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir ao slide `x`", "carousel_go_to": "Ir ao slide `x`"
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
} }

View file

@ -513,7 +513,5 @@
"carousel_slide": "Diapositivo {{current}} de{{total}}", "carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel", "carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir para o diapositivo`x`", "carousel_go_to": "Ir para o diapositivo`x`",
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.", "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
"preferences_preload_label": "Pré-carregamento dos dados: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
} }

View file

@ -11,7 +11,6 @@
"last": "последние", "last": "последние",
"Next page": "Следующая страница", "Next page": "Следующая страница",
"Previous page": "Предыдущая страница", "Previous page": "Предыдущая страница",
"First page": "Первая страница",
"Clear watch history?": "Очистить историю просмотров?", "Clear watch history?": "Очистить историю просмотров?",
"New password": "Новый пароль", "New password": "Новый пароль",
"New passwords must match": "Новые пароли не совпадают", "New passwords must match": "Новые пароли не совпадают",
@ -49,8 +48,8 @@
"preferences_category_player": "Настройки проигрывателя", "preferences_category_player": "Настройки проигрывателя",
"preferences_video_loop_label": "Всегда повторять: ", "preferences_video_loop_label": "Всегда повторять: ",
"preferences_autoplay_label": "Автовоспроизведение: ", "preferences_autoplay_label": "Автовоспроизведение: ",
"preferences_continue_label": "Воспроизводить следующее видео: ", "preferences_continue_label": "Переходить к следующему видео? ",
"preferences_continue_autoplay_label": "Автовоспроизведение следующего видео: ", "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
"preferences_listen_label": "Режим «только аудио» по умолчанию: ", "preferences_listen_label": "Режим «только аудио» по умолчанию: ",
"preferences_local_label": "Проигрывать видео через прокси? ", "preferences_local_label": "Проигрывать видео через прокси? ",
"preferences_speed_label": "Скорость видео по умолчанию: ", "preferences_speed_label": "Скорость видео по умолчанию: ",
@ -514,6 +513,5 @@
"toggle_theme": "Переключатель тем", "toggle_theme": "Переключатель тем",
"carousel_slide": "Пролистано {{current}} из {{total}}", "carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё", "carousel_skip": "Пропустить всё",
"carousel_go_to": "Перейти к странице `x`", "carousel_go_to": "Перейти к странице `x`"
"preferences_preload_label": "Предзагрузка видеоданных: "
} }

View file

@ -13,7 +13,7 @@
"Import and Export Data": "Uvoz in izvoz podatkov", "Import and Export Data": "Uvoz in izvoz podatkov",
"Import": "Uvozi", "Import": "Uvozi",
"Import Invidious data": "Uvozi Invidious JSON podatke", "Import Invidious data": "Uvozi Invidious JSON podatke",
"Import YouTube subscriptions": "Uvozi YouTube CSV ali OPML naročnine", "Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine",
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine", "Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke", "Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
"Export": "Izvozi", "Export": "Izvozi",
@ -105,7 +105,7 @@
"Show more": "Pokaži več", "Show more": "Pokaži več",
"Switch Invidious Instance": "Preklopi Invidious instanco", "Switch Invidious Instance": "Preklopi Invidious instanco",
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.", "search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
"search_message_use_another_instance": "Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.", "search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
"Wilson score: ": "Wilsonov rezultat: ", "Wilson score: ": "Wilsonov rezultat: ",
"Engagement: ": "Sodelovanje: ", "Engagement: ": "Sodelovanje: ",
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ", "Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
@ -462,7 +462,7 @@
"search_filters_features_option_four_k": "4K", "search_filters_features_option_four_k": "4K",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"next_steps_error_message_refresh": "Osveži", "next_steps_error_message_refresh": "Osveži",
"search_filters_date_option_hour": "V zadnji uri", "search_filters_date_option_hour": "Zadnja ura",
"search_filters_features_option_purchased": "Kupljeno", "search_filters_features_option_purchased": "Kupljeno",
"search_filters_sort_label": "Razvrsti po", "search_filters_sort_label": "Razvrsti po",
"search_filters_sort_option_views": "številu ogledov", "search_filters_sort_option_views": "številu ogledov",
@ -521,16 +521,5 @@
"generic_channels_count_1": "{{count}} kanala", "generic_channels_count_1": "{{count}} kanala",
"generic_channels_count_2": "{{count}} kanali", "generic_channels_count_2": "{{count}} kanali",
"generic_channels_count_3": "{{count}} kanalov", "generic_channels_count_3": "{{count}} kanalov",
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)", "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
"Add to playlist": "Dodaj na seznam predvajanja",
"Add to playlist: ": "Dodaj na seznam predvajanja: ",
"Search for videos": "Iskanje videoposnetkov",
"The Popular feed has been disabled by the administrator.": "Administrator je onemogočil priljubljeni vir.",
"Answer": "Odgovor",
"Filipino (auto-generated)": "filipinščina (samodejno ustvarjeno)",
"toggle_theme": "Preklopi temo",
"carousel_slide": "Diapozitiv {{current}} od {{total}}",
"carousel_skip": "Preskoči galerijo",
"carousel_go_to": "Pojdi na diapozitiv `x`",
"preferences_preload_label": "Predhodno naloži video podatke: "
} }

View file

@ -492,7 +492,5 @@
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.", "The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
"carousel_skip": "Anashkaloje Rrotullamen", "carousel_skip": "Anashkaloje Rrotullamen",
"carousel_slide": "Diapozitiv {{current}} nga {{total}}", "carousel_slide": "Diapozitiv {{current}} nga {{total}}",
"carousel_go_to": "Kalo te diapozitivi `x`", "carousel_go_to": "Kalo te diapozitivi `x`"
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
"preferences_preload_label": "Parangarko të dhëna videoje: "
} }

View file

@ -513,7 +513,5 @@
"Answer": "Odgovor", "Answer": "Odgovor",
"Search for videos": "Pretražite video snimke", "Search for videos": "Pretražite video snimke",
"carousel_skip": "Preskoči karusel", "carousel_skip": "Preskoči karusel",
"toggle_theme": "Подеси тему", "toggle_theme": "Подеси тему"
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
} }

View file

@ -513,7 +513,5 @@
"Add to playlist: ": "Додајте на плејлисту: ", "Add to playlist: ": "Додајте на плејлисту: ",
"carousel_skip": "Прескочи карусел", "carousel_skip": "Прескочи карусел",
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.", "The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
"carousel_slide": "Слајд {{current}} од {{total}}", "carousel_slide": "Слајд {{current}} од {{total}}"
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
} }

View file

@ -496,7 +496,5 @@
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.", "The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
"carousel_slide": "Bildspel {{current}} av {{total}}", "carousel_slide": "Bildspel {{current}} av {{total}}",
"carousel_skip": "Hoppa över karusellen", "carousel_skip": "Hoppa över karusellen",
"carousel_go_to": "Gå till bildspel `x`", "carousel_go_to": "Gå till bildspel `x`"
"preferences_preload_label": "Förladda video data: ",
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
} }

View file

@ -1,502 +0,0 @@
{
"Add to playlist": "பிளேலிச்ட்டில் சேர்க்கவும்",
"generic_channels_count": "{{count}} சேனல்",
"generic_channels_count_plural": "{{count}} சேனல்கள்",
"generic_views_count": "{{count}} பார்வை",
"generic_views_count_plural": "{{count}} காட்சிகள்",
"generic_videos_count": "{{count}} வீடியோ",
"generic_videos_count_plural": "{{count}} வீடியோக்கள்",
"generic_playlists_count": "{{count}} பிளேலிச்ட்",
"generic_playlists_count_plural": "{{count}} பிளேலிச்ட்கள்",
"generic_subscribers_count": "{{count}} சந்தாதாரர்",
"generic_subscribers_count_plural": "{{count}} சந்தாதாரர்கள்",
"generic_button_delete": "நீக்கு",
"generic_button_rss": "ஆர்.எச்.எச்",
"LIVE": "வாழ",
"Shared `x` ago": "`X` முன்பு பகிரப்பட்டது",
"Unsubscribe": "குழுவிலகவும்",
"View playlist on YouTube": "யூடியூப்பில் பிளேலிச்ட்டைக் காண்க",
"newest": "புதியது",
"oldest": "பழமையானது",
"popular": "மக்கள்",
"last": "கடைசி",
"Next page": "அடுத்த பக்கம்",
"Previous page": "முந்தைய பக்கம்",
"Clear watch history?": "தெளிவான கண்காணிப்பு வரலாறு?",
"New password": "புதிய கடவுச்சொல்",
"New passwords must match": "புதிய கடவுச்சொற்கள் பொருந்த வேண்டும்",
"Authorize token?": "கிள்ளாக்கை அங்கீகரிக்கவா?",
"Yes": "ஆம்",
"Import YouTube playlist (.csv)": "யூடியூப் பிளேலிச்ட்டை இறக்குமதி செய்க (.csv)",
"Import YouTube watch history (.json)": "YouTube வாட்ச் வரலாற்றை இறக்குமதி செய்க (.json)",
"Import Invidious data": "வன்கவர்வு சாதொபொகு தரவை இறக்குமதி செய்க",
"Import YouTube subscriptions": "YouTube காபிம அல்லது OPML சந்தாக்களை இறக்குமதி செய்க",
"Import FreeTube subscriptions (.db)": "ஃப்ரீட்யூப் சந்தாக்களை இறக்குமதி செய்க (.db)",
"Import NewPipe data (.zip)": "நியூபைப் தரவை இறக்குமதி செய்க (.zip)",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள் (நியூபைப் & ஃப்ரீட்யூப்பிற்கு)",
"Export subscriptions as OPML": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள்",
"Export data as JSON": "சாதொபொகு ஆக வன்கவர்வு தரவை ஏற்றுமதி செய்யுங்கள்",
"Delete account?": "கணக்கை நீக்கவா?",
"History": "வரலாறு",
"JavaScript license information": "சாவாச்கிரிப்ட் உரிம செய்தி",
"source": "மூலம்",
"An alternative front-end to YouTube": "YouTube க்கு ஒரு மாற்று முன் இறுதியில்",
"Log in": "புகுபதிகை",
"Log in/register": "உள்நுழைக/பதிவு செய்யுங்கள்",
"User ID": "பயனர் ஐடி",
"Password": "கடவுச்சொல்",
"Time (h:mm:ss):": "நேரம் (h: மிமீ: எச்எச்):",
"Sign In": "விடுபதிகை",
"Register": "பதிவு செய்யுங்கள்",
"E-mail": "மின்னஞ்சல்",
"Preferences": "விருப்பத்தேர்வுகள்",
"preferences_preload_label": "வீடியோ தரவை முன்பே ஏற்றவும்: ",
"preferences_autoplay_label": "தன்னியக்க: ",
"preferences_continue_label": "இயல்பாக அடுத்து விளையாடுங்கள்: ",
"preferences_local_label": "பதிலாள் வீடியோக்கள்: ",
"preferences_watch_history_label": "கண்காணிப்பு வரலாற்றை இயக்கு: ",
"preferences_speed_label": "இயல்புநிலை வேகம்: ",
"preferences_quality_label": "விருப்பமான வீடியோ தரம்: ",
"preferences_quality_dash_label": "விருப்பமான கோடு வீடியோ தரம்: ",
"preferences_quality_dash_option_auto": "தானி",
"preferences_quality_dash_option_best": "சிறந்த",
"preferences_quality_dash_option_worst": "மோசமான",
"preferences_quality_dash_option_4320p": "4320 ப",
"preferences_quality_dash_option_1080p": "1080 ப",
"preferences_quality_dash_option_720p": "720 ஆ",
"preferences_quality_dash_option_480p": "480 ப",
"preferences_quality_dash_option_360p": "360 ப",
"preferences_quality_dash_option_144p": "144 ப",
"preferences_volume_label": "பிளேயர் தொகுதி: ",
"preferences_comments_label": "இயல்புநிலை கருத்துகள்: ",
"Fallback captions: ": "குறைவடையும் தலைப்புகள்: ",
"preferences_captions_label": "இயல்புநிலை தலைப்புகள்: ",
"preferences_related_videos_label": "தொடர்புடைய வீடியோக்களைக் காட்டு: ",
"preferences_annotations_label": "முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டு: ",
"preferences_vr_mode_label": "ஊடாடும் 360 டிகிரி வீடியோக்கள் (வெப்சிஎல் தேவை): ",
"preferences_category_visual": "காட்சி விருப்பத்தேர்வுகள்",
"light": "ஒளி",
"preferences_thin_mode_label": "மெல்லிய பயன்முறை: ",
"preferences_category_misc": "இதர விருப்பத்தேர்வுகள்",
"preferences_category_subscription": "சந்தா விருப்பத்தேர்வுகள்",
"preferences_annotations_subscribed_label": "சந்தா சேனல்களுக்கு முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டவா? ",
"Redirect homepage to feed: ": "உணவளிக்க முகப்புப்பக்கத்தை திருப்பி விடுங்கள்: ",
"preferences_sort_label": "வீடியோக்களை வரிசைப்படுத்துங்கள்: ",
"published": "வெளியிடப்பட்டது",
"published - reverse": "வெளியிடப்பட்டது - தலைகீழ்",
"alphabetically": "அகரவரிசை",
"preferences_unseen_only_label": "கவனக்குறைவாக மட்டுமே காட்டுங்கள்: ",
"preferences_notifications_only_label": "அறிவிப்புகளைக் காட்டுங்கள் (ஏதேனும் இருந்தால்): ",
"Enable web notifications": "வலை அறிவிப்புகளை இயக்கவும்",
"`x` is live": "`x` நேரலையில்",
"preferences_category_data": "தரவு விருப்பத்தேர்வுகள்",
"Manage subscriptions": "சந்தாக்களை நிர்வகிக்கவும்",
"Watch history": "வரலாற்றைப் பாருங்கள்",
"Delete account": "கணக்கை நீக்கு",
"preferences_category_admin": "நிர்வாகி விருப்பத்தேர்வுகள்",
"preferences_default_home_label": "இயல்புநிலை முகப்புப்பக்கம்: ",
"preferences_feed_menu_label": "ஊட்ட மெனு: ",
"preferences_show_nick_label": "மேலே புனைப்பெயரைக் காட்டு: ",
"Top enabled: ": "மேலே இயக்கப்பட்டது: ",
"CAPTCHA enabled: ": "கேப்ட்சா இயக்கப்பட்டது: ",
"Login enabled: ": "உள்நுழைவு இயக்கப்பட்டது: ",
"Registration enabled: ": "பதிவு இயக்கப்பட்டது: ",
"Report statistics: ": "அறிக்கை புள்ளிவிவரங்கள்: ",
"Save preferences": "விருப்பங்களை சேமிக்கவும்",
"Subscription manager": "சந்தா மேலாளர்",
"Token manager": "கிள்ளாக்கு மேலாளர்",
"Token": "கிள்ளாக்கு",
"search": "தேடல்",
"Released under the AGPLv3 on Github.": "கிட்அப்பில் AgPlv3 இன் கீழ் வெளியிடப்பட்டது.",
"View JavaScript license information.": "சாவாச்கிரிப்ட் உரிமத் தகவலைக் காண்க.",
"View privacy policy.": "தனியுரிமைக் கொள்கையைக் காண்க.",
"Trending": "டிரெண்டிங்",
"Public": "பொது",
"Unlisted": "பட்டியலிடப்படாதது",
"Private": "தனிப்பட்ட",
"View all playlists": "அனைத்து பிளேலிச்ட்களையும் காண்க",
"Updated `x` ago": "`X` முன்பு புதுப்பிக்கப்பட்டது",
"Delete playlist `x`?": "பிளேலிச்ட்டை நீக்கவா?",
"Playlist privacy": "பிளேலிச்ட் தனியுரிமை",
"Watch on YouTube": "YouTube இல் பாருங்கள்",
"Hide annotations": "சிறுகுறிப்புகளை மறைக்கவும்",
"Show replies": "பதில்களைக் காட்டு",
"Incorrect password": "தவறான கடவுச்சொல்",
"Wrong answer": "தவறான பதில்",
"Erroneous CAPTCHA": "தவறான கேப்ட்சா",
"CAPTCHA is a required field": "கேப்ட்சா ஒரு தேவையான புலம்",
"User ID is a required field": "பயனர் ஐடி தேவையான புலம்",
"Password is a required field": "கடவுச்சொல் தேவையான புலம்",
"Password cannot be empty": "கடவுச்சொல் காலியாக இருக்க முடியாது",
"Please log in": "தயவுசெய்து உள்நுழைக",
"This channel does not exist.": "இந்த சேனல் இல்லை.",
"Could not get channel info.": "சேனல் தகவலைப் பெற முடியவில்லை.",
"Could not fetch comments": "கருத்துகளைப் பெற முடியவில்லை",
"comments_points_count": "{{count}} புள்ளி",
"comments_points_count_plural": "{{count}} புள்ளிகள்",
"Could not create mix.": "கலவையை உருவாக்க முடியவில்லை.",
"Empty playlist": "வெற்று பிளேலிச்ட்",
"Not a playlist.": "ஒரு பிளேலிச்ட் அல்ல.",
"Playlist does not exist.": "பிளேலிச்ட் இல்லை.",
"Could not pull trending pages.": "பிரபலமான பக்கங்களை இழுக்க முடியவில்லை.",
"Erroneous challenge": "தவறான அறைகூவல்",
"Erroneous token": "தவறான கிள்ளாக்கு",
"No such user": "அத்தகைய பயனர் இல்லை",
"Token is expired, please try again": "கிள்ளாக்கு காலாவதியானது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்",
"English": "ஆங்கிலம்",
"English (United States)": "ஆங்கிலம் (ஐக்கிய அமெரிக்க)",
"English (United Kingdom)": "ஆங்கிலம் (ஐக்கிய முடியரசு)",
"English (auto-generated)": "ஆங்கிலம் (தானாக உருவாக்கப்பட்ட)",
"Afrikaans": "ஆப்பிரிக்கா",
"Albanian": "அல்பேனிய",
"Amharic": "அம்ஆரிக்",
"Arabic": "அரபு",
"Armenian": "ஆர்மீனியன்",
"Azerbaijani": "அசர்பைசானி",
"Bangla": "பாங்லா",
"Basque": "பாச்க்",
"Belarusian": "பெலாருசியன்",
"Bosnian": "போச்னிய",
"Bulgarian": "பல்கேரியன்",
"Burmese": "பர்மீச்",
"Cantonese (Hong Kong)": "கான்டோனீச் (ஆங்காங்)",
"Catalan": "கற்றலான்",
"Cebuano": "செபுவானோ",
"Chinese": "சீன",
"Chinese (China)": "சீன (சீனா)",
"Chinese (Hong Kong)": "சீன (ஆங்காங்)",
"Chinese (Simplified)": "சீன (எளிமைப்படுத்தப்பட்ட)",
"Chinese (Taiwan)": "சீன (தைவான்)",
"Chinese (Traditional)": "சீன (பாரம்பரிய)",
"Dutch": "டச்சு",
"Finnish": "பின்னிச்",
"French": "பிரஞ்சு",
"German (auto-generated)": "செர்மன் (தானாக உருவாக்கப்பட்ட)",
"Greek": "கிரேக்கம்",
"Gujarati": "குசராத்தி",
"Haitian Creole": "ஐட்டிய கிரியோல்",
"Hungarian": "அங்கேரியன்",
"Icelandic": "ஐச்லாந்திய",
"Igbo": "இக்போ",
"Korean (auto-generated)": "கொரிய (தானாக உருவாக்கப்பட்ட)",
"Macedonian": "மாசிடோனியன்",
"Malagasy": "மலகாசி",
"Maltese": "மால்டிச்",
"Maori": "மௌரி",
"Malayalam": "மலையாளம்",
"Marathi": "மராத்தி",
"Mongolian": "மங்கோலியன்",
"Nepali": "நேபாளி",
"Norwegian Bokmål": "நார்வேசியன் பொக்மால்",
"Nyanja": "நயன்சா",
"Russian": "ரச்ய",
"Russian (auto-generated)": "ரச்ய (தானாக உருவாக்கப்பட்ட)",
"Samoan": "சமோவான்",
"Scottish Gaelic": "ச்கோட்டிச் கயாலிக்",
"Serbian": "செர்பிய",
"Shona": "சோனா",
"Sindhi": "சிந்தி",
"Somali": "சோமாலி",
"Southern Sotho": "தெற்கத்திய சோதோ",
"Spanish": "ச்பானிச்",
"Spanish (auto-generated)": "ச்பானிச் (தானாக உருவாக்கப்பட்ட)",
"Sundanese": "சுந்தானியர்கள்",
"Swahili": "ச்வாஇலி",
"Swedish": "ச்வீடிச்",
"Tajik": "தசிக்",
"Tamil": "தமிழ்",
"Thai": "தாய்",
"Turkish": "துருக்கிய",
"Vietnamese": "வியட்நாமிய",
"Welsh": "வேல்ச்",
"Xhosa": "ஓசா",
"Yiddish": "யெட்டிச்",
"Yoruba": "யோருபா",
"Top": "மேலே",
"About": "பற்றி",
"View as playlist": "பிளேலிச்ட்டாக காண்க",
"Gaming": "கேமிங்",
"News": "செய்தி",
"Movies": "திரைப்படங்கள்",
"Download as: ": "என பதிவிறக்கவும்: ",
"Download is disabled": "பதிவிறக்கம் முடக்கப்பட்டுள்ளது",
"(edited)": "(திருத்தப்பட்டது)",
"YouTube comment permalink": "YouTube கருத்து பெர்மாலின்க்",
"`x` marked it with a ❤": "`x` அதை a உடன் குறித்தது",
"Video mode": "வீடியோ பயன்முறை",
"Playlists": "பிளேலிச்ட்கள்",
"search_filters_date_option_today": "இன்று",
"search_filters_date_option_week": "இந்த வாரம்",
"search_filters_date_option_month": "இந்த மாதம்",
"search_filters_type_option_channel": "வாய்க்கால்",
"search_filters_type_option_playlist": "பிளேலிச்ட்",
"search_filters_duration_label": "காலம்",
"search_filters_duration_option_none": "எந்த காலமும்",
"search_filters_duration_option_medium": "நடுத்தர (4 - 20 நிமிடங்கள்)",
"search_filters_duration_option_long": "நீண்ட (> 20 நிமிடங்கள்)",
"search_filters_features_label": "நற்பொருத்தங்கள்",
"search_filters_features_option_four_k": "எச்.சி.",
"search_filters_features_option_live": "நேரடி",
"search_filters_features_option_hd": "எச்டி",
"search_filters_features_option_subtitles": "வசன வரிகள்/சிசி",
"search_filters_features_option_c_commons": "கிரியேட்டிவ் காமன்ச்",
"search_filters_features_option_three_sixty": "360 °",
"search_filters_features_option_three_d": "ZD",
"search_filters_features_option_hdr": "எச்.டி.ஆர்",
"search_filters_features_option_location": "இடம்",
"search_filters_sort_option_relevance": "பொருத்தமானது",
"search_filters_sort_option_rating": "செயல்வரம்பு",
"Current version: ": "தற்போதைய பதிப்பு: ",
"next_steps_error_message": "அதன் பிறகு நீங்கள் முயற்சி செய்ய வேண்டும்: ",
"next_steps_error_message_refresh": "புதுப்பிப்பு",
"next_steps_error_message_go_to_youtube": "YouTube க்குச் செல்லுங்கள்",
"footer_donate_page": "நன்கொடை",
"footer_modfied_source_code": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு",
"adminprefs_modified_source_code_url_label": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு களஞ்சியத்திற்கு முகவரி",
"videoinfo_started_streaming_x_ago": "`X` முன்பு ச்ட்ரீமிங் செய்யத் தொடங்கியது",
"videoinfo_watch_on_youTube": "YouTube இல் பாருங்கள்",
"download_subtitles": "வசன வரிகள் - `x` (.vtt)",
"user_created_playlists": "`x` உருவாக்கியது பிளேலிச்ட்கள்",
"user_saved_playlists": "`x` சேமித்த பிளேலிச்ட்கள்",
"crash_page_before_reporting": "ஒரு பிழையைப் புகாரளிப்பதற்கு முன், உங்களிடம் இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்:",
"crash_page_switch_instance": "<a href = \"` x` \"> மற்றொரு நிகழ்வைப் பயன்படுத்த முயற்சித்தேன் </a>",
"crash_page_search_issue": "அறிவிலிமையத்தில் உள்ள <a href=\"`x`\"> தற்போதைய சிக்கல்களைத் தேடியது</a>",
"channel_tab_shorts_label": "குறுக்குகள்",
"channel_tab_streams_label": "லைவ்ச்ட்ரீம்கள்",
"carousel_go_to": "`X` ச்லைடு செல்லவும்",
"Popular": "புகழ்பெற்ற",
"Subscribe": "குழுசேர்",
"View channel on YouTube": "YouTube இல் சேனலைக் காண்க",
"Authorize token for `x`?": "`X` க்கு கிள்ளாக்கை அங்கீகரிக்கவா?",
"No": "இல்லை",
"Add to playlist: ": "பிளேலிச்ட்டில் சேர்க்கவும்: ",
"Answer": "பதில்",
"Search for videos": "வீடியோக்களைத் தேடுங்கள்",
"The Popular feed has been disabled by the administrator.": "பிரபலமான ஊட்டத்தை நிர்வாகியால் முடக்கப்பட்டுள்ளது.",
"generic_subscriptions_count": "{{count}} சந்தா",
"generic_subscriptions_count_plural": "{{count}} சந்தாக்கள்",
"generic_button_edit": "தொகு",
"generic_button_save": "சேமி",
"generic_button_cancel": "ரத்துசெய்",
"Import and Export Data": "தரவை இறக்குமதி செய்து ஏற்றுமதி செய்யுங்கள்",
"Import": "இறக்குமதி",
"Import NewPipe subscriptions (.json)": "நியூபிப்பிப் சந்தாக்களை இறக்குமதி செய்யுங்கள் (.json)",
"Export": "ஏற்றுமதி",
"Text CAPTCHA": "உரை கேப்ட்சா",
"Image CAPTCHA": "பட கேப்ட்சா",
"preferences_category_player": "பிளேயர் விருப்பத்தேர்வுகள்",
"preferences_video_loop_label": "எப்போதும் லூப்: ",
"preferences_continue_autoplay_label": "தன்னியக்க அடுத்த வீடியோ: ",
"preferences_listen_label": "இயல்பாக கேளுங்கள்: ",
"preferences_quality_option_dash": "கோடு (தகவமைப்பு தரம்)",
"preferences_quality_option_hd720": "HD720",
"preferences_quality_option_medium": "சராசரி",
"preferences_quality_option_small": "சிறிய",
"preferences_quality_dash_option_2160p": "2160 ப",
"preferences_quality_dash_option_1440p": "1440 ப",
"preferences_quality_dash_option_240p": "240 ப",
"youtube": "YouTube",
"reddit": "ரெடிட்",
"invidious": "வெகுவாக",
"preferences_extend_desc_label": "வீடியோ விளக்கத்தை தானாக நீட்டிக்கவும்: ",
"preferences_region_label": "உள்ளடக்க நாடு: ",
"preferences_player_style_label": "பிளேயர் ச்டைல்: ",
"Dark mode: ": "இருண்ட முறை: ",
"preferences_dark_mode_label": "தீம்: ",
"dark": "இருண்ட",
"preferences_automatic_instance_redirect_label": "தானியங்கி நிகழ்வு திசைதிருப்பல் (redirect.invidious.io க்கு குறைவடையும்): ",
"preferences_max_results_label": "ஊட்டத்தில் காட்டப்பட்டுள்ள வீடியோக்களின் எண்ணிக்கை: ",
"alphabetically - reverse": "அகரவரிசை - தலைகீழ்",
"channel name": "சேனல் பெயர்",
"channel name - reverse": "சேனல் பெயர் - தலைகீழ்",
"Only show latest video from channel: ": "சேனலில் இருந்து அண்மைக் கால வீடியோவைக் காட்டுங்கள்: ",
"Only show latest unwatched video from channel: ": "சேனலில் இருந்து அண்மைக் கால கவனிக்கப்படாத வீடியோவைக் காட்டுங்கள்: ",
"`x` uploaded a video": "`x` ஒரு வீடியோவைப் பதிவேற்றியது",
"Clear watch history": "தெளிவான கண்காணிப்பு வரலாறு",
"Log out": "விடுபதிகை",
"Source available here.": "சான்று இங்கே கிடைக்கிறது.",
"Delete playlist": "பிளேலிச்ட்டை நீக்கு",
"Create playlist": "பிளேலிச்ட்டை உருவாக்கவும்",
"Title": "தலைப்பு",
"Import/export data": "தரவு இறக்குமதி/ஏற்றுமதி",
"Change password": "கடவுச்சொல்லை மாற்றவும்",
"Manage tokens": "டோக்கன்களை நிர்வகிக்கவும்",
"Popular enabled: ": "பிரபலமான இயக்கப்பட்டது: ",
"tokens_count": "{{count}} கிள்ளாக்கு",
"tokens_count_plural": "{{count}} டோக்கன்கள்",
"Import/export": "இறக்குமதி/ஏற்றுமதி",
"unsubscribe": "குழுவிலகவும்",
"revoke": "ரத்து செய்யுங்கள்",
"Subscriptions": "சந்தாக்கள்",
"subscriptions_unseen_notifs_count": "{{count}} காணப்படாத அறிவிப்பு",
"subscriptions_unseen_notifs_count_plural": "{{count}} காணப்படாத அறிவிப்புகள்",
"Editing playlist `x`": "பிளேலிச்ட்டைத் திருத்துதல் `x`",
"playlist_button_add_items": "வீடியோக்களைச் சேர்க்கவும்",
"Show more": "மேலும் காட்டு",
"Show less": "குறைவாகக் காட்டு",
"Switch Invidious Instance": "அக்யோர்ட் உதாரணத்தை மாற்றவும்",
"search_message_no_results": "முடிவுகள் எதுவும் கிடைக்கவில்லை.",
"search_message_change_filters_or_query": "உங்கள் தேடல் வினவலை அகலப்படுத்த முயற்சிக்கவும்/அல்லது வடிப்பான்களை மாற்றவும்.",
"search_message_use_another_instance": "நீங்கள் <a href = \"` x` \"> மற்றொரு நிகழ்வில் தேடலாம் </a>.",
"Show annotations": "சிறுகுறிப்புகளைக் காட்டு",
"Genre: ": "வகை: ",
"License: ": "உரிமம்: ",
"Standard YouTube license": "நிலையான YouTube உரிமம்",
"Family friendly? ": "குடும்ப நட்பு? ",
"Wilson score: ": "வில்சன் மதிப்பெண்: ",
"Engagement: ": "நிச்சயதார்த்தம்: ",
"Whitelisted regions: ": "அனுமதிப்பட்டிய பகுதிகள்: ",
"Blacklisted regions: ": "தடுப்புப்பட்டியாக்கப்பட்ட பகுதிகள்: ",
"Music in this video": "இந்த வீடியோவில் இசை",
"Artist: ": "கலைஞர்: ",
"Song: ": "பாடல்: ",
"Album: ": "ஆல்பம்: ",
"Shared `x`": "பகிரப்பட்டது `x`",
"Premieres in `x`": "`X` இல் பிரீமியர்ச்",
"Premieres `x`": "பிரீமியர்ச் `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "ஆய்! நீங்கள் சாவாச்கிரிப்ட் முடக்கப்பட்டிருப்பது போல் தெரிகிறது. கருத்துகளைக் காண இங்கே சொடுக்கு செய்க, அவர்கள் ஏற்றுவதற்கு சிறிது நேரம் ஆகலாம் என்பதை நினைவில் கொள்ளுங்கள்.",
"View YouTube comments": "YouTube கருத்துகளைக் காண்க",
"View more comments on Reddit": "ரெடிட் குறித்த கூடுதல் கருத்துகளைக் காண்க",
"View `x` comments": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`X` கருத்தைக் காண்க",
"": "`X` கருத்துகளைக் காண்க"
},
"View Reddit comments": "ரெடிட் கருத்துகளைக் காண்க",
"Hide replies": "பதில்களை மறைக்கவும்",
"Wrong username or password": "தவறான பயனர்பெயர் அல்லது கடவுச்சொல்",
"Password cannot be longer than 55 characters": "கடவுச்சொல் 55 எழுத்துகளை விட நீளமாக இருக்க முடியாது",
"Invidious Private Feed for `x`": "`X` க்கான மோசமான தனியார் ஊட்டம்",
"channel:`x`": "சேனல்: `x`",
"Deleted or invalid channel": "நீக்கப்பட்ட அல்லது தவறான சேனல்",
"comments_view_x_replies": "{{count}} பதிலைக் காண்க",
"comments_view_x_replies_plural": "{{count}} பதில்களைக் காண்க",
"`x` ago": "`x` முன்பு",
"Load more": "மேலும் ஏற்றவும்",
"Hidden field \"challenge\" is a required field": "மறைக்கப்பட்ட புலம் \"அறைகூவல்\" என்பது தேவையான புலம்",
"Hidden field \"token\" is a required field": "மறைக்கப்பட்ட புலம் \"கிள்ளாக்கு\" என்பது தேவையான புலம்",
"Corsican": "கார்சிகன்",
"Croatian": "குரோசியன்",
"Czech": "செக்",
"Danish": "டேனிச்",
"Dutch (auto-generated)": "டச்சு (தானாக உருவாக்கப்பட்ட)",
"Esperanto": "எச்பெராண்டோ",
"Estonian": "எச்டோனிய",
"Filipino": "ஃபிலிபினோ",
"Filipino (auto-generated)": "பிலிப்பைன்ச் (தானாக உருவாக்கிய)",
"French (auto-generated)": "பிரஞ்சு (தானாக உருவாக்கப்பட்ட)",
"Galician": "காலிசியன்",
"Georgian": "சார்சியன்",
"German": "செர்மன்",
"Hausa": "ஔசா",
"Lao": "லாவோ",
"Latin": "லத்தீன்",
"Latvian": "லாட்வியன்",
"Hawaiian": "அவாயியன்",
"Hebrew": "எபிரேய",
"Lithuanian": "லிதுவேனியன்",
"Hindi": "இந்தி",
"Hmong": "அமோங்",
"Indonesian": "இந்தோனேசிய",
"Indonesian (auto-generated)": "இந்தோனேசிய (தானாக உருவாக்கப்பட்ட)",
"Interlingue": "இன்டர்லின்குய்",
"Irish": "ஐரிச்",
"Italian": "இத்தாலிய",
"Italian (auto-generated)": "இத்தாலியன் (தானாக உருவாக்கப்பட்ட)",
"Japanese": "சப்பானியர்கள்",
"Japanese (auto-generated)": "சப்பானிய (தானாக உருவாக்கப்பட்ட)",
"Javanese": "சாவானீச்",
"Kannada": "கன்னடா",
"Kazakh": "கசாக்",
"Khmer": "கெமர்",
"Korean": "கொரிய",
"Kurdish": "குர்திச்",
"Kyrgyz": "கிர்கிச்",
"Luxembourgish": "லக்சம்போர்கிச்",
"Malay": "மலாய்",
"Pashto": "பச்தோ",
"Persian": "பெர்சியன்",
"Polish": "போலீச்",
"Portuguese": "போர்த்துகீசியம்",
"Portuguese (auto-generated)": "போர்த்துகீசியம் (தானாக உருவாக்கிய)",
"generic_count_minutes": "{{count}} மணித்துளி",
"generic_count_minutes_plural": "{{count}} நிமிடங்கள்",
"generic_count_seconds": "{{count}} இரண்டாவது",
"generic_count_seconds_plural": "{{count}} வினாடிகள்",
"Fallback comments: ": "குறைவடையும் கருத்துரைகள்: ",
"Portuguese (Brazil)": "போர்த்துகீசியம் (பிரேசில்)",
"Punjabi": "பஞ்சாபி",
"Romanian": "ருமேனிய",
"Sinhala": "சிங்களம்",
"Slovak": "ச்லோவாக்",
"Slovenian": "ச்லோவேனியன்",
"Spanish (Latin America)": "ச்பானிச் (லத்தீன் அமெரிக்கா)",
"Spanish (Mexico)": "ச்பானிச் (மெக்சிகோ)",
"Spanish (Spain)": "ச்பானிச் (ச்பெயின்)",
"Telugu": "தெலுங்கு",
"Turkish (auto-generated)": "துருக்கிய (தானாக உருவாக்கிய)",
"Ukrainian": "உக்ரேனிய",
"Urdu": "உருது",
"Uzbek": "உச்பெக்",
"Vietnamese (auto-generated)": "வியட்நாமிய (தானாக உருவாக்கப்பட்ட)",
"Western Frisian": "மேற்கு ஃபிரிசியன்",
"Zulu": "சுலு",
"generic_count_years": "{{count}}} ஆண்டு",
"generic_count_years_plural": "{{count}} ஆண்டுகள்",
"generic_count_months": "{{count}} மாதம்",
"generic_count_months_plural": "{{count}} மாதங்கள்",
"generic_count_weeks": "{{count}}} வாரம்",
"generic_count_weeks_plural": "{{count}} வாரங்கள்",
"generic_count_days": "{{count}}} நாள்",
"generic_count_days_plural": "{{count}} நாட்கள்",
"generic_count_hours": "{{count}} மணிநேரம்",
"generic_count_hours_plural": "{{count}} மணிநேரம்",
"Search": "தேடல்",
"Rating: ": "மதிப்பீடு: ",
"preferences_locale_label": "மொழி: ",
"Default": "இயல்புநிலை",
"Music": "இசை",
"Download": "பதிவிறக்கம்",
"%A %B %-d, %Y": "%A %b %-d, %y",
"permalink": "பெர்மாலின்க்",
"Channel Sponsor": "சேனல் ஒப்புரவாளர்",
"Audio mode": "ஆடியோ பயன்முறை",
"search_filters_duration_option_short": "குறுகிய (<4 நிமிடங்கள்)",
"search_filters_title": "வடிப்பான்கள்",
"search_filters_date_label": "தேதி பதிவேற்றும் தேதி",
"search_filters_date_option_none": "எந்த தேதி",
"search_filters_date_option_hour": "கடைசி மணி",
"search_filters_date_option_year": "இந்த ஆண்டு",
"search_filters_type_label": "வகை",
"search_filters_type_option_all": "எந்த வகை",
"search_filters_type_option_video": "ஒளிதோற்றம்",
"search_filters_type_option_movie": "படம்",
"search_filters_type_option_show": "காட்டு",
"search_filters_features_option_vr180": "VR180",
"search_filters_features_option_purchased": "வாங்கப்பட்டது",
"search_filters_sort_label": "வரிசைப்படுத்தவும்",
"search_filters_sort_option_date": "பதிவேற்ற தேதி",
"search_filters_sort_option_views": "எண்ணிக்கை காண்க",
"search_filters_apply_button": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்களைப் பயன்படுத்துங்கள்",
"footer_documentation": "ஆவணப்படுத்துதல்",
"footer_source_code": "மூலக் குறியீடு",
"footer_original_source_code": "அசல் மூலக் குறியீடு",
"none": "எதுவுமில்லை",
"videoinfo_youTube_embed_link": "உட்பொதிக்கப்பட்டது",
"videoinfo_invidious_embed_link": "உட்பொதிப்பு இணைப்பு",
"Video unavailable": "வீடியோ கிடைக்கவில்லை",
"preferences_save_player_pos_label": "பிளேபேக் நிலையை சேமிக்கவும்: ",
"crash_page_you_found_a_bug": "நீங்கள் ஒரு பிழையை கண்டுபிடித்ததாகத் தெரிகிறது!",
"crash_page_refresh": "<a href = \"` x` \"> பக்கத்தை புதுப்பிக்க முயற்சித்தேன் </a>",
"crash_page_read_the_faq": "<a href = \"` x` \"> அடிக்கடி கேட்கப்படும் கேள்விகள் (கேள்விகள்) </a> ஐப் படியுங்கள்",
"crash_page_report_issue": "மேலே எதுவும் உதவவில்லை என்றால், தயவுசெய்து <a href = \"` x` \"> அறிவிலிமையம் </a> (முன்னுரிமை ஆங்கிலத்தில்) ஒரு புதிய சிக்கலைத் திறந்து உங்கள் செய்தியில் பின்வரும் உரையைச் சேர்க்கவும் (அந்த உரையை மொழிபெயர்க்க வேண்டாம்):",
"error_video_not_in_playlist": "கோரப்பட்ட வீடியோ இந்த பிளேலிச்ட்டில் இல்லை. <a href = \"` x` \"> பிளேலிச்ட் முகப்பு பக்கத்திற்கு இங்கே சொடுக்கு செய்க. </a>",
"channel_tab_videos_label": "வீடியோக்கள்",
"channel_tab_podcasts_label": "பாட்காச்ட்கள்",
"channel_tab_releases_label": "வெளியீடுகள்",
"channel_tab_playlists_label": "பிளேலிச்ட்கள்",
"channel_tab_community_label": "சமூகம்",
"channel_tab_channels_label": "சேனல்கள்",
"toggle_theme": "கருப்பொருளை மாற்றவும்",
"carousel_slide": "{{total}} இன் ச்லைடு {{current}}",
"carousel_skip": "கொணர்வி தவிர்க்கவும்"
}

View file

@ -1 +0,0 @@
{}

View file

@ -496,6 +496,5 @@
"carousel_slide": "Sunum {{current}} / {{total}}", "carousel_slide": "Sunum {{current}} / {{total}}",
"carousel_skip": "Kayar menüyü atla", "carousel_skip": "Kayar menüyü atla",
"carousel_go_to": "`x` sunumuna git", "carousel_go_to": "`x` sunumuna git",
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.", "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
"preferences_preload_label": "Video verilerini önceden yükle: "
} }

View file

@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.", "The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
"carousel_slide": "Слайд {{current}} з {{total}}", "carousel_slide": "Слайд {{current}} з {{total}}",
"carousel_skip": "Пропустити карусель", "carousel_skip": "Пропустити карусель",
"carousel_go_to": "Перейти до слайда `x`", "carousel_go_to": "Перейти до слайда `x`"
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
} }

View file

@ -479,7 +479,5 @@
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。", "The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图", "carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
"carousel_skip": "跳过图集", "carousel_skip": "跳过图集",
"carousel_go_to": "转到图 `x`", "carousel_go_to": "转到图 `x`"
"preferences_preload_label": "预加载视频数据: ",
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
} }

View file

@ -479,7 +479,5 @@
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張", "carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
"carousel_skip": "略過輪播", "carousel_skip": "略過輪播",
"carousel_go_to": "跳到投影片 `x`", "carousel_go_to": "跳到投影片 `x`",
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。", "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
"preferences_preload_label": "預先載入影片資訊 ",
"Filipino (auto-generated)": "菲律賓語(自動產生)"
} }

View file

@ -1,97 +0,0 @@
require "http"
require "colorize"
ESBUILD_VERSION = "0.25.0"
esbuild_os = ""
esbuild_arch = ""
# https://esbuild.github.io/getting-started/#other-ways-to-install
{% if flag?(:linux) %}
esbuild_os = "linux"
{% elsif flag?(:windows) %}
esbuild_os = "win32"
{% elsif flag?(:darwin) %}
esbuild_os = "darwin"
{% elsif flag?(:freebsd) %}
esbuild_os = "freebsd"
{% elsif flag?(:openbsd) %}
esbuild_os = "openbsd"
{% elsif flag?(:netbsd) %}
esbuild_os = "netbsd"
{% elsif flag?(:solaris) %}
esbuild_os = "sunos"
{% else %}
esbuild_os = "linux"
{% end %}
{% if flag?(:x86_64) %}
esbuild_arch = "x64"
{% elsif flag?(:arm64) %}
esbuild_arch = "arm64"
{% else %}
esbuild_arch = "x64"
{% end %}
tmp_dir_path = "#{Dir.tempdir}/invidious-esbuild-binary"
esbuild_tar_location = "#{tmp_dir_path}/esbuild-#{esbuild_os}-#{esbuild_os}-#{ESBUILD_VERSION}.tgz"
esbuild_binary_location = "#{tmp_dir_path}/package/bin/esbuild"
Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path
esbuild_url = "https://registry.npmjs.org/@esbuild/#{esbuild_os}-#{esbuild_arch}/-/#{esbuild_os}-#{esbuild_arch}-#{ESBUILD_VERSION}.tgz"
# Download esbuild binary
HTTP::Client.get(esbuild_url) do |response|
puts "Downloading esbuild from #{esbuild_url}"
data = response.body_io.gets_to_end
File.write(esbuild_tar_location, data)
`tar -vzxf '#{esbuild_tar_location}' -C '#{tmp_dir_path}'`
raise "Extraction for #{esbuild_tar_location} failed" if !$?.success?
puts "esbuild downloaded successfully"
end
files_to_minify = [
"_helpers.js",
"comments.js",
"embed.js",
"handlers.js",
"notifications.js",
"pagination.js",
"playlist_widget.js",
"post.js",
"sse.js",
"subscribe_widget.js",
"themes.js",
"watch.js",
"player.js",
"watched_indicator.js",
"watched_widget.js",
]
files_to_minify.each do |file|
file_path = "assets/js/#{file}"
outdir = "assets/js/minified"
process_output = IO::Memory.new
process = Process.run("#{esbuild_binary_location}", error: process_output, args: [
file_path,
"--color=false",
"--sourcemap",
"--minify",
"--outdir=#{outdir}",
]
)
if process.success?
puts "Minified #{file}".colorize(:green)
elsif !process.success?
puts "Failed to minify #{file}, esbuild exited with exit code #{process.exit_code}: #{process_output.to_s.split("\n").first}".colorize(:red)
raise Exception.new("All files have to be minified sucessfully in order to compile!")
end
end
puts "Minify done!"
# Cleanup
`rm -rf #{tmp_dir_path}`

View file

@ -2,39 +2,46 @@ version: 2.0
shards: shards:
ameba: ameba:
git: https://github.com/crystal-ameba/ameba.git git: https://github.com/crystal-ameba/ameba.git
version: 1.6.4 version: 1.6.1
athena-negotiation: athena-negotiation:
git: https://github.com/athena-framework/negotiation.git git: https://github.com/athena-framework/negotiation.git
version: 0.1.5 version: 0.1.1
backtracer: backtracer:
git: https://github.com/sija/backtracer.cr.git git: https://github.com/sija/backtracer.cr.git
version: 1.2.4 version: 1.2.2
db: db:
git: https://github.com/crystal-lang/crystal-db.git git: https://github.com/crystal-lang/crystal-db.git
version: 0.13.1 version: 0.10.1
exception_page: exception_page:
git: https://github.com/crystal-loot/exception_page.git git: https://github.com/crystal-loot/exception_page.git
version: 0.4.1 version: 0.2.2
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.10.3
inotify: inotify:
git: https://github.com/petoem/inotify.cr.git git: https://github.com/petoem/inotify.cr.git
version: 1.0.3 version: 1.0.3
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.10.3
kemal: kemal:
git: https://github.com/kemalcr/kemal.git git: https://github.com/kemalcr/kemal.git
version: 1.6.0 version: 1.1.2
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.6.1
pg: pg:
git: https://github.com/will/crystal-pg.git git: https://github.com/will/crystal-pg.git
version: 0.28.0 version: 0.24.0
pool:
git: https://github.com/ysbaddaden/pool.git
version: 0.2.4
protodec: protodec:
git: https://github.com/iv-org/protodec.git git: https://github.com/iv-org/protodec.git
@ -45,8 +52,8 @@ shards:
version: 0.4.1 version: 0.4.1
redis: redis:
git: https://github.com/jgaskins/redis.git git: https://github.com/stefanwille/crystal-redis.git
version: 0.12.0 version: 2.9.1
spectator: spectator:
git: https://github.com/icy-arctic-fox/spectator.git git: https://github.com/icy-arctic-fox/spectator.git
@ -54,5 +61,5 @@ shards:
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.21.0 version: 0.18.0

View file

@ -1,23 +1,27 @@
name: invidious name: invidious
version: 2.20250314.0-dev version: 0.20.1
authors: authors:
- Invidious team <contact@invidious.io> - Omar Roth <omarroth@protonmail.com>
- Contributors! - Invidious team
description: | targets:
Invidious is an alternative front-end to YouTube invidious:
main: src/invidious.cr
dependencies: dependencies:
pg: pg:
github: will/crystal-pg github: will/crystal-pg
version: ~> 0.28.0 version: ~> 0.24.0
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: ~> 0.21.0 version: ~> 0.18.0
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 1.6.0 version: ~> 1.1.2
kilt:
github: jeromegn/kilt
version: ~> 0.6.1
protodec: protodec:
github: iv-org/protodec github: iv-org/protodec
version: ~> 0.1.5 version: ~> 0.1.5
@ -25,7 +29,7 @@ dependencies:
github: athena-framework/negotiation github: athena-framework/negotiation
version: ~> 0.1.1 version: ~> 0.1.1
redis: redis:
github: jgaskins/redis github: stefanwille/crystal-redis
inotify: inotify:
github: petoem/inotify.cr github: petoem/inotify.cr
version: 1.0.3 version: 1.0.3
@ -41,10 +45,6 @@ development_dependencies:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 1.6.1 version: ~> 1.6.1
crystal: ">= 1.10.0, < 2.0.0" crystal: ">= 1.0.0, < 2.0.0"
license: AGPLv3 license: AGPLv3
repository: https://github.com/iv-org/invidious
homepage: https://invidious.io
documentation: https://docs.invidious.io

View file

@ -0,0 +1,16 @@
# Overrides for Kemal's `content_for` macro in order to keep using
# kilt as it was before Kemal v1.1.1 (Kemal PR #618).
require "kemal"
require "kilt"
macro content_for(key, file = __FILE__)
%proc = ->() {
__kilt_io__ = IO::Memory.new
{{ yield }}
__kilt_io__.to_s
}
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc
nil
end

View file

@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt
filesize = data.bytesize filesize = data.bytesize
attachment(env, filename, disposition) attachment(env, filename, disposition)
Kemal.config.static_headers.try(&.call(env, file_path, filestat)) Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
file = IO::Memory.new(data) file = IO::Memory.new(data)
if env.request.method == "GET" && env.request.headers.has_key?("Range") if env.request.method == "GET" && env.request.headers.has_key?("Range")

View file

@ -17,8 +17,10 @@
require "digest/md5" require "digest/md5"
require "file_utils" require "file_utils"
# Require kemal, then our own overrides # Require kemal, kilt, then our own overrides
require "kemal" require "kemal"
require "kilt"
require "./ext/kemal_content_for.cr"
require "./ext/kemal_static_file_handler.cr" require "./ext/kemal_static_file_handler.cr"
require "http_proxy" require "http_proxy"
@ -49,8 +51,7 @@ require "./invidious/channels/*"
require "./invidious/user/*" require "./invidious/user/*"
require "./invidious/search/*" require "./invidious/search/*"
require "./invidious/routes/**" require "./invidious/routes/**"
require "./invidious/jobs/base_job" require "./invidious/jobs/**"
require "./invidious/jobs/*"
# Declare the base namespace for invidious # Declare the base namespace for invidious
module Invidious module Invidious
@ -75,14 +76,19 @@ end
HMAC_KEY = CONFIG.hmac_key HMAC_KEY = CONFIG.hmac_key
PG_DB = DB.open CONFIG.database_url PG_DB = DB.open CONFIG.database_url
REDIS_DB = Redis::PooledClient.new(unixsocket: CONFIG.redis_socket || nil, url: CONFIG.redis_url || nil)
if REDIS_DB.ping
puts "Connected to redis"
end
ARCHIVE_URL = URI.parse("https://archive.org") ARCHIVE_URL = URI.parse("https://archive.org")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com") YT_URL = URI.parse("https://www.youtube.com")
PUBSUB_HOST_URL = CONFIG.pubsub_domain PUBSUB_HOST_URL = CONFIG.pubsub_domain
HOST_URL = make_host_url(Kemal.config) HOST_URL = make_host_url(Kemal.config)
EXT_VIDEOP_LIST = gen_videoplayback_proxy_list()
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@ -113,12 +119,6 @@ YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
COMPANION_POOL = [] of CompanionConnectionPool
CONFIG.invidious_companion.each do |companion|
COMPANION_POOL << CompanionConnectionPool.new(companion, capacity: CONFIG.pool_size)
end
# CLI # CLI
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
@ -160,15 +160,6 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_log
# Check table integrity # Check table integrity
Invidious::Database.check_integrity(CONFIG) Invidious::Database.check_integrity(CONFIG)
# Minifies Invidious Javascript
{% if flag?(:minify_debug) || (flag?(:release) || flag?(:production)) && !flag?(:skip_minified_js) %}
{% puts "\nMinifying Invidious JavaScript\n" %}
{% puts run("../scripts/minify-js.cr").stringify %}
JS_PATH="js/minified"
{% else %}
JS_PATH="js"
{% end %}
{% if !flag?(:skip_videojs_download) %} {% if !flag?(:skip_videojs_download) %}
# Resolve player dependencies. This is done at compile time. # Resolve player dependencies. This is done at compile time.
# #
@ -211,24 +202,19 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32) CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
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::ClearExpiredItemsJob.new
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
if !CONFIG.tokens_server.empty? if !CONFIG.external_videoplayback_proxy.empty?
Invidious::Jobs.register Invidious::Jobs::RefreshSessionTokens.new Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
else
LOGGER.info("jobs: Disabling RefreshSessionTokens job. Invidious will use the tokens that are on the configuration file")
end end
if CONFIG.invidious_companion.present? if CONFIG.refresh_tokens
Invidious::Jobs.register Invidious::Jobs::CheckBackend.new Invidious::Jobs.register Invidious::Jobs::RefreshTokens.new
else
LOGGER.info("jobs: Disabling CheckBackend job. invidious-companion and their respective external video playback proxies (if set on invidious-companion) will not be checked")
end end
Invidious::Jobs.start_all Invidious::Jobs.start_all
@ -253,8 +239,8 @@ error 500 do |env, ex|
error_template(500, ex) error_template(500, ex)
end end
static_headers do |env| static_headers do |response|
env.response.headers.add("Cache-Control", "max-age=2629800") response.headers.add("Cache-Control", "max-age=2629800")
end end
# Init Kemal # Init Kemal
@ -271,6 +257,8 @@ add_context_storage_type(Preferences)
add_context_storage_type(Invidious::User) add_context_storage_type(Invidious::User)
Kemal.config.logger = LOGGER Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
Kemal.config.app_name = "Invidious" Kemal.config.app_name = "Invidious"
# Use in kemal's production mode. # Use in kemal's production mode.
@ -279,16 +267,4 @@ Kemal.config.app_name = "Invidious"
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
{% end %} {% end %}
Kemal.run do |config| Kemal.run
if socket_binding = CONFIG.socket_binding
File.delete?(socket_binding.path)
# Create a socket and set its desired permissions
server = UNIXServer.new(socket_binding.path)
perms = socket_binding.permissions.to_i(base: 8)
File.chmod(socket_binding.path, perms)
config.server.not_nil!.bind server
else
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
end
end

View file

@ -249,7 +249,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if was_insert if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
# else
# Invidious::Database::Users.feed_needs_update(video)
end
else else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end end
@ -281,7 +285,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if Time.utc - video.published > 1.minute if Time.utc - video.published > 1.minute
was_insert = Invidious::Database::ChannelVideos.insert(video) was_insert = Invidious::Database::ChannelVideos.insert(video)
if was_insert if was_insert
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
# else
# Invidious::Database::Users.feed_needs_update(video)
end
end end
end end
end end

View file

@ -44,12 +44,3 @@ def fetch_channel_releases(ucid, author, continuation)
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end end
def fetch_channel_courses(ucid, author, continuation)
if continuation
initial_data = YoutubeAPI.browse(continuation)
else
initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
end
return extract_items(initial_data, author, ucid)
end

View file

@ -1,3 +1,78 @@
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 module Invidious::Channel::Tabs
extend self extend self
@ -26,7 +101,7 @@ module Invidious::Channel::Tabs
end end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_videos_ctoken(ucid, sort_by) continuation ||= make_initial_content_ctoken(ucid, "videos", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation) initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
@ -55,10 +130,14 @@ module Invidious::Channel::Tabs
# Shorts # Shorts
# ------------------- # -------------------
def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") def get_shorts(channel : AboutChannel, continuation : String? = nil)
continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by) if continuation.nil?
initial_data = YoutubeAPI.browse(continuation: continuation) # 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
return extract_items(initial_data, channel.author, channel.ucid) return extract_items(initial_data, channel.author, channel.ucid)
end end
@ -66,8 +145,9 @@ module Invidious::Channel::Tabs
# Livestreams # Livestreams
# ------------------- # -------------------
def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") def get_livestreams(channel : AboutChannel, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by) continuation ||= make_initial_content_ctoken(channel.ucid, "livestreams", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation) initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, channel.author, channel.ucid) return extract_items(initial_data, channel.author, channel.ucid)
@ -91,102 +171,4 @@ module Invidious::Channel::Tabs
return items, next_continuation return items, next_continuation
end 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 end

View file

@ -8,13 +8,6 @@ struct DBConfig
property dbname : String property dbname : String
end end
struct SocketBindingConfig
include YAML::Serializable
property path : String
property permissions : String
end
struct ConfigPreferences struct ConfigPreferences
include YAML::Serializable include YAML::Serializable
@ -52,7 +45,8 @@ struct ConfigPreferences
property vr_mode : Bool = true property vr_mode : Bool = true
property show_nick : Bool = true property show_nick : Bool = true
property save_player_pos : Bool = false property save_player_pos : Bool = false
property enable_dearrow : Bool = false property po_token : String = ""
property visitor_data : String = ""
def to_tuple def to_tuple
{% begin %} {% begin %}
@ -75,22 +69,6 @@ end
class Config class Config
include YAML::Serializable include YAML::Serializable
class CompanionConfig
include YAML::Serializable
@[YAML::Field(converter: Preferences::URIConverter)]
property private_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)]
property i2p_public_url : URI = URI.parse("")
property note : String = ""
property domain : Array(String) = [] of String
end
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1 property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update). # Time interval between two executions of the job that crawls channel videos (subscriptions update).
@ -108,8 +86,8 @@ class Config
# Database configuration using 12-Factor "Database URL" syntax # Database configuration using 12-Factor "Database URL" syntax
@[YAML::Field(converter: Preferences::URIConverter)] @[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("") property database_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)] property redis_url : String?
property redis_url : URI = URI.parse("") property redis_socket : String?
# Use polling to keep decryption function up to date # Use polling to keep decryption function up to date
property decrypt_polling : Bool = false property decrypt_polling : Bool = false
# Used for crawling channels: threads should check all videos uploaded by a channel # Used for crawling channels: threads should check all videos uploaded by a channel
@ -128,10 +106,14 @@ class Config
property domain : String? property domain : String?
# Materialious redirects # Materialious redirects
property materialious_domain : String? property materialious_domain : String?
# Alternative domains. You can add other domains, like TOR and I2P addresses
property alternative_domains : Array(String) = [] of String
property donation_url : String?
property contact_url : String?
property home_domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key) # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false property use_pubsub_feeds : Bool | Int32 = false
property use_innertube_for_feeds : Bool = true
property popular_enabled : Bool = true property popular_enabled : Bool = true
property captcha_enabled : Bool = true property captcha_enabled : Bool = true
property login_enabled : Bool = true property login_enabled : Bool = true
@ -182,8 +164,6 @@ class Config
property port : Int32 = 3000 property port : Int32 = 3000
# Host to bind (overridden by command line argument) # Host to bind (overridden by command line argument)
property host_binding : String = "0.0.0.0" property host_binding : String = "0.0.0.0"
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
property socket_binding : SocketBindingConfig? = nil
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) # 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 property pool_size : Int32 = 100
# HTTP Proxy configuration # HTTP Proxy configuration
@ -197,12 +177,6 @@ class Config
# poToken for passing bot attestation # poToken for passing bot attestation
property po_token : String? = nil property po_token : String? = nil
# Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig
# Invidious companion API key
property invidious_companion_key : String = ""
# Saved cookies in "name1=value1; name2=value2..." format # Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new property cookies : HTTP::Cookies = HTTP::Cookies.new
@ -219,30 +193,16 @@ class Config
# of the backend # of the backend
property backends_delimiter : String = "|" property backends_delimiter : String = "|"
# External videoplayback proxies list. They should include `https://`
# at the start of the URI
property external_videoplayback_proxy : Array(NamedTuple(url: String, balance: Bool)) = [] of NamedTuple(url: String, balance: Bool)
# Job to refresh tokens from a Redis compatible DB
property refresh_tokens : Bool = true
property pubsub_domain : String = "" property pubsub_domain : String = ""
property server_id_cookie_name : String = "COMPANION_ID" property ignore_user_tokens : Bool = false
property tokens_server : String = ""
property video_cache : VideoCacheConfig
class VideoCacheConfig
include YAML::Serializable
property enabled : Bool = true
property backend : Int32 = 1
# Max quantity of keys that can be held on the LRU cache
property lru_max_size : Int32 = 18432 # ~512MB
end
property check_backends_interval : Int32 = 30
property force_local : Bool = true
property disable_livestreams : Bool = true
property max_popuplar_results : Int32 = 40
{% if flag?(:linux) %} {% if flag?(:linux) %}
property reload_config_automatically : Bool = true property reload_config_automatically : Bool = true
@ -332,9 +292,6 @@ class Config
config = Config.from_yaml(config_yaml) config = Config.from_yaml(config_yaml)
# Update config from env vars (upcased and prefixed with "INVIDIOUS_") # 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 %} {% for ivar in Config.instance_vars %}
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
@ -371,40 +328,16 @@ class Config
exit(1) exit(1)
end end
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 %} {% end %}
if config.invidious_companion.present?
# invidious_companion and signature_server can't work together
if config.signature_server
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
exit(1)
elsif config.invidious_companion_key.empty?
puts "Config: Please configure a key if you are using invidious companion."
exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!"
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
exit(1)
elsif config.invidious_companion_key.size != 16
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
exit(1)
end
elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
else
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/")
end
# HMAC_key is mandatory # HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854 # See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty? if config.hmac_key.empty?
puts "Config: 'hmac_key' is required/can't be empty" puts "Config: 'hmac_key' is required/can't be empty"
exit(1) exit(1)
elsif config.hmac_key == "CHANGE_ME!!"
puts "Config: The value of 'hmac_key' needs to be changed!!"
exit(1)
end end
# Build database_url from db.* if it's not set directly # Build database_url from db.* if it's not set directly
@ -424,33 +357,6 @@ class Config
end end
end end
if config.video_cache.enabled
if !config.video_cache.backend.in?(0, 1, 2)
puts "Config: 'video_cache_storage', can only be:"
puts "0 (PostgreSQL)"
puts "1 (Redis compatible DB) (Default)"
puts "2 (In memory LRU)"
end
end
# Check if the socket configuration is valid
if sb = config.socket_binding
if sb.path.ends_with?("/") || File.directory?(sb.path)
puts "Config: The socket path " + sb.path + " must not be a directory!"
exit(1)
end
d = File.dirname(sb.path)
if !File.directory?(d)
puts "Config: Socket directory " + sb.path + " does not exist or is not a directory!"
exit(1)
end
p = sb.permissions.to_i?(base: 8)
if !p || p < 0 || p > 0o777
puts "Config: Socket permissions must be an octal between 0 and 777!"
exit(1)
end
end
return config return config
end end
end end

View file

@ -149,7 +149,7 @@ module Invidious::Database::ChannelVideos
SELECT DISTINCT ON (ucid) * SELECT DISTINCT ON (ucid) *
FROM channel_videos FROM channel_videos
WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT #{CONFIG.max_popuplar_results}) GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
ORDER BY ucid, published DESC ORDER BY ucid, published DESC
SQL SQL

View file

@ -119,15 +119,15 @@ module Invidious::Database::Users
# Update (notifs) # Update (notifs)
# ------------------- # -------------------
def add_multiple_notifications(channel_id : String, video_ids : Array(String)) def add_notification(video : ChannelVideo)
request = <<-SQL request = <<-SQL
UPDATE users UPDATE users
SET notifications = array_cat(notifications, $1), SET notifications = array_append(notifications, $1),
feed_needs_update = true feed_needs_update = true
WHERE $2 = ANY(subscriptions) WHERE $2 = ANY(subscriptions)
SQL SQL
PG_DB.exec(request, video_ids, channel_id) PG_DB.exec(request, video.id, video.ucid)
end end
def remove_notification(user : User, vid : String) def remove_notification(user : User, vid : String)
@ -154,15 +154,17 @@ module Invidious::Database::Users
# Update (misc) # Update (misc)
# ------------------- # -------------------
def feed_needs_update(channel_id : String) # Feeds never need update. PubSubHubBub is the one that sends videos to
request = <<-SQL # invidious.
UPDATE users # def feed_needs_update(video : ChannelVideo)
SET feed_needs_update = true # request = <<-SQL
WHERE $1 = ANY(subscriptions) # UPDATE users
SQL # SET feed_needs_update = true
# WHERE $1 = ANY(subscriptions)
# SQL
PG_DB.exec(request, channel_id) # PG_DB.exec(request, video.ucid)
end # end
def update_preferences(user : User) def update_preferences(user : User)
request = <<-SQL request = <<-SQL
@ -184,36 +186,6 @@ module Invidious::Database::Users
PG_DB.exec(request, pass, user.email) PG_DB.exec(request, pass, user.email)
end end
def update_username(user : User, username : String)
request = <<-SQL
UPDATE users
SET email = $1
WHERE email = $2
SQL
PG_DB.exec(request, username, user.email)
end
def update_user_session_id(user : User, username : String)
request = <<-SQL
UPDATE session_ids
SET email = $1
WHERE email = $2
SQL
PG_DB.exec(request, username, user.email)
end
def update_user_playlists_author(user : User, username : String)
request = <<-SQL
UPDATE playlists
SET author = $1
WHERE author = $2
SQL
PG_DB.exec(request, username, user.email)
end
# ------------------- # -------------------
# Select # Select
# ------------------- # -------------------

View file

@ -1,181 +1,27 @@
require "./base.cr" require "./base.cr"
require "redis"
VideoCache = Invidious::Database::Videos::Cache.new
module Invidious::Database::Videos module Invidious::Database::Videos
class Cache
def initialize
case CONFIG.video_cache.backend
when 0
@cache = CacheMethods::PostgresSQL.new
when 1
@cache = CacheMethods::Redis_.new
when 2
@cache = CacheMethods::LRU.new
else
LOGGER.debug "Video Cache: Using default cache method to store video cache (PostgreSQL)"
@cache = CacheMethods::PostgresSQL.new
end
end
def set(video : Video, expire_time)
@cache.set(video, expire_time)
end
def del(id : String)
@cache.del(id)
end
def get(id : String)
return @cache.get(id)
end
end
module CacheMethods
# TODO: Save the cache on a file with a Job
class LRU
@max_size : Int32
@lru = {} of String => String
@access = [] of String
def initialize(@max_size = CONFIG.video_cache.lru_max_size)
LOGGER.info "Video Cache: Using in memory LRU to store video cache"
LOGGER.info "Video Cache, LRU: LRU cache max size set to #{@max_size}"
end
# TODO: Handle expire_time with a Job
def set(video : Video, expire_time)
self[video.id] = video.info.to_json
self[video.id + ":time"] = "#{video.updated}"
end
def del(id : String)
self.delete(id)
self.delete(id + ":time")
end
def get(id : String)
info = self[id]
time = self[id + ":time"]
if info && time
return Video.new({
id: id,
info: JSON.parse(info).as_h,
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
})
else
return nil
end
end
private def [](key)
if @lru[key]?
@access.delete(key)
@access.push(key)
@lru[key]
else
nil
end
end
private def []=(key, value)
if @lru.size >= @max_size
lru_key = @access.shift
@lru.delete(lru_key)
end
@lru[key] = value
@access.push(key)
end
private def delete(key)
if @lru[key]?
@lru.delete(key)
@access.delete(key)
end
end
end
class Redis_
@redis : Redis::Client
def initialize
@redis = Redis::Client.new(CONFIG.redis_url)
LOGGER.info "Video Cache: Using Redis compatible DB to store video cache"
LOGGER.info "Connecting to Redis compatible DB"
if @redis.ping
LOGGER.info "Connected to Redis compatible DB at '#{CONFIG.redis_url}'" if CONFIG.redis_url
end
end
def set(video : Video, expire_time)
@redis.set(video.id, video.info.to_json, ex: expire_time)
@redis.set(video.id + ":time", video.updated.to_s, ex: expire_time)
end
def del(id : String)
@redis.del(id)
@redis.del(id + ":time")
end
def get(id : String)
info = @redis.get(id)
time = @redis.get(id + ":time")
if info && time
return Video.new({
id: id,
info: JSON.parse(info).as_h,
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
})
else
return nil
end
end
end
class PostgresSQL
def initialize
LOGGER.info "Video Cache: Using PostgreSQL to store video cache"
end
def set(video : Video, expire_time)
request = <<-SQL
INSERT INTO videos
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING
SQL
PG_DB.exec(request, video.id, video.info.to_json, video.updated)
end
def del(id)
request = <<-SQL
DELETE FROM videos *
WHERE id = $1
SQL
PG_DB.exec(request, id)
end
def get(id : String) : Video?
request = <<-SQL
SELECT * FROM videos
WHERE id = $1
SQL
return PG_DB.query_one?(request, id, as: Video)
end
end
end
extend self extend self
def insert(video : Video) def insert(video : Video)
VideoCache.set(video: video, expire_time: 14400) if CONFIG.video_cache.enabled request = <<-SQL
INSERT INTO videos
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING
SQL
REDIS_DB.set(video.id, video.info.to_json, ex: 14400)
REDIS_DB.set(video.id + ":time", video.updated, ex: 14400)
end end
def delete(id) def delete(id)
VideoCache.del(id) request = <<-SQL
DELETE FROM videos *
WHERE id = $1
SQL
REDIS_DB.del(id)
REDIS_DB.del(id + ":time")
end end
def delete_expired def delete_expired
@ -198,6 +44,19 @@ module Invidious::Database::Videos
end end
def select(id : String) : Video? def select(id : String) : Video?
return VideoCache.get(id) request = <<-SQL
SELECT * FROM videos
WHERE id = $1
SQL
if ((info = REDIS_DB.get(id)) && (time = REDIS_DB.get(id + ":time")))
return Video.new({
id: id,
info: JSON.parse(info).as_h,
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
})
else
return nil
end
end end
end end

View file

@ -7,9 +7,8 @@ module Invidious::Frontend::ChannelPage
Streams Streams
Podcasts Podcasts
Releases Releases
Courses
Playlists Playlists
Posts Community
Channels Channels
end end

View file

@ -3,24 +3,6 @@ require "uri"
module Invidious::Frontend::Pagination module Invidious::Frontend::Pagination
extend self 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) private def previous_page(str : String::Builder, locale : String?, url : String)
# Link # Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">) str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
@ -90,24 +72,18 @@ module Invidious::Frontend::Pagination
end end
end end
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params) def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
return String.build do |str| return String.build do |str|
str << %(<div class="h-box">\n) str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n) str << %(<div class="page-nav-container flexible">\n)
str << %(<div class="page-prev-container flex-left">) str << %(<div class="page-prev-container flex-left"></div>\n)
if !first_page
self.first_page(str, locale, base_url.to_s)
end
str << %(</div>\n)
str << %(<div class="page-next-container flex-right">) str << %(<div class="page-next-container flex-right">)
if !ctoken.nil? if !ctoken.nil?
params["continuation"] = ctoken params_next = URI::Params{"continuation" => ctoken}
url_next = HttpServer::Utils.add_params_to_url(base_url, params) url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
self.next_page(str, locale, url_next.to_s) self.next_page(str, locale, url_next.to_s)
end end

View file

@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
@full_videos, @full_videos,
@video_streams, @video_streams,
@audio_streams, @audio_streams,
@captions, @captions
) )
end end
end end

View file

@ -1,85 +0,0 @@
module BackendInfo
extend self
@@exvpp_url : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
@@status : Array(Int32) = Array.new(CONFIG.invidious_companion.size, 0)
@@csp : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
@@mutex : Mutex = Mutex.new
def check_backends
check_companion()
end
private def check_companion
CONFIG.invidious_companion.each_with_index do |companion, index|
spawn do
begin
response = HTTP::Client.get "#{companion.private_url}/healthz"
if response.status_code == 200
check_videoplayback_proxy(companion, index)
generate_csp([companion.public_url.to_s, companion.i2p_public_url.to_s], @@exvpp_url[index], index)
else
@@status[index] = 0
end
rescue
@@status[index] = 0
end
end
end
end
private def check_videoplayback_proxy(companion : Config::CompanionConfig, index : Int32)
begin
info = HTTP::Client.get "#{companion.private_url}/info"
exvpp_url = JSON.parse(info.body)["external_videoplayback_proxy"]?.try &.to_s
rescue JSON::ParseException
@@status[index] = 2
return
end
exvpp_url = "" if exvpp_url.nil?
@@exvpp_url[index] = exvpp_url
if exvpp_url.empty?
@@status[index] = 2
return
else
begin
exvpp_health = HTTP::Client.get "#{exvpp_url}/health"
if exvpp_health.status_code == 200
@@status[index] = 2
return exvpp_url
else
@@status[index] = 1
end
rescue
@@status[index] = 1
end
end
end
private def generate_csp(companion_url : Array(String), exvpp_url : String? = nil, index : Int32? = nil)
@@mutex.synchronize do
@@csp[index] = ""
companion_url.each do |url|
@@csp[index] += " #{url}"
end
@@csp[index] += " #{exvpp_url}"
end
end
def get_status
return @@status
end
def get_exvpp
return @@exvpp_url
end
def get_csp(index : Int32)
# A little mutex to prevent sending a partial CSP header
# Not sure if this is necessary. But if the @@csp[index] is being assigned
# at the same time when it's being accessed, a data race will appear
@@mutex.synchronize do
return @@csp[index], @@csp[index]
end
end
end

View file

@ -18,6 +18,40 @@ end
class HTTP::Client class HTTP::Client
property family : Socket::Family = Socket::Family::UNSPEC property family : Socket::Family = Socket::Family::UNSPEC
# Override stdlib to automatically initialize proxy if configured
#
# Accurate as of crystal 1.12.1
def initialize(@host : String, port = nil, tls : TLSContext = nil)
check_host_only(@host)
{% if flag?(:without_openssl) %}
if tls
raise "HTTP::Client TLS is disabled because `-D without_openssl` was passed at compile time"
end
@tls = nil
{% else %}
@tls = case tls
when true
OpenSSL::SSL::Context::Client.new
when OpenSSL::SSL::Context::Client
tls
when false, nil
nil
end
{% end %}
@port = (port || (@tls ? 443 : 80)).to_i
self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
end
def initialize(@io : IO, @host = "", @port = 80)
@reconnect = false
self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
end
private def io private def io
io = @io io = @io
return io if io return io if io

View file

@ -130,7 +130,7 @@ def error_json_helper(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
exception : Exception, exception : Exception,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil
) )
if exception.is_a?(InfoException) if exception.is_a?(InfoException)
return error_json_helper(env, status_code, exception.message || "", additional_fields) return error_json_helper(env, status_code, exception.message || "", additional_fields)
@ -152,7 +152,7 @@ def error_json_helper(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
message : String, message : String,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil
) )
env.response.content_type = "application/json" env.response.content_type = "application/json"
env.response.status_code = status_code env.response.status_code = status_code
@ -180,11 +180,8 @@ def error_redirect_helper(env : HTTP::Server::Context)
next_steps_text = translate(locale, "next_steps_error_message") next_steps_text = translate(locale, "next_steps_error_message")
refresh = translate(locale, "next_steps_error_message_refresh") refresh = translate(locale, "next_steps_error_message_refresh")
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube") go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
go_to_youtube_embed = translate(locale, "videoinfo_youTube_embed_link")
switch_instance = translate(locale, "Switch Invidious Instance") switch_instance = translate(locale, "Switch Invidious Instance")
show_embed_link = "(<a rel=\"noreferrer noopener\" href=\"https://youtube.com/embed/#{env.params.query["v"]}\">#{go_to_youtube_embed}</a>)" if env.params.query["v"]?
return <<-END_HTML return <<-END_HTML
<p style="margin-bottom: 4px;">#{next_steps_text}</p> <p style="margin-bottom: 4px;">#{next_steps_text}</p>
<ul> <ul>
@ -196,7 +193,6 @@ def error_redirect_helper(env : HTTP::Server::Context)
</li> </li>
<li> <li>
<a rel="noreferrer noopener" 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>
#{show_embed_link}
</li> </li>
</ul> </ul>
END_HTML END_HTML

View file

@ -27,7 +27,6 @@ class Kemal::RouteHandler
# Processes the route if it's a match. Otherwise renders 404. # Processes the route if it's a match. Otherwise renders 404.
private def process_request(context) private def process_request(context)
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found? raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
return if context.response.closed?
content = context.route.handler.call(context) 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) if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)

View file

@ -1,22 +1,8 @@
# 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 = { LOCALES_LIST = {
"ar" => "العربية", # Arabic "ar" => "العربية", # Arabic
"bg" => "български", # Bulgarian
"bn" => "বাংলা", # Bengali "bn" => "বাংলা", # Bengali
"ca" => "Català", # Catalan "ca" => "Català", # Catalan
"cs" => "Čeština", # Czech "cs" => "Čeština", # Czech
"cy" => "Cymraeg", # Welsh
"da" => "Dansk", # Danish "da" => "Dansk", # Danish
"de" => "Deutsch", # German "de" => "Deutsch", # German
"el" => "Ελληνικά", # Greek "el" => "Ελληνικά", # Greek
@ -37,7 +23,6 @@ LOCALES_LIST = {
"it" => "Italiano", # Italian "it" => "Italiano", # Italian
"ja" => "日本語", # Japanese "ja" => "日本語", # Japanese
"ko" => "한국어", # Korean "ko" => "한국어", # Korean
"lmo" => "Lombard", # Lombard
"lt" => "Lietuvių", # Lithuanian "lt" => "Lietuvių", # Lithuanian
"nb-NO" => "Norsk bokmål", # Norwegian Bokmål "nb-NO" => "Norsk bokmål", # Norwegian Bokmål
"nl" => "Nederlands", # Dutch "nl" => "Nederlands", # Dutch
@ -54,7 +39,6 @@ LOCALES_LIST = {
"sr" => "Srpski (latinica)", # Serbian (Latin) "sr" => "Srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish "sv-SE" => "Svenska", # Swedish
"ta" => "தமிழ்", # Tamil
"tr" => "Türkçe", # Turkish "tr" => "Türkçe", # Turkish
"uk" => "Українська", # Ukrainian "uk" => "Українська", # Ukrainian
"vi" => "Tiếng Việt", # Vietnamese "vi" => "Tiếng Việt", # Vietnamese

View file

@ -12,9 +12,7 @@ enum LogLevel
end end
class Invidious::LogHandler < Kemal::BaseLogHandler class Invidious::LogHandler < Kemal::BaseLogHandler
def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @color : Bool = true)
Colorize.enabled = use_color
Colorize.on_tty_only!
end end
def call(context : HTTP::Server::Context) def call(context : HTTP::Server::Context)
@ -58,7 +56,8 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
{% for level in %w(trace debug info warn error fatal) %} {% for level in %w(trace debug info warn error fatal) %}
def {{level.id}}(message : String) def {{level.id}}(message : String)
if LogLevel::{{level.id.capitalize}} >= @level if LogLevel::{{level.id.capitalize}} >= @level
puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})).toggle(@color))
end end
end end
{% end %} {% end %}

View file

@ -56,11 +56,12 @@ macro templated(_filename, template = "template", navbar_search = true, buffer_f
{{ layout = "src/invidious/views/" + template + ".ecr" }} {{ layout = "src/invidious/views/" + template + ".ecr" }}
__content_filename__ = {{filename}} __content_filename__ = {{filename}}
render {{filename}}, {{layout}} content = Kilt.render({{filename}})
Kilt.render({{layout}})
end end
macro rendered(filename) macro rendered(filename)
render("src/invidious/views/#{{{filename}}}.ecr") Kilt.render("src/invidious/views/#{{{filename}}}.ecr")
end end
# Similar to Kemals halt method but works in a # Similar to Kemals halt method but works in a

View file

@ -0,0 +1,49 @@
module Tokens
extend self
@@po_token : String | Nil
@@visitor_data : String | Nil
def refresh_tokens
@@po_token = REDIS_DB.get("invidious:po_token")
@@visitor_data = REDIS_DB.get("invidious:visitor_data")
LOGGER.debug("RefreshTokens: Tokens are:")
LOGGER.debug("RefreshTokens: po_token: #{@@po_token}")
LOGGER.debug("RefreshTokens: visitor_data: #{@@visitor_data}")
end
def get_tokens
return {@@po_token, @@visitor_data}
end
def get_po_token
return @@po_token
end
def get_visitor_data
return @@visitor_data
end
def generate_tokens(user : String)
po_token = ""
visitor_data = ""
attempts = 0
LOGGER.debug("Generating po_token and visitor_data for user: '#{user}'")
REDIS_DB.publish("generate-token", "#{user}")
while REDIS_DB.get("invidious:#{user}:po_token").nil? && REDIS_DB.get("invidious:#{user}:visitor_data").nil?
if attempts > 50
break
end
LOGGER.debug("Waiting for tokens to arrive at redis for user: '#{user}'")
attempts += 1
sleep 250.milliseconds
end
po_token = REDIS_DB.get("invidious:#{user}:po_token")
visitor_data = REDIS_DB.get("invidious:#{user}:visitor_data")
LOGGER.debug("Tokens successfully generated for user: '#{user}'")
return {po_token, visitor_data}
end
end

View file

@ -24,7 +24,6 @@ struct SearchVideo
property length_seconds : Int32 property length_seconds : Int32
property premiere_timestamp : Time? property premiere_timestamp : Time?
property author_verified : Bool property author_verified : Bool
property author_thumbnail : String?
property badges : VideoBadges property badges : VideoBadges
def to_xml(auto_generated, query_params, xml : XML::Builder) def to_xml(auto_generated, query_params, xml : XML::Builder)
@ -89,24 +88,6 @@ struct SearchVideo
json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorVerified", self.author_verified 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 json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, self.id) Invidious::JSONify::APIv1.thumbnails(json, self.id)
end end
@ -242,7 +223,7 @@ struct SearchChannel
qualities.each do |quality| qualities.each do |quality|
json.object do json.object do
json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
json.field "width", quality json.field "width", quality
json.field "height", quality json.field "height", quality
end end

View file

@ -1,35 +0,0 @@
module SessionTokens
extend self
@@po_token : String | Nil
@@visitor_data : String | Nil
def refresh_tokens
begin
response = HTTP::Client.get "#{CONFIG.tokens_server}/generate"
if !response.status_code == 200
LOGGER.error("RefreshSessionTokens: Expected response to have status code 200 but got #{response.status_code} from #{CONFIG.tokens_server}")
end
json = JSON.parse(response.body)
@@po_token = json.try &.["potoken"].as_s || nil
@@visitor_data = json.try &.["visitorData"].as_s || nil
rescue ex
LOGGER.error("RefreshSessionTokens: Failed to fetch tokens from #{CONFIG.tokens_server}: #{ex.message}")
return
end
if !@@po_token.nil? && !@@visitor_data.nil?
set_tokens
LOGGER.debug("RefreshSessionTokens: Successfully updated po_token and visitor_data")
else
LOGGER.warn("RefreshSessionTokens: Tokens are empty!. Invidious will use the tokens that are on the configuration file")
end
LOGGER.trace("RefreshSessionTokens: Tokens are:")
LOGGER.trace("RefreshSessionTokens: po_token: #{CONFIG.po_token}")
LOGGER.trace("RefreshSessionTokens: visitor_data: #{CONFIG.visitor_data}")
end
def set_tokens
CONFIG.po_token = @@po_token
CONFIG.visitor_data = @@visitor_data
end
end

View file

@ -384,44 +384,16 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
return text return text
end end
def decrypt_ecb_without_salt(data, key) # Generates a list of external videoplayback proxies for
cipher = OpenSSL::Cipher.new("aes-128-ecb") # CSP
cipher.decrypt def gen_videoplayback_proxy_list
cipher.key = key if !CONFIG.external_videoplayback_proxy.empty?
cipher.padding = false external_videoplayback_proxy = ""
CONFIG.external_videoplayback_proxy.each do |proxy|
io = IO::Memory.new external_videoplayback_proxy += " #{proxy[:url]}"
io.write(cipher.update(data)) end
io.write(cipher.final) else
io.rewind external_videoplayback_proxy = ""
end
data_ = io.to_s return external_videoplayback_proxy
padding = data_[-1].ord
return data_[0...(data_.bytesize - padding)]
end
def video_playback_decrypt(data)
data = Base64.decode(data)
decrypted_query = decrypt_ecb_without_salt(data, CONFIG.invidious_companion_key)
return decrypted_query
end
def encrypt_ecb_without_salt(data, key)
cipher = OpenSSL::Cipher.new("aes-128-ecb")
cipher.encrypt
cipher.key = key
io = IO::Memory.new
io.write(cipher.update(data))
io.write(cipher.final)
io.rewind
return io
end
def invidious_companion_encrypt(data)
timestamp = Time.utc.to_unix
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
return Base64.urlsafe_encode(encrypted_data)
end end

View file

@ -4,6 +4,53 @@ module Invidious::HttpServer
module Utils module Utils
extend self extend self
@@proxy_list : Array(String) = [] of String
@@current_proxy : String = ""
@@count : Int64 = Time.utc.to_unix
def check_external_proxy
CONFIG.external_videoplayback_proxy.each do |proxy|
begin
response = HTTP::Client.get("#{proxy[:url]}/health")
if response.status_code == 200
if @@proxy_list.includes?(proxy[:url])
next
end
if proxy[:balance]
@@proxy_list << proxy[:url]
LOGGER.debug("CheckExternalProxy: Adding proxy '#{proxy[:url]}' to the list of proxies")
end
break if proxy[:balance] == false && !@@proxy_list.empty?
@@proxy_list << proxy[:url]
end
rescue
if @@proxy_list.includes?(proxy[:url])
LOGGER.debug("CheckExternalProxy: Proxy '#{proxy[:url]}' is not available, removing it from the list of proxies")
@@proxy_list.delete(proxy[:url])
end
LOGGER.debug("CheckExternalProxy: Proxy '#{proxy[:url]}' is not available")
end
end
LOGGER.trace("CheckExternalProxy: List of proxies:")
LOGGER.trace("#{@@proxy_list.inspect}")
end
# TODO: If the function is called many times, it will return a random
# proxy from the list. That is not how it should be.
# It should return the same proxy, in multiple function calls
def select_proxy
if (@@count - (Time.utc.to_unix - 30)) <= 0
return if @@proxy_list.size <= 0
@@current_proxy = @@proxy_list[Random.rand(@@proxy_list.size)]
LOGGER.debug("Current proxy is: '#{@@current_proxy}'")
@@count = Time.utc.to_unix
end
end
def get_external_proxy
return @@current_proxy
end
def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false)
url = URI.parse(raw_url) url = URI.parse(raw_url)
@ -14,7 +61,11 @@ module Invidious::HttpServer
url.query_params = params url.query_params = params
if absolute if absolute
return "#{HOST_URL}#{url.request_target}" if !(proxy = get_external_proxy()).empty?
return "#{proxy}#{url.request_target}"
else
return "#{HOST_URL}#{url.request_target}"
end
else else
return url.request_target return url.request_target
end end

View file

@ -1,13 +0,0 @@
class Invidious::Jobs::CheckBackend < Invidious::Jobs::BaseJob
def initialize
end
def begin
loop do
BackendInfo.check_backends
LOGGER.info("Backend Checker: Done, sleeping for #{CONFIG.check_backends_interval} seconds")
sleep CONFIG.check_backends_interval.seconds
Fiber.yield
end
end
end

View file

@ -0,0 +1,14 @@
class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
def initialize
end
def begin
loop do
HttpServer::Utils.check_external_proxy
HttpServer::Utils.select_proxy
LOGGER.info("CheckExternalProxy: Done, sleeping for 15 seconds")
sleep 15.seconds
Fiber.yield
end
end
end

View file

@ -1,32 +1,8 @@
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 class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
private getter notification_channel : ::Channel(VideoNotification)
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
private getter pg_url : URI private getter pg_url : URI
def initialize(@notification_channel, @connection_channel, @pg_url) def initialize(@connection_channel, @pg_url)
end end
def begin def begin
@ -34,70 +10,6 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } 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(&.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 loop do
action, connection = connection_channel.receive action, connection = connection_channel.receive

View file

@ -1,10 +1,10 @@
class Invidious::Jobs::RefreshSessionTokens < Invidious::Jobs::BaseJob class Invidious::Jobs::RefreshTokens < Invidious::Jobs::BaseJob
def initialize def initialize
end end
def begin def begin
loop do loop do
SessionTokens.refresh_tokens Tokens.refresh_tokens
LOGGER.info("RefreshTokens: Done, sleeping for 5 seconds") LOGGER.info("RefreshTokens: Done, sleeping for 5 seconds")
sleep 5.seconds sleep 5.seconds
Fiber.yield Fiber.yield

View file

@ -8,7 +8,7 @@ module Invidious::JSONify::APIv1
build_thumbnails(id).each do |thumbnail| build_thumbnails(id).each do |thumbnail|
json.object do json.object do
json.field "quality", thumbnail[:name] json.field "quality", thumbnail[:name]
json.field "url", "/vi/#{id}/#{thumbnail["url"]}.jpg" json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "width", thumbnail[:width] json.field "width", thumbnail[:width]
json.field "height", thumbnail[:height] json.field "height", thumbnail[:height]
end end

View file

@ -267,12 +267,6 @@ module Invidious::JSONify::APIv1
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]? json.field "viewCountText", rv["short_view_count"]?
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
json.field "published", rv["published"]?
if rv["published"]?.try &.presence
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 end
end end

View file

@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
}) })
end end
def template_mix(mix, listen) def template_mix(mix)
html = <<-END_HTML html = <<-END_HTML
<h3> <h3>
<a href="/mix?list=#{mix["mixId"]}"> <a href="/mix?list=#{mix["mixId"]}">
@ -95,7 +95,7 @@ def template_mix(mix, listen)
mix["videos"].as_a.each do |video| mix["videos"].as_a.each do |video|
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>

View file

@ -505,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
return videos return videos
end end
def template_playlist(playlist, listen) def template_playlist(playlist)
html = <<-END_HTML html = <<-END_HTML
<h3> <h3>
<a href="/playlist?list=#{playlist["playlistId"]}"> <a href="/playlist?list=#{playlist["playlistId"]}">
@ -519,7 +519,7 @@ def template_playlist(playlist, listen)
playlist["videos"].as_a.each do |video| playlist["videos"].as_a.each do |video|
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item" id="#{video["videoId"]}"> <li class="pure-menu-item" id="#{video["videoId"]}">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}"> <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>

View file

@ -78,75 +78,6 @@ module Invidious::Routes::Account
env.redirect referer env.redirect referer
end end
# -------------------
# Username update
# -------------------
# Show the username change interface (GET request)
def get_change_username(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":change_username"}, HMAC_KEY)
templated "user/change_username"
end
# Handle the username change (POST request)
def post_change_username(env)
locale = env.get("preferences").as(Preferences).locale
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
sid = sid.as(String)
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end
new_username = env.params.body["new_username"]?.try &.downcase.byte_slice(0, 254)
if new_username.nil?
return error_template(401, "username_required_field")
end
if new_username.empty?
return error_template(401, "username_empty")
end
if new_username == user.email
return error_template(401, "username_is_the_same")
end
if Invidious::Database::Users.select(email: new_username)
return error_template(401, "username_taken")
end
Invidious::Database::Users.update_username(user, new_username.to_s)
Invidious::Database::Users.update_user_session_id(user, new_username.to_s)
Invidious::Database::Users.update_user_playlists_author(user, new_username.to_s)
env.redirect referer
end
# ------------------- # -------------------
# Account deletion # Account deletion
# ------------------- # -------------------
@ -395,9 +326,17 @@ module Invidious::Routes::Account
end end
end end
case action = env.params.query["action"]? if env.params.query["action_revoke_token"]?
when "revoke_token" action = "action_revoke_token"
session = env.params.query["session"] else
return env.redirect referer
end
session = env.params.query["session"]?
session ||= ""
case action
when .starts_with? "action_revoke_token"
Invidious::Database::SessionIDs.delete(sid: session, email: user.email) Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else else
return error_json(400, "Unsupported action #{action}") return error_json(400, "Unsupported action #{action}")
@ -410,4 +349,40 @@ module Invidious::Routes::Account
return "{}" return "{}"
end end
end end
# -------------------
# poToken and visitorData tokens generation
# -------------------
# Generates a poToken & visitorData for the user, server side
def generate_tokens(env)
locale = env.get("preferences").as(Preferences).locale
preferences = env.get("preferences").as(Preferences)
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
po_token, visitor_data = Tokens.generate_tokens(user.email)
if po_token.nil? || visitor_data.nil?
return error_template(500, "Internal server error. Please submit an issue here IF THE ISSUE PERSISTS: https://git.nadeko.net/Fijxu/invidious/issues")
end
user.preferences.po_token = po_token
user.preferences.visitor_data = visitor_data
Invidious::Database::Users.update_preferences(user)
REDIS_DB.del("invidious:#{user.email}:po_token")
REDIS_DB.del("invidious:#{user.email}:visitor_data")
templated "user/tokens"
end
end end

View file

@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]?
if CONFIG.invidious_companion.present?
companion_public_url = env.get("companion_public_url").as(String)
return env.redirect "#{companion_public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
end
# Since some implementations create playlists based on resolution regardless of different codecs, # Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation # we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@ -60,6 +55,10 @@ module Invidious::Routes::API::Manifest
end end
end end
audio_streams.reject! do |z|
z if z.dig?("audioTrack", "audioIsDefault") == false
end
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
"profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static",
@ -75,23 +74,17 @@ module Invidious::Routes::API::Manifest
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) 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. # 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 # However, most players don't support auto quality switching, so we have to trick them
# into providing a quality selector. # into providing a quality selector.
# See https://github.com/iv-org/invidious/issues/3074 for more details. # 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: "#{displayname} [#{bitrate}k]", lang: lang) do xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i itag = fmt["itag"].as_i
url = fmt["url"].as_s url = fmt["url"].as_s
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate") xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
@ -188,7 +181,7 @@ module Invidious::Routes::API::Manifest
manifest = response.body manifest = response.body
if local if local
manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match| manifest = manifest.gsub(/^https:\/\/[^\n"]*/m) do |match|
uri = URI.parse(match) uri = URI.parse(match)
path = uri.path path = uri.path
@ -221,13 +214,13 @@ module Invidious::Routes::API::Manifest
raw_params["host"] = uri.host.not_nil! raw_params["host"] = uri.host.not_nil!
if CONFIG.https_only proxy = Invidious::HttpServer::Utils.get_external_proxy
scheme = "https://"
else
scheme = "http://"
end
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}" if !proxy.empty?
"#{proxy}/videoplayback?#{raw_params}"
else
"#{HOST_URL}/videoplayback?#{raw_params}"
end
end end
end end
@ -250,12 +243,7 @@ module Invidious::Routes::API::Manifest
manifest = response.body manifest = response.body
if local if local
if CONFIG.https_only manifest = manifest.gsub("https://www.youtube.com", HOST_URL)
scheme = "https://"
else
scheme = "http://"
end
manifest = manifest.gsub("https://www.youtube.com", "#{scheme}#{env.request.headers["Host"]}")
manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true")
end end

View file

@ -226,7 +226,7 @@ module Invidious::Routes::API::V1::Authenticated
end end
playlist = create_playlist(title, privacy, user) playlist = create_playlist(title, privacy, user)
env.response.headers["Location"] = "#{env.request.headers["Host"]}/api/v1/auth/playlists/#{playlist.id}" env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
env.response.status_code = 201 env.response.status_code = 201
{ {
"title" => title, "title" => title,
@ -482,7 +482,7 @@ module Invidious::Routes::API::V1::Authenticated
env.response.content_type = "text/event-stream" env.response.content_type = "text/event-stream"
raw_topics = env.params.body["topics"]? || env.params.query["topics"]? raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
topics = raw_topics.try &.split(",").uniq!.first(1000) topics = raw_topics.try &.split(",").uniq.first(1000)
topics ||= [] of String topics ||= [] of String
create_notification_stream(env, topics, CONNECTION_CHANNEL) create_notification_stream(env, topics, CONNECTION_CHANNEL)

View file

@ -197,7 +197,6 @@ module Invidious::Routes::API::V1::Channels
get_channel() get_channel()
# Retrieve continuation from URL parameters # Retrieve continuation from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
if channel.is_age_gated if channel.is_age_gated
@ -212,7 +211,7 @@ module Invidious::Routes::API::V1::Channels
else else
begin begin
videos, next_continuation = Channel::Tabs.get_shorts( videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation, sort_by: sort_by channel, continuation: continuation
) )
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)
@ -368,35 +367,6 @@ module Invidious::Routes::API::V1::Channels
end end
end end
def self.courses(env)
locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
continuation = env.params.query["continuation"]?
# Use the macro defined above
channel = nil # Make the compiler happy
get_channel()
items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
JSON.build do |json|
json.object do
json.field "playlists" do
json.array do
items.each do |item|
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
end
end
end
json.field "continuation", next_continuation if next_continuation
end
end
end
def self.community(env) def self.community(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale

View file

@ -42,9 +42,6 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
listen_param = env.params.query["listen"]?
listen = (listen_param == "true" || listen_param == "1")
if plid.starts_with? "RD" if plid.starts_with? "RD"
return env.redirect "/api/v1/mixes/#{plid}" return env.redirect "/api/v1/mixes/#{plid}"
end end
@ -88,7 +85,7 @@ module Invidious::Routes::API::V1::Misc
end end
if format == "html" if format == "html"
playlist_html = template_playlist(json_response, listen) playlist_html = template_playlist(json_response)
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} 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 = { response = {
@ -114,9 +111,6 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
listen_param = env.params.query["listen"]?
listen = (listen_param == "true" || listen_param == "1")
begin begin
mix = fetch_mix(rdid, continuation, locale: locale) mix = fetch_mix(rdid, continuation, locale: locale)
@ -147,7 +141,9 @@ module Invidious::Routes::API::V1::Misc
json.field "authorUrl", "/channel/#{video.ucid}" json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, video.id) json.array do
Invidious::JSONify::APIv1.thumbnails(json, video.id)
end
end end
json.field "index", video.index json.field "index", video.index
@ -161,7 +157,7 @@ module Invidious::Routes::API::V1::Misc
if format == "html" if format == "html"
response = JSON.parse(response) response = JSON.parse(response)
playlist_html = template_mix(response, listen) playlist_html = template_mix(response)
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
response = { response = {

View file

@ -31,7 +31,9 @@ module Invidious::Routes::API::V1::Search
query = env.params.query["q"]? || "" query = env.params.query["q"]? || ""
begin begin
client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) client = HTTP::Client.new("suggestqueries-clients6.youtube.com")
client.before_request { |r| add_yt_headers(r) }
url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" 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 response = client.get(url).body

View file

@ -263,59 +263,60 @@ module Invidious::Routes::API::V1::Videos
annotations = "" annotations = ""
case source # case source
when "archive" # when "archive"
if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) # if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id))
annotations = cached_annotation.annotations # annotations = cached_annotation.annotations
else # else
index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0') # index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0')
# IA doesn't handle leading hyphens, # # IA doesn't handle leading hyphens,
# so we use https://archive.org/details/youtubeannotations_64 # # so we use https://archive.org/details/youtubeannotations_64
if index == "62" # if index == "62"
index = "64" # index = "64"
id = id.sub(/^-/, 'A') # id = id.sub(/^-/, 'A')
end # end
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") # file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) # location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
if !location.headers["Location"]? # if !location.headers["Location"]?
env.response.status_code = location.status_code # env.response.status_code = location.status_code
end # end
response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) # response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
if response.body.empty? # if response.body.empty?
haltf env, 404 # haltf env, 404
end # end
if response.status_code != 200 # if response.status_code != 200
haltf env, response.status_code # haltf env, response.status_code
end # end
annotations = response.body # annotations = response.body
cache_annotation(id, annotations) # cache_annotation(id, annotations)
end # end
else # "youtube" # else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") # response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
if response.status_code != 200 # if response.status_code != 200
haltf env, response.status_code # haltf env, response.status_code
end # end
annotations = response.body # annotations = response.body
end # end
etag = sha256(annotations)[0, 16] # etag = sha256(annotations)[0, 16]
if env.request.headers["If-None-Match"]?.try &.== etag # if env.request.headers["If-None-Match"]?.try &.== etag
haltf env, 304 # haltf env, 304
else # else
env.response.headers["ETag"] = etag # env.response.headers["ETag"] = etag
annotations # annotations
end # end
annotations
end end
def self.comments(env) def self.comments(env)
@ -429,90 +430,4 @@ module Invidious::Routes::API::V1::Videos
end end
end end
end end
# Fetches transcripts from YouTube
#
# Use the `lang` and `autogen` query parameter to select which transcript to fetch
# Request without any URL parameters to see all the available transcripts.
def self.transcripts(env)
env.response.content_type = "application/json"
id = env.params.url["id"]
lang = env.params.query["lang"]?
label = env.params.query["label"]?
auto_generated = env.params.query["autogen"]? ? true : false
# Return all available transcript options when none is given
if !label && !lang
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
response = JSON.build do |json|
# The amount of transcripts available to fetch is the
# same as the amount of captions available.
available_transcripts = video.captions
json.object do
json.field "transcripts" do
json.array do
available_transcripts.each do |transcript|
json.object do
json.field "label", transcript.name
json.field "languageCode", transcript.language_code
json.field "autoGenerated", transcript.auto_generated
if transcript.auto_generated
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen"
else
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}"
end
end
end
end
end
end
end
return response
end
# If lang is not given then we attempt to fetch
# the transcript through the given label
if lang.nil?
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
target_transcript = video.captions.select(&.name.== label)
if target_transcript.empty?
return error_json(404, NotFoundException.new("Requested transcript does not exist"))
else
target_transcript = target_transcript[0]
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
end
end
params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated)
begin
transcript = Invidious::Videos::Transcript.from_raw(
YoutubeAPI.get_transcript(params), lang, auto_generated
)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
return transcript.to_json
end
end end

View file

@ -1,16 +0,0 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::BackendSwitcher
def self.switch(env)
referer = get_referer(env)
backend_id = env.params.query["backend_id"]?.try &.to_i
if backend_id.nil?
return error_template(400, "Backend ID is required")
end
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(env.request.headers["Host"], backend_id)
env.redirect referer
end
end

View file

@ -1,7 +1,6 @@
module Invidious::Routes::BeforeAll module Invidious::Routes::BeforeAll
def self.handle(env) def self.handle(env)
preferences = Preferences.from_json("{}") preferences = Preferences.from_json("{}")
host = env.request.headers["Host"]
begin begin
if prefs_cookie = env.request.cookies["PREFS"]? if prefs_cookie = env.request.cookies["PREFS"]?
@ -21,60 +20,12 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff" env.response.headers["X-Content-Type-Options"] = "nosniff"
extra_media_csp = ""
extra_connect_csp = ""
if CONFIG.invidious_companion.present?
current_companion_d = host.split(".")[0].scan(/(\d+)$/).last?.try &.[0].to_i
if current_companion_d
current_companion_d = current_companion_d - 1
env.set "using_domain", true
env.set "current_companion", current_companion_d
env.set "companion_public_url", CONFIG.invidious_companion[current_companion_d].public_url.to_s
else
if !env.request.cookies[CONFIG.server_id_cookie_name]?
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host)
end
begin
current_companion = env.request.cookies[CONFIG.server_id_cookie_name].value.try &.to_i
rescue
current_companion = rand(CONFIG.invidious_companion.size)
end
if current_companion > CONFIG.invidious_companion.size
current_companion = current_companion % CONFIG.invidious_companion.size
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
end
companion_status = BackendInfo.get_status
if companion_status[current_companion] != 2
alive_companion = companion_status.index(2)
if alive_companion
env.set "companion_switched", true
current_companion = alive_companion
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
end
end
env.set "current_companion", current_companion
if host.split(".").last == "i2p"
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].i2p_public_url.to_s
else
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].public_url.to_s
end
end
extra_media_csp, extra_connect_csp = BackendInfo.get_csp(env.get("current_companion").as(Int32))
end
# Allow media resources to be loaded from google servers # Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed # TODO: check if *.youtube.com can be removed
if CONFIG.disabled?("local") || !preferences.local if CONFIG.disabled?("local") || !preferences.local
extra_media_csp += " https://*.googlevideo.com:443 https://*.youtube.com:443" extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
else
extra_media_csp = ""
end end
# Only allow the pages at /embed/* to be embedded # Only allow the pages at /embed/* to be embedded
@ -84,24 +35,21 @@ module Invidious::Routes::BeforeAll
frame_ancestors = "'none'" frame_ancestors = "'none'"
end end
scheme = env.request.headers["X-Forwarded-Proto"]? || ("https" if CONFIG.https_only) || "http"
env.set "scheme", scheme
# TODO: Remove style-src's 'unsafe-inline', requires to remove all # TODO: Remove style-src's 'unsafe-inline', requires to remove all
# inline styles (<style> [..] </style>, style=" [..] ") # inline styles (<style> [..] </style>, style=" [..] ")
env.response.headers["Content-Security-Policy"] = { env.response.headers["Content-Security-Policy"] = {
"default-src 'none'", "default-src 'none'",
"script-src 'self'", "script-src 'self'",
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: " + "#{scheme}://#{env.request.headers["Host"]?}", "img-src 'self' data:",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self'" + extra_connect_csp, "connect-src 'self'" + EXT_VIDEOP_LIST,
"manifest-src 'self'", "manifest-src 'self'",
"media-src 'self' blob:" + extra_media_csp, "media-src 'self' blob:" + extra_media_csp + EXT_VIDEOP_LIST,
"child-src 'self' blob:", "child-src 'self' blob:",
"frame-src 'self'", "frame-src 'self'",
"frame-ancestors " + frame_ancestors, "frame-ancestors " + frame_ancestors,
}.join("; ") if CONFIG.csp }.join("; ")
env.response.headers["Referrer-Policy"] = "same-origin" env.response.headers["Referrer-Policy"] = "same-origin"

View file

@ -20,11 +20,10 @@ module Invidious::Routes::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase sort_by = env.params.query["sort_by"]?.try &.downcase
if channel.auto_generated if channel.auto_generated
sort_by ||= "last"
sort_options = {"last", "oldest", "newest"} sort_options = {"last", "oldest", "newest"}
items, next_continuation = fetch_channel_playlists( items, next_continuation = fetch_channel_playlists(
channel.ucid, channel.author, continuation, sort_by channel.ucid, channel.author, continuation, (sort_by || "last")
) )
items.uniq! do |item| items.uniq! do |item|
@ -50,11 +49,9 @@ module Invidious::Routes::Channels
end end
next_continuation = nil next_continuation = nil
else else
sort_by ||= "newest"
sort_options = {"newest", "oldest", "popular"} sort_options = {"newest", "oldest", "popular"}
items, next_continuation = Channel::Tabs.get_videos(
items, next_continuation = Channel::Tabs.get_60_videos( channel, continuation: continuation, sort_by: (sort_by || "newest")
channel, continuation: continuation, sort_by: sort_by
) )
end end
end end
@ -85,12 +82,13 @@ module Invidious::Routes::Channels
end end
next_continuation = nil next_continuation = nil
else else
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" # TODO: support sort option for shorts
sort_options = {"newest", "oldest", "popular"} sort_by = ""
sort_options = [] of String
# Fetch items and continuation token # Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_shorts( items, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation, sort_by: sort_by channel, continuation: continuation
) )
end end
@ -197,29 +195,7 @@ module Invidious::Routes::Channels
templated "channel" templated "channel"
end end
def self.courses(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
locale, user, subscriptions, continuation, ucid, channel = data
sort_by = ""
sort_options = [] of String
items, next_continuation = fetch_channel_courses(
channel.ucid, channel.author, continuation
)
items = items.select(SearchPlaylist)
items.each(&.author = "")
selected_tab = Frontend::ChannelPage::TabsAvailable::Courses
templated "channel"
end
def self.community(env) def self.community(env)
return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts"
data = self.fetch_basic_information(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple) if !data.is_a?(Tuple)
return data return data
@ -236,7 +212,7 @@ module Invidious::Routes::Channels
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
if !channel.tabs.includes? "community" && "posts" if !channel.tabs.includes? "community"
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
@ -329,8 +305,7 @@ module Invidious::Routes::Channels
private KNOWN_TABS = { private KNOWN_TABS = {
"home", "videos", "shorts", "streams", "podcasts", "home", "videos", "shorts", "streams", "podcasts",
"releases", "courses", "playlists", "community", "channels", "about", "releases", "playlists", "community", "channels", "about",
"posts",
} }
# Redirects brand url channels to a normal /channel/:ucid route # Redirects brand url channels to a normal /channel/:ucid route

View file

@ -203,11 +203,6 @@ module Invidious::Routes::Embed
return env.redirect url return env.redirect url
end end
if CONFIG.invidious_companion.present?
current_companion = env.get("current_companion").as(Int32)
invidious_companion = CONFIG.invidious_companion[current_companion]
end
rendered "embed" rendered "embed"
end end
end end

View file

@ -143,25 +143,32 @@ module Invidious::Routes::Feeds
# RSS feeds # RSS feeds
def self.rss_channel(env) def self.rss_channel(env)
locale = env.get("preferences").as(Preferences).locale
env.response.headers["Content-Type"] = "application/atom+xml" env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml" env.response.content_type = "application/atom+xml"
if env.params.url["ucid"].matches?(/^[\w-]+$/) ucid = env.params.url["ucid"]
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"]? || "") 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 = { namespaces = {
"yt" => "http://www.youtube.com/xml/schemas/2015", "yt" => "http://www.youtube.com/xml/schemas/2015",
"media" => "http://search.yahoo.com/mrss/", "media" => "http://search.yahoo.com/mrss/",
"default" => "http://www.w3.org/2005/Atom", "default" => "http://www.w3.org/2005/Atom",
} }
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}") response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body) rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@ -172,7 +179,7 @@ module Invidious::Routes::Feeds
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) 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 author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s 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 views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64
@ -180,44 +187,41 @@ module Invidious::Routes::Feeds
title: title, title: title,
id: video_id, id: video_id,
author: author, author: author,
ucid: video_ucid, ucid: ucid,
published: published, published: published,
views: views, views: views,
description_html: description_html, description_html: description_html,
length_seconds: 0, length_seconds: 0,
premiere_timestamp: nil, premiere_timestamp: nil,
author_verified: false, author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::None,
}) })
end end
author = ""
author = videos[0].author if videos.size > 0
XML.build(indent: " ", encoding: "UTF-8") do |xml| XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", 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", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
"xml:lang": "en-US") do "xml:lang": "en-US") do
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
xml.element("id") { xml.text "yt:channel:#{ucid}" } xml.element("id") { xml.text "yt:channel:#{channel.ucid}" }
xml.element("yt:channelId") { xml.text ucid } xml.element("yt:channelId") { xml.text channel.ucid }
xml.element("title") { author } xml.element("icon") { xml.text channel.author_thumbnail }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}") xml.element("title") { xml.text channel.author }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text author } xml.element("name") { xml.text channel.author }
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
end end
xml.element("image") do xml.element("image") do
xml.element("url") { xml.text "" } xml.element("url") { xml.text channel.author_thumbnail }
xml.element("title") { xml.text author } xml.element("title") { xml.text channel.author }
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
end end
videos.each do |video| videos.each do |video|
video.to_xml(false, params, xml) video.to_xml(channel.auto_generated, params, xml)
end end
end end
end end
@ -305,9 +309,8 @@ module Invidious::Routes::Feeds
end end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
document = XML.parse(response.body) document = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
node.attributes.each do |attribute| node.attributes.each do |attribute|
case attribute.name case attribute.name
@ -413,33 +416,43 @@ module Invidious::Routes::Feeds
author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content)
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
title = entry.xpath_node("default:title", namespaces).not_nil!.content
if CONFIG.use_innertube_for_feeds begin
begin video = get_video(id, force_refresh: true)
video_ = get_video(id, force_refresh: true) rescue
rescue next # skip this video since it raised an exception (e.g. it is a scheduled live event)
next # skip this video since it raised an exception (e.g. it is a scheduled live event) end
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 end
video = ChannelVideo.new({ video = ChannelVideo.new({
id: id, id: id,
title: title, title: video.title,
published: published, published: published,
updated: updated, updated: updated,
ucid: ucid, ucid: video.ucid,
author: author, author: author,
length_seconds: video_.try &.length_seconds || 0, length_seconds: video.length_seconds,
live_now: video_.try &.live_now || false, live_now: video.live_now,
premiere_timestamp: video_.try &.premiere_timestamp || nil, premiere_timestamp: video.premiere_timestamp,
views: video_.try &.views || nil, views: video.views,
}) })
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
if was_insert if was_insert
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) if CONFIG.enable_user_notifications
Invidious::Database::Users.add_notification(video)
# else
# Invidious::Database::Users.feed_needs_update(video)
end
end end
end end
end end

View file

@ -111,7 +111,7 @@ module Invidious::Routes::Images
if name == "maxres.jpg" if name == "maxres.jpg"
build_thumbnails(id).each do |thumb| build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200 if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200
name = thumb[:url] + ".jpg" name = thumb[:url] + ".jpg"
break break
end end

View file

@ -60,7 +60,13 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email) Invidious::Database::SessionIDs.insert(sid, email)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(env.request.headers["Host"], sid) # Checks if there is any alternative domain, like a second domain name,
# TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
else else
return error_template(401, "Wrong username or password") return error_template(401, "Wrong username or password")
end end
@ -160,7 +166,13 @@ module Invidious::Routes::Login
Invidious::Database::Users.insert(user) Invidious::Database::Users.insert(user)
Invidious::Database::SessionIDs.insert(sid, email) Invidious::Database::SessionIDs.insert(sid, email)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(env.request.headers["Host"], sid) # Checks if there is any alternative domain, like a second domain name,
# TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
if env.request.cookies["PREFS"]? if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences) user.preferences = env.get("preferences").as(Preferences)

Some files were not shown because too many files have changed in this diff Show more