Compare commits
8 commits
master
...
external-p
Author | SHA1 | Date | |
---|---|---|---|
e1a1bf0f79 | |||
7ded7455b0 | |||
53f0a5b1e0 | |||
34a11fe1bb | |||
2db9396dea | |||
|
0c3e5baab0 | ||
73ba53a327 | |||
00bcf53d0f |
98 changed files with 736 additions and 1889 deletions
|
@ -38,9 +38,6 @@ Style/RedundantBegin:
|
||||||
Style/RedundantReturn:
|
Style/RedundantReturn:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Style/RedundantNext:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
Style/ParenthesesAroundCondition:
|
Style/ParenthesesAroundCondition:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,6 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
- "experimental"
|
|
||||||
- "experimental2"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
@ -37,15 +35,13 @@ jobs:
|
||||||
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@v6
|
- uses: https://code.forgejo.org/docker/build-push-action@v5
|
||||||
name: Build images
|
name: Build images
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
# cache-from: type=gha
|
|
||||||
# cache-to: type=gha,mode=max
|
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
"release=1"
|
"release=1"
|
||||||
|
|
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -6,7 +6,7 @@ docker/ @unixfox
|
||||||
kubernetes/ @unixfox
|
kubernetes/ @unixfox
|
||||||
|
|
||||||
README.md @thefrenchghosty
|
README.md @thefrenchghosty
|
||||||
config/config.example.yml @SamantazFox @unixfox
|
config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
|
||||||
|
|
||||||
scripts/ @syeopite
|
scripts/ @syeopite
|
||||||
shards.lock @syeopite
|
shards.lock @syeopite
|
||||||
|
|
8
.github/workflows/build-stable-container.yml
vendored
8
.github/workflows/build-stable-container.yml
vendored
|
@ -1,7 +1,6 @@
|
||||||
name: Build and release container
|
name: Build and release container
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
@ -47,11 +46,9 @@ jobs:
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: quay.io/invidious/invidious
|
images: quay.io/invidious/invidious
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=raw,value=latest
|
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
labels: |
|
labels: |
|
||||||
quay.expires-after=12w
|
quay.expires-after=12w
|
||||||
|
|
||||||
|
@ -73,11 +70,10 @@ jobs:
|
||||||
with:
|
with:
|
||||||
images: quay.io/invidious/invidious
|
images: quay.io/invidious/invidious
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
|
||||||
suffix=-arm64
|
suffix=-arm64
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=raw,value=latest
|
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
labels: |
|
labels: |
|
||||||
quay.expires-after=12w
|
quay.expires-after=12w
|
||||||
|
|
||||||
|
|
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
|
@ -38,11 +38,10 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
stable: [true]
|
stable: [true]
|
||||||
crystal:
|
crystal:
|
||||||
|
- 1.9.2
|
||||||
- 1.10.1
|
- 1.10.1
|
||||||
- 1.11.2
|
- 1.11.2
|
||||||
- 1.12.1
|
- 1.12.1
|
||||||
- 1.13.2
|
|
||||||
- 1.14.0
|
|
||||||
include:
|
include:
|
||||||
- crystal: nightly
|
- crystal: nightly
|
||||||
stable: false
|
stable: false
|
||||||
|
@ -52,11 +51,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Install required APT packages
|
|
||||||
run: |
|
|
||||||
sudo apt install -y libsqlite3-dev
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install Crystal
|
- name: Install Crystal
|
||||||
uses: crystal-lang/install-crystal@v1.8.0
|
uses: crystal-lang/install-crystal@v1.8.0
|
||||||
with:
|
with:
|
||||||
|
@ -65,9 +59,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
|
||||||
|
@ -79,6 +71,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
|
||||||
|
|
||||||
|
@ -124,12 +124,8 @@ 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:
|
||||||
|
@ -149,18 +145,7 @@ jobs:
|
||||||
key: shards-${{ hashFiles('shard.lock') }}
|
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
|
||||||
|
|
13
.github/workflows/stale.yml
vendored
13
.github/workflows/stale.yml
vendored
|
@ -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"
|
||||||
|
|
329
CHANGELOG.md
329
CHANGELOG.md
|
@ -1,333 +1,6 @@
|
||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## vX.Y.0 (future)
|
## 2024-04-26
|
||||||
|
|
||||||
|
|
||||||
## v2.20241110.0
|
|
||||||
|
|
||||||
### Wrap-up
|
|
||||||
|
|
||||||
This release is most importantly here to fix to the annoying "Youtube API returned error 400"
|
|
||||||
error that prevented all channel pages from loading.
|
|
||||||
|
|
||||||
If you're updating from the previous release, it provides no improvements on the ability to play
|
|
||||||
videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
|
|
||||||
by a previous attempt at restoring video playback on large instances.
|
|
||||||
|
|
||||||
In the preferences, a new option allows for control of video preload. When enabled, this option
|
|
||||||
tells the browser to load the video as soon as the page is loaded (this used to be the default).
|
|
||||||
When disabled, the video starts loading only when the "play" button is pressed.
|
|
||||||
|
|
||||||
New interface languages available: Bulgarian, Welsh and Lombard
|
|
||||||
|
|
||||||
New dependency required: `tzdata`.
|
|
||||||
|
|
||||||
An HTTP proxy can be configured directly in Invidious, if needed. \
|
|
||||||
**NOTE:** In that case, it is recommended to comment out `force_resolve`.
|
|
||||||
|
|
||||||
|
|
||||||
### New features & important changes
|
|
||||||
|
|
||||||
#### For users
|
|
||||||
|
|
||||||
* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
|
|
||||||
* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
|
|
||||||
* Preferences: Addition of the new "preload" option
|
|
||||||
* New interface languages available: Bulgarian, Welsh and Lombard
|
|
||||||
* Added "Filipino (auto-generated)" to the list of caption languages available
|
|
||||||
* Lots of new translations from Weblate
|
|
||||||
|
|
||||||
#### For instance owners
|
|
||||||
|
|
||||||
* Allow the configuration of an HTTP proxy to talk to Youtube
|
|
||||||
* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
|
|
||||||
* The instance list is downloaded in the background to improve redirection speed
|
|
||||||
* New `colorize_logs` option makes each log level a different color
|
|
||||||
|
|
||||||
#### For developpers
|
|
||||||
|
|
||||||
* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
|
|
||||||
`newest`, `oldest` and `popular`
|
|
||||||
* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
|
|
||||||
* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
|
|
||||||
`is3d` and `hasCaptions`
|
|
||||||
|
|
||||||
### Bugs fixed
|
|
||||||
|
|
||||||
#### User-side
|
|
||||||
|
|
||||||
* Channels: The second page of shorts now loads as expected
|
|
||||||
* Channels: Fixed intermittent empty "playlists" tab
|
|
||||||
* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
|
|
||||||
* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
|
|
||||||
* Switching to another instance is much faster
|
|
||||||
* Fixed an "invalid byte sequence" error when subscribing to a playlist
|
|
||||||
* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
|
|
||||||
|
|
||||||
#### For instance owners
|
|
||||||
|
|
||||||
* Fix `force_resolve` being ignored in some cases
|
|
||||||
|
|
||||||
#### API
|
|
||||||
|
|
||||||
* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
|
|
||||||
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
|
||||||
|
|
||||||
* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha)
|
|
||||||
* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox)
|
|
||||||
* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox)
|
|
||||||
* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox)
|
|
||||||
* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox)
|
|
||||||
* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue)
|
|
||||||
* Stale bot updates ([#5060], thanks @syeopite)
|
|
||||||
* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox)
|
|
||||||
* Channels: Fix for live videos ([#5027], thanks @iBicha)
|
|
||||||
* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox)
|
|
||||||
* Shards: Update database dependencies ([#5034], by @SamantazFox)
|
|
||||||
* Logger: Add color support for different log levels ([#4931], thanks @Fijxu)
|
|
||||||
* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite)
|
|
||||||
* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite)
|
|
||||||
* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox)
|
|
||||||
* Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox)
|
|
||||||
* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu)
|
|
||||||
* Fix player menus hiding onHover ready ([#4750], thanks @giacomocerquone)
|
|
||||||
* Use connection pools when requesting images from YouTube ([#4326], thanks @syeopite)
|
|
||||||
* Add support for using Invidious through a HTTP Proxy ([#4270], thanks @syeopite)
|
|
||||||
* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox)
|
|
||||||
* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
|
|
||||||
* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
|
|
||||||
* Parse more metadata badges for SearchVideos ([#4863], thanks @ChunkyProgrammer)
|
|
||||||
* Translations update from Hosted Weblate ([#4862], thanks to our many translators)
|
|
||||||
* Videos: Convert URL before putting result into cache ([#4850], by @SamantazFox)
|
|
||||||
* HTML: Add error message to "search issues on GitHub" link ([#4652], thanks @tracedgod)
|
|
||||||
* Preferences: Add option to control preloading of video data ([#4122], thanks @Nerdmind)
|
|
||||||
* Performance: Improve speed of automatic instance redirection ([#4193], thanks @syeopite)
|
|
||||||
* Remove myself from CODEOWNERS on the config file ([#4942], by @TheFrenchGhosty)
|
|
||||||
* Update latest version WEB_CREATOR + fix comment web embed ([#4930], thanks @unixfox)
|
|
||||||
* use WEB_CREATOR when po_token with WEB_EMBED as a fallback ([#4928], thanks @unixfox)
|
|
||||||
* Revert "use web screen embed for fixing potoken functionality"
|
|
||||||
* use web screen embed for fixing potoken functionality ([#4923], thanks @unixfox)
|
|
||||||
|
|
||||||
[#4122]: https://github.com/iv-org/invidious/pull/4122
|
|
||||||
[#4193]: https://github.com/iv-org/invidious/pull/4193
|
|
||||||
[#4270]: https://github.com/iv-org/invidious/pull/4270
|
|
||||||
[#4326]: https://github.com/iv-org/invidious/pull/4326
|
|
||||||
[#4652]: https://github.com/iv-org/invidious/pull/4652
|
|
||||||
[#4709]: https://github.com/iv-org/invidious/pull/4709
|
|
||||||
[#4750]: https://github.com/iv-org/invidious/pull/4750
|
|
||||||
[#4754]: https://github.com/iv-org/invidious/pull/4754
|
|
||||||
[#4850]: https://github.com/iv-org/invidious/pull/4850
|
|
||||||
[#4862]: https://github.com/iv-org/invidious/pull/4862
|
|
||||||
[#4863]: https://github.com/iv-org/invidious/pull/4863
|
|
||||||
[#4887]: https://github.com/iv-org/invidious/pull/4887
|
|
||||||
[#4888]: https://github.com/iv-org/invidious/pull/4888
|
|
||||||
[#4894]: https://github.com/iv-org/invidious/pull/4894
|
|
||||||
[#4923]: https://github.com/iv-org/invidious/pull/4923
|
|
||||||
[#4928]: https://github.com/iv-org/invidious/pull/4928
|
|
||||||
[#4930]: https://github.com/iv-org/invidious/pull/4930
|
|
||||||
[#4931]: https://github.com/iv-org/invidious/pull/4931
|
|
||||||
[#4934]: https://github.com/iv-org/invidious/pull/4934
|
|
||||||
[#4942]: https://github.com/iv-org/invidious/pull/4942
|
|
||||||
[#4984]: https://github.com/iv-org/invidious/pull/4984
|
|
||||||
[#4991]: https://github.com/iv-org/invidious/pull/4991
|
|
||||||
[#4993]: https://github.com/iv-org/invidious/pull/4993
|
|
||||||
[#4995]: https://github.com/iv-org/invidious/pull/4995
|
|
||||||
[#5027]: https://github.com/iv-org/invidious/pull/5027
|
|
||||||
[#5034]: https://github.com/iv-org/invidious/pull/5034
|
|
||||||
[#5045]: https://github.com/iv-org/invidious/pull/5045
|
|
||||||
[#5046]: https://github.com/iv-org/invidious/pull/5046
|
|
||||||
[#5059]: https://github.com/iv-org/invidious/pull/5059
|
|
||||||
[#5060]: https://github.com/iv-org/invidious/pull/5060
|
|
||||||
[#5063]: https://github.com/iv-org/invidious/pull/5063
|
|
||||||
[#5070]: https://github.com/iv-org/invidious/pull/5070
|
|
||||||
[#5071]: https://github.com/iv-org/invidious/pull/5071
|
|
||||||
|
|
||||||
|
|
||||||
## v2.20240825.2 (2024-08-26)
|
|
||||||
|
|
||||||
This releases fixes the container tags pushed on quay.io.
|
|
||||||
Previously, the ARM64 build was released under the `latest` tag, instead of `latest-arm64`.
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
|
||||||
|
|
||||||
CI: Fix docker container tags ([#4883], by @SamantazFox)
|
|
||||||
|
|
||||||
[#4877]: https://github.com/iv-org/invidious/pull/4877
|
|
||||||
|
|
||||||
|
|
||||||
## v2.20240825.1 (2024-08-25)
|
|
||||||
|
|
||||||
Add patch component to be [semver] compliant and make github actions happy.
|
|
||||||
|
|
||||||
[semver]: https://semver.org/
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
|
||||||
|
|
||||||
Allow manual trigger of release-container build ([#4877], thanks @syeopite)
|
|
||||||
|
|
||||||
[#4877]: https://github.com/iv-org/invidious/pull/4877
|
|
||||||
|
|
||||||
|
|
||||||
## v2.20240825.0 (2024-08-25)
|
|
||||||
|
|
||||||
### New features & important changes
|
|
||||||
|
|
||||||
#### For users
|
|
||||||
|
|
||||||
* The search bar now has a button that you can click!
|
|
||||||
* Youtube URLs can be pasted directly in the search bar. Prepend search query with a
|
|
||||||
backslash (`\`) to disable that feature (useful if you need to search for a video whose
|
|
||||||
title contains some youtube URL).
|
|
||||||
* On the channel page the "streams" tab can be sorted by either: "newest", "oldest" or "popular"
|
|
||||||
* Lots of translations have been updated (thanks to our contributors on Weblate!)
|
|
||||||
* Videos embedded in local HTML files (e.g: a webpage saved from a blog) can now be played
|
|
||||||
|
|
||||||
#### For instance owners
|
|
||||||
|
|
||||||
* Invidious now has the ability to provide a `po_token` and `visitordata` to Youtube in order to
|
|
||||||
circumvent current Youtube restrictions.
|
|
||||||
* Invidious can use an (optional) external signature server like [inv_sig_helper]. Please note that
|
|
||||||
some videos can't be played without that signature server.
|
|
||||||
* The Helm charts were moved to a separate repo: https://github.com/iv-org/invidious-helm-chart
|
|
||||||
* We have changed how containers are released: the `latest` tag now tracks tagged releases, whereas
|
|
||||||
the `master` tag tracks the most recent commits of the `master` branch ("nightly" builds).
|
|
||||||
|
|
||||||
[inv_sig_helper]: https://github.com/iv-org/inv_sig_helper
|
|
||||||
|
|
||||||
#### For developpers
|
|
||||||
|
|
||||||
* The versions of Crystal that we test in CI/CD are now: `1.9.2`, `1.10.1`, `1.11.2`, `1.12.1`.
|
|
||||||
Please note that due to a bug in the `libxml` bindings (See [#4256]), versions prior to `1.10.0`
|
|
||||||
are not recommended to use.
|
|
||||||
* Thanks to @syeopite, the code is now [ameba] compliant.
|
|
||||||
* Ameba is part of our CI/CD pipeline, and its rules will be enforced in future PRs.
|
|
||||||
* The transcript code has been rewritten to permit transcripts as a feature rather than being
|
|
||||||
only a workaround for captions. Trancripts feature is coming soon!
|
|
||||||
* Various fixes regarding the logic interacting with Youtube
|
|
||||||
* The `sort_by` parameter can be used on the `/api/v1/channels/{id}/streams` endpoint. Accepted
|
|
||||||
values are: "newest", "oldest" and "popular"
|
|
||||||
|
|
||||||
[ameba]: https://github.com/crystal-ameba/ameba
|
|
||||||
[#4256]: https://github.com/iv-org/invidious/issues/4256
|
|
||||||
|
|
||||||
|
|
||||||
### Bugs fixed
|
|
||||||
|
|
||||||
#### User-side
|
|
||||||
|
|
||||||
* Channels: fixed broken "subscribers" and "views" counters
|
|
||||||
* Watch page: playback position is reset at the end of a video, so that the next time this video
|
|
||||||
is watched, it will start from the beginning rather than 15 seconds before the end
|
|
||||||
* Watch page: the items in the "add to playlist" drop down are now sorted alphabetically
|
|
||||||
* Videos: the "genre" URL is now always pointing to a valid webpage
|
|
||||||
* Playlists: Fixed `Could not parse N episodes` error on podcast playlists
|
|
||||||
* All external links should now have the [`rel`] attibute set to `noreferrer noopener` for
|
|
||||||
increased privacy.
|
|
||||||
* Preferences: Fixed the admin-only "modified source code" input being ignored
|
|
||||||
* Watch/channel pages: use the full image URL in `og:image` and `twitter:image` meta tags
|
|
||||||
|
|
||||||
[`rel`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel
|
|
||||||
|
|
||||||
#### API
|
|
||||||
|
|
||||||
* fixed the `local` parameter not applying to `formatStreams` on `/api/v1/videos/{id}`
|
|
||||||
* fixed an `Index out of bounds` error hapenning when a playlist had no videos
|
|
||||||
* fixed duplicated query parameters in proxied video URLs
|
|
||||||
* Return actual video height/width/fps rather than hard coded values
|
|
||||||
* Fixed the `/api/v1/popular` endpoint not returning a proper error code/message when the
|
|
||||||
popular page/endpoint are disabled.
|
|
||||||
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
|
||||||
|
|
||||||
* HTML: Sort playlists alphabetically in watch page drop down ([#4853], by @SamantazFox)
|
|
||||||
* Videos: Fix XSS vulnerability in description/comments ([#4852], thanks _anonymous_)
|
|
||||||
* YtAPI: Bump client versions ([#4849], by @SamantazFox)
|
|
||||||
* SigHelper: Fix inverted time comparison in 'check_update' ([#4845], by @SamantazFox)
|
|
||||||
* Storyboards: Various fixes and code cleaning ([#4153], by SamantazFox)
|
|
||||||
* Fix lint errors introduced in #4146 and #4295 ([#4876], thanks @syeopite)
|
|
||||||
* Search: Add support for Youtube URLs ([#4146], by @SamantazFox)
|
|
||||||
* Channel: Render age restricted channels ([#4295], thanks @ChunkyProgrammer)
|
|
||||||
* Ameba: Miscellaneous fixes ([#4807], thanks @syeopite)
|
|
||||||
* API: Proxy formatStreams URLs too ([#4859], thanks @colinleroy)
|
|
||||||
* UI: Add search button to search bar ([#4706], thanks @thansk)
|
|
||||||
* Add ability to set po_token and visitordata ID ([#4789], thanks @unixfox)
|
|
||||||
* Add support for an external signature server ([#4772], by @SamantazFox)
|
|
||||||
* Ameba: Fix Naming/VariableNames ([#4790], thanks @syeopite)
|
|
||||||
* Translations update from Hosted Weblate ([#4659])
|
|
||||||
* Ameba: Fix Lint/UselessAssign ([#4795], thanks @syeopite)
|
|
||||||
* HTML: Add rel="noreferrer noopener" to external links ([#4667], thanks @ulmemxpoc)
|
|
||||||
* Remove unused methods in Invidious::LogHandler ([#4812], thanks @syeopite)
|
|
||||||
* Ameba: Fix Lint/NotNilAfterNoBang ([#4796], thanks @syeopite)
|
|
||||||
* Ameba: Fix unused argument Lint warnings ([#4805], thanks @syeopite)
|
|
||||||
* Ameba: i18next.cr fixes ([#4806], thanks @syeopite)
|
|
||||||
* Ameba: Disable rules ([#4792], thanks @syeopite)
|
|
||||||
* Channel: parse subscriber count and channel banner ([#4785], thanks @ChunkyProgrammer)
|
|
||||||
* Player: Fix playback position of already watched videos ([#4731], thanks @Fijxu)
|
|
||||||
* Videos: Fix genre url being unusable ([#4717], thanks @meatball133)
|
|
||||||
* API: Fix out of bound error on empty playlists ([#4696], thanks @Fijxu)
|
|
||||||
* Handle playlists cataloged as Podcast ([#4695], thanks @Fijxu)
|
|
||||||
* API: Fix duplicated query parameters in proxied video URLs ([#4587], thanks @absidue)
|
|
||||||
* API: Return actual stream height, width and fps ([#4586], thanks @absidue)
|
|
||||||
* Preferences: Fix handling of modified source code URL ([#4437], thanks @nooptek)
|
|
||||||
* API: Fix URL for vtt subtitles ([#4221], thanks @karelrooted)
|
|
||||||
* Channels: Add sort options to streams ([#4224], thanks @src-tinkerer)
|
|
||||||
* API: Fix error code for disabled popular endpoint ([#4296], thanks @iBicha)
|
|
||||||
* Allow embedding videos in local HTML files ([#4450], thanks @tomasz1986)
|
|
||||||
* CI: Bump Crystal version matrix ([#4654], by @SamantazFox)
|
|
||||||
* YtAPI: Remove API keys like official clients ([#4655], by @SamantazFox)
|
|
||||||
* HTML: Use full URL in the og:image property ([#4675], thanks @Fijxu)
|
|
||||||
* Rewrite transcript logic to be more generic ([#4747], thanks @syeopite)
|
|
||||||
* CI: Run Ameba ([#4753], thanks @syeopite)
|
|
||||||
* CI: Add release based containers ([#4763], thanks @syeopite)
|
|
||||||
* move helm chart to a dedicated github repository ([#4711], thanks @unixfox)
|
|
||||||
|
|
||||||
[#4146]: https://github.com/iv-org/invidious/pull/4146
|
|
||||||
[#4153]: https://github.com/iv-org/invidious/pull/4153
|
|
||||||
[#4221]: https://github.com/iv-org/invidious/pull/4221
|
|
||||||
[#4224]: https://github.com/iv-org/invidious/pull/4224
|
|
||||||
[#4295]: https://github.com/iv-org/invidious/pull/4295
|
|
||||||
[#4296]: https://github.com/iv-org/invidious/pull/4296
|
|
||||||
[#4437]: https://github.com/iv-org/invidious/pull/4437
|
|
||||||
[#4450]: https://github.com/iv-org/invidious/pull/4450
|
|
||||||
[#4586]: https://github.com/iv-org/invidious/pull/4586
|
|
||||||
[#4587]: https://github.com/iv-org/invidious/pull/4587
|
|
||||||
[#4654]: https://github.com/iv-org/invidious/pull/4654
|
|
||||||
[#4655]: https://github.com/iv-org/invidious/pull/4655
|
|
||||||
[#4659]: https://github.com/iv-org/invidious/pull/4659
|
|
||||||
[#4667]: https://github.com/iv-org/invidious/pull/4667
|
|
||||||
[#4675]: https://github.com/iv-org/invidious/pull/4675
|
|
||||||
[#4695]: https://github.com/iv-org/invidious/pull/4695
|
|
||||||
[#4696]: https://github.com/iv-org/invidious/pull/4696
|
|
||||||
[#4706]: https://github.com/iv-org/invidious/pull/4706
|
|
||||||
[#4711]: https://github.com/iv-org/invidious/pull/4711
|
|
||||||
[#4717]: https://github.com/iv-org/invidious/pull/4717
|
|
||||||
[#4731]: https://github.com/iv-org/invidious/pull/4731
|
|
||||||
[#4747]: https://github.com/iv-org/invidious/pull/4747
|
|
||||||
[#4753]: https://github.com/iv-org/invidious/pull/4753
|
|
||||||
[#4763]: https://github.com/iv-org/invidious/pull/4763
|
|
||||||
[#4772]: https://github.com/iv-org/invidious/pull/4772
|
|
||||||
[#4785]: https://github.com/iv-org/invidious/pull/4785
|
|
||||||
[#4789]: https://github.com/iv-org/invidious/pull/4789
|
|
||||||
[#4790]: https://github.com/iv-org/invidious/pull/4790
|
|
||||||
[#4792]: https://github.com/iv-org/invidious/pull/4792
|
|
||||||
[#4795]: https://github.com/iv-org/invidious/pull/4795
|
|
||||||
[#4796]: https://github.com/iv-org/invidious/pull/4796
|
|
||||||
[#4805]: https://github.com/iv-org/invidious/pull/4805
|
|
||||||
[#4806]: https://github.com/iv-org/invidious/pull/4806
|
|
||||||
[#4807]: https://github.com/iv-org/invidious/pull/4807
|
|
||||||
[#4812]: https://github.com/iv-org/invidious/pull/4812
|
|
||||||
[#4845]: https://github.com/iv-org/invidious/pull/4845
|
|
||||||
[#4849]: https://github.com/iv-org/invidious/pull/4849
|
|
||||||
[#4852]: https://github.com/iv-org/invidious/pull/4852
|
|
||||||
[#4853]: https://github.com/iv-org/invidious/pull/4853
|
|
||||||
[#4859]: https://github.com/iv-org/invidious/pull/4859
|
|
||||||
[#4876]: https://github.com/iv-org/invidious/pull/4876
|
|
||||||
|
|
||||||
|
|
||||||
## v2.20240427 (2024-04-27)
|
|
||||||
|
|
||||||
Major bug fixes:
|
Major bug fixes:
|
||||||
* Videos: Use android test suite client (#4650, thanks @SamantazFox)
|
* Videos: Use android test suite client (#4650, thanks @SamantazFox)
|
||||||
|
|
9
Makefile
9
Makefile
|
@ -7,11 +7,6 @@ STATIC := 0
|
||||||
|
|
||||||
NO_DBG_SYMBOLS := 0
|
NO_DBG_SYMBOLS := 0
|
||||||
|
|
||||||
# Enable multi-threading.
|
|
||||||
# Warning: Experimental feature!!
|
|
||||||
# invidious is not stable when MT is enabled.
|
|
||||||
MT := 0
|
|
||||||
|
|
||||||
|
|
||||||
FLAGS ?=
|
FLAGS ?=
|
||||||
|
|
||||||
|
@ -24,10 +19,6 @@ ifeq ($(STATIC), 1)
|
||||||
FLAGS += --static
|
FLAGS += --static
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(MT), 1)
|
|
||||||
FLAGS += -Dpreview_mt
|
|
||||||
endif
|
|
||||||
|
|
||||||
|
|
||||||
ifeq ($(NO_DBG_SYMBOLS), 1)
|
ifeq ($(NO_DBG_SYMBOLS), 1)
|
||||||
FLAGS += --no-debug
|
FLAGS += --no-debug
|
||||||
|
|
|
@ -68,7 +68,6 @@
|
||||||
|
|
||||||
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
|
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
padding-top: 2em
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px;
|
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px;
|
||||||
|
|
|
@ -3,6 +3,7 @@ var player_data = JSON.parse(document.getElementById('player_data').textContent)
|
||||||
var video_data = JSON.parse(document.getElementById('video_data').textContent);
|
var video_data = JSON.parse(document.getElementById('video_data').textContent);
|
||||||
|
|
||||||
var options = {
|
var options = {
|
||||||
|
preload: 'auto',
|
||||||
liveui: true,
|
liveui: true,
|
||||||
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0],
|
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0],
|
||||||
controlBar: {
|
controlBar: {
|
||||||
|
@ -50,8 +51,8 @@ videojs.Vhs.xhr.beforeRequest = function(options) {
|
||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
videojs.Vhs.GOAL_BUFFER_LENGTH = 40;
|
videojs.Vhs.GOAL_BUFFER_LENGTH = 20;
|
||||||
videojs.Vhs.MAX_GOAL_BUFFER_LENGTH = 80;
|
videojs.Vhs.MAX_GOAL_BUFFER_LENGTH = 30;
|
||||||
|
|
||||||
var player = videojs('player', options);
|
var player = videojs('player', options);
|
||||||
|
|
||||||
|
|
|
@ -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!!"
|
|
||||||
|
|
||||||
#########################################
|
#########################################
|
||||||
#
|
#
|
||||||
|
@ -220,17 +173,6 @@ https_only: false
|
||||||
##
|
##
|
||||||
#force_resolve:
|
#force_resolve:
|
||||||
|
|
||||||
##
|
|
||||||
## Configuration for using a HTTP proxy
|
|
||||||
##
|
|
||||||
## If unset, then no HTTP proxy will be used.
|
|
||||||
##
|
|
||||||
http_proxy:
|
|
||||||
user:
|
|
||||||
password:
|
|
||||||
host:
|
|
||||||
port:
|
|
||||||
|
|
||||||
|
|
||||||
##
|
##
|
||||||
## Use Innertube's transcripts API instead of timedtext for closed captions
|
## Use Innertube's transcripts API instead of timedtext for closed captions
|
||||||
|
@ -282,13 +224,11 @@ http_proxy:
|
||||||
|
|
||||||
##
|
##
|
||||||
## 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
|
||||||
|
|
||||||
|
@ -839,22 +779,6 @@ default_user_preferences:
|
||||||
# Video player behavior
|
# Video player behavior
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
##
|
|
||||||
## This option controls the value of the HTML5 <video> element's
|
|
||||||
## "preload" attribute.
|
|
||||||
##
|
|
||||||
## If set to 'false', no video data will be loaded until the user
|
|
||||||
## explicitly starts the video by clicking the "Play" button.
|
|
||||||
## If set to 'true', the web browser will buffer some video data
|
|
||||||
## while the page is loading.
|
|
||||||
##
|
|
||||||
## See: https://www.w3schools.com/tags/att_video_preload.asp
|
|
||||||
##
|
|
||||||
## Accepted values: true, false
|
|
||||||
## Default: true
|
|
||||||
##
|
|
||||||
#preload: true
|
|
||||||
|
|
||||||
##
|
##
|
||||||
## Automatically play videos on page load.
|
## Automatically play videos on page load.
|
||||||
##
|
##
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
import lldb
|
|
||||||
|
|
||||||
class CrystalArraySyntheticProvider:
|
|
||||||
def __init__(self, valobj, internal_dict):
|
|
||||||
self.valobj = valobj
|
|
||||||
self.buffer = None
|
|
||||||
self.size = 0
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
if self.valobj.type.is_pointer:
|
|
||||||
self.valobj = self.valobj.Dereference()
|
|
||||||
self.size = int(self.valobj.child[0].value)
|
|
||||||
self.type = self.valobj.type
|
|
||||||
self.buffer = self.valobj.child[3]
|
|
||||||
|
|
||||||
def num_children(self):
|
|
||||||
size = 0 if self.size is None else self.size
|
|
||||||
return size
|
|
||||||
|
|
||||||
def get_child_index(self, name):
|
|
||||||
try:
|
|
||||||
return int(name.lstrip('[').rstrip(']'))
|
|
||||||
except:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
def get_child_at_index(self,index):
|
|
||||||
if index >= self.size:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
elementType = self.buffer.type.GetPointeeType()
|
|
||||||
offset = elementType.size * index
|
|
||||||
return self.buffer.CreateChildAtOffset('[' + str(index) + ']', offset, elementType)
|
|
||||||
except Exception as e:
|
|
||||||
print('Got exception %s' % (str(e)))
|
|
||||||
return None
|
|
||||||
|
|
||||||
def findType(name, module):
|
|
||||||
cachedTypes = module.GetTypes()
|
|
||||||
for idx in range(cachedTypes.GetSize()):
|
|
||||||
type = cachedTypes.GetTypeAtIndex(idx)
|
|
||||||
if type.name == name:
|
|
||||||
return type
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def CrystalString_SummaryProvider(value, dict):
|
|
||||||
error = lldb.SBError()
|
|
||||||
if value.TypeIsPointerType():
|
|
||||||
value = value.Dereference()
|
|
||||||
process = value.GetTarget().GetProcess()
|
|
||||||
byteSize = int(value.child[0].value)
|
|
||||||
len = int(value.child[1].value)
|
|
||||||
len = byteSize or len
|
|
||||||
strAddr = value.child[2].load_addr
|
|
||||||
val = process.ReadCStringFromMemory(strAddr, len + 1, error)
|
|
||||||
return '"%s"' % val
|
|
||||||
|
|
||||||
|
|
||||||
def __lldb_init_module(debugger, dict):
|
|
||||||
debugger.HandleCommand(r'type synthetic add -l crystal_formatters.CrystalArraySyntheticProvider -x "^Array\(.+\)(\s*\**)?" -w Crystal')
|
|
||||||
debugger.HandleCommand(r'type summary add -F crystal_formatters.CrystalString_SummaryProvider -x "^(String|\(String \| Nil\))(\s*\**)?$" -w Crystal')
|
|
||||||
debugger.HandleCommand(r'type category enable Crystal')
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM mirror.gcr.io/crystallang/crystal:1.14.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
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ RUN crystal spec --warnings all \
|
||||||
--link-flags "-lxml2 -llzma"
|
--link-flags "-lxml2 -llzma"
|
||||||
RUN if [[ "${release}" == 1 ]] ; then \
|
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 \
|
else \
|
||||||
|
@ -32,8 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
|
||||||
--link-flags "-lxml2 -llzma"; \
|
--link-flags "-lxml2 -llzma"; \
|
||||||
fi
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
@ -33,8 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
|
||||||
--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
|
||||||
|
|
|
@ -483,7 +483,7 @@
|
||||||
"comments_view_x_replies_3": "عرض رد {{count}}",
|
"comments_view_x_replies_3": "عرض رد {{count}}",
|
||||||
"comments_view_x_replies_4": "عرض الردود {{count}}",
|
"comments_view_x_replies_4": "عرض الردود {{count}}",
|
||||||
"comments_view_x_replies_5": "عرض رد {{count}}",
|
"comments_view_x_replies_5": "عرض رد {{count}}",
|
||||||
"search_message_use_another_instance": "يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
|
"search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
|
||||||
"comments_points_count_0": "{{count}} نقطة",
|
"comments_points_count_0": "{{count}} نقطة",
|
||||||
"comments_points_count_1": "نقطة واحدة",
|
"comments_points_count_1": "نقطة واحدة",
|
||||||
"comments_points_count_2": "نقطتان",
|
"comments_points_count_2": "نقطتان",
|
||||||
|
|
|
@ -471,7 +471,7 @@
|
||||||
"search_filters_title": "Filtry",
|
"search_filters_title": "Filtry",
|
||||||
"search_filters_duration_option_medium": "Střední (4 - 20 minut)",
|
"search_filters_duration_option_medium": "Střední (4 - 20 minut)",
|
||||||
"search_filters_duration_option_long": "Dlouhá (> 20 minut)",
|
"search_filters_duration_option_long": "Dlouhá (> 20 minut)",
|
||||||
"search_message_use_another_instance": "Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.",
|
"search_message_use_another_instance": " Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.",
|
||||||
"search_filters_features_label": "Vlastnosti",
|
"search_filters_features_label": "Vlastnosti",
|
||||||
"search_filters_features_option_three_sixty": "360°",
|
"search_filters_features_option_three_sixty": "360°",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
|
|
|
@ -47,7 +47,6 @@
|
||||||
"Preferences": "Einstellungen",
|
"Preferences": "Einstellungen",
|
||||||
"preferences_category_player": "Wiedergabeeinstellungen",
|
"preferences_category_player": "Wiedergabeeinstellungen",
|
||||||
"preferences_video_loop_label": "Immer wiederholen: ",
|
"preferences_video_loop_label": "Immer wiederholen: ",
|
||||||
"preferences_preload_label": "Videodaten vorladen: ",
|
|
||||||
"preferences_autoplay_label": "Automatisch abspielen: ",
|
"preferences_autoplay_label": "Automatisch abspielen: ",
|
||||||
"preferences_continue_label": "Immer automatisch nächstes Video abspielen: ",
|
"preferences_continue_label": "Immer automatisch nächstes Video abspielen: ",
|
||||||
"preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ",
|
"preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ",
|
||||||
|
@ -323,7 +322,7 @@
|
||||||
"channel_tab_community_label": "Gemeinschaft",
|
"channel_tab_community_label": "Gemeinschaft",
|
||||||
"search_filters_sort_option_relevance": "Relevanz",
|
"search_filters_sort_option_relevance": "Relevanz",
|
||||||
"search_filters_sort_option_rating": "Bewertung",
|
"search_filters_sort_option_rating": "Bewertung",
|
||||||
"search_filters_sort_option_date": "Hochladedatum",
|
"search_filters_sort_option_date": "Datum",
|
||||||
"search_filters_sort_option_views": "Aufrufe",
|
"search_filters_sort_option_views": "Aufrufe",
|
||||||
"search_filters_type_label": "Inhaltstyp",
|
"search_filters_type_label": "Inhaltstyp",
|
||||||
"search_filters_duration_label": "Dauer",
|
"search_filters_duration_label": "Dauer",
|
||||||
|
@ -455,7 +454,7 @@
|
||||||
"Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)",
|
"Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)",
|
||||||
"search_filters_title": "Filtern",
|
"search_filters_title": "Filtern",
|
||||||
"search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.",
|
"search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.",
|
||||||
"search_message_use_another_instance": "Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
|
"search_message_use_another_instance": " Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
|
||||||
"Popular enabled: ": "„Beliebt“-Seite aktiviert: ",
|
"Popular enabled: ": "„Beliebt“-Seite aktiviert: ",
|
||||||
"search_message_no_results": "Keine Ergebnisse gefunden.",
|
"search_message_no_results": "Keine Ergebnisse gefunden.",
|
||||||
"search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)",
|
"search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)",
|
||||||
|
@ -494,8 +493,5 @@
|
||||||
"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 Folie `x` gehen",
|
|
||||||
"carousel_slide": "Folie {{current}} von {{total}}",
|
|
||||||
"carousel_skip": "Karussell überspringen"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -489,10 +489,5 @@
|
||||||
"search_filters_date_label": "Ημερομηνία αναφόρτωσης",
|
"search_filters_date_label": "Ημερομηνία αναφόρτωσης",
|
||||||
"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: ": "Λίστα αναπαραγωγής: ",
|
|
||||||
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
|
|
||||||
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
|
|
||||||
"toggle_theme": "Αλλαγή θέματος"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,6 @@
|
||||||
"Preferences": "Preferences",
|
"Preferences": "Preferences",
|
||||||
"preferences_category_player": "Player preferences",
|
"preferences_category_player": "Player preferences",
|
||||||
"preferences_video_loop_label": "Always loop: ",
|
"preferences_video_loop_label": "Always loop: ",
|
||||||
"preferences_preload_label": "Preload video data: ",
|
|
||||||
"preferences_autoplay_label": "Autoplay: ",
|
"preferences_autoplay_label": "Autoplay: ",
|
||||||
"preferences_continue_label": "Play next by default: ",
|
"preferences_continue_label": "Play next by default: ",
|
||||||
"preferences_continue_autoplay_label": "Autoplay next video: ",
|
"preferences_continue_autoplay_label": "Autoplay next video: ",
|
||||||
|
@ -192,7 +191,7 @@
|
||||||
"Switch Invidious Instance": "Switch Invidious Instance",
|
"Switch Invidious Instance": "Switch Invidious Instance",
|
||||||
"search_message_no_results": "No results found.",
|
"search_message_no_results": "No results found.",
|
||||||
"search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.",
|
"search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.",
|
||||||
"search_message_use_another_instance": "You can also <a href=\"`x`\">search on another instance</a>.",
|
"search_message_use_another_instance": " You can also <a href=\"`x`\">search on another instance</a>.",
|
||||||
"Hide annotations": "Hide annotations",
|
"Hide annotations": "Hide annotations",
|
||||||
"Show annotations": "Show annotations",
|
"Show annotations": "Show annotations",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
|
@ -287,7 +286,6 @@
|
||||||
"Esperanto": "Esperanto",
|
"Esperanto": "Esperanto",
|
||||||
"Estonian": "Estonian",
|
"Estonian": "Estonian",
|
||||||
"Filipino": "Filipino",
|
"Filipino": "Filipino",
|
||||||
"Filipino (auto-generated)": "Filipino (auto-generated)",
|
|
||||||
"Finnish": "Finnish",
|
"Finnish": "Finnish",
|
||||||
"French": "French",
|
"French": "French",
|
||||||
"French (auto-generated)": "French (auto-generated)",
|
"French (auto-generated)": "French (auto-generated)",
|
||||||
|
@ -425,7 +423,7 @@
|
||||||
"search_filters_title": "Filters",
|
"search_filters_title": "Filters",
|
||||||
"search_filters_date_label": "Upload date",
|
"search_filters_date_label": "Upload date",
|
||||||
"search_filters_date_option_none": "Any date",
|
"search_filters_date_option_none": "Any date",
|
||||||
"search_filters_date_option_hour": "Last hour",
|
"search_filters_date_option_hour": "Last Hour",
|
||||||
"search_filters_date_option_today": "Today",
|
"search_filters_date_option_today": "Today",
|
||||||
"search_filters_date_option_week": "This week",
|
"search_filters_date_option_week": "This week",
|
||||||
"search_filters_date_option_month": "This month",
|
"search_filters_date_option_month": "This month",
|
||||||
|
@ -457,7 +455,7 @@
|
||||||
"search_filters_sort_label": "Sort By",
|
"search_filters_sort_label": "Sort By",
|
||||||
"search_filters_sort_option_relevance": "Relevance",
|
"search_filters_sort_option_relevance": "Relevance",
|
||||||
"search_filters_sort_option_rating": "Rating",
|
"search_filters_sort_option_rating": "Rating",
|
||||||
"search_filters_sort_option_date": "Upload date",
|
"search_filters_sort_option_date": "Upload Date",
|
||||||
"search_filters_sort_option_views": "View count",
|
"search_filters_sort_option_views": "View count",
|
||||||
"search_filters_apply_button": "Apply selected filters",
|
"search_filters_apply_button": "Apply selected filters",
|
||||||
"Current version: ": "Current version: ",
|
"Current version: ": "Current version: ",
|
||||||
|
|
|
@ -480,7 +480,7 @@
|
||||||
"tokens_count_0": "{{count}} token",
|
"tokens_count_0": "{{count}} token",
|
||||||
"tokens_count_1": "{{count}} tokens",
|
"tokens_count_1": "{{count}} tokens",
|
||||||
"tokens_count_2": "{{count}} tokens",
|
"tokens_count_2": "{{count}} tokens",
|
||||||
"search_message_use_another_instance": "También puedes <a href=\"`x`\">buscar en otra instancia</a>.",
|
"search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.",
|
||||||
"Popular enabled: ": "¿Habilitar la sección popular? ",
|
"Popular enabled: ": "¿Habilitar la sección popular? ",
|
||||||
"error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>",
|
"error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>",
|
||||||
"channel_tab_streams_label": "Directos",
|
"channel_tab_streams_label": "Directos",
|
||||||
|
|
|
@ -360,7 +360,7 @@
|
||||||
"search_filters_duration_label": "مدت",
|
"search_filters_duration_label": "مدت",
|
||||||
"search_filters_features_label": "ویژگیها",
|
"search_filters_features_label": "ویژگیها",
|
||||||
"search_filters_sort_label": "به ترتیب",
|
"search_filters_sort_label": "به ترتیب",
|
||||||
"search_filters_date_option_hour": "ساعت گذشته",
|
"search_filters_date_option_hour": "یک ساعت گذشته",
|
||||||
"search_filters_date_option_today": "امروز",
|
"search_filters_date_option_today": "امروز",
|
||||||
"search_filters_date_option_week": "این هفته",
|
"search_filters_date_option_week": "این هفته",
|
||||||
"search_filters_date_option_month": "این ماه",
|
"search_filters_date_option_month": "این ماه",
|
||||||
|
@ -461,7 +461,7 @@
|
||||||
"Song: ": "آهنگ: ",
|
"Song: ": "آهنگ: ",
|
||||||
"Channel Sponsor": "اسپانسر کانال",
|
"Channel Sponsor": "اسپانسر کانال",
|
||||||
"Standard YouTube license": "پروانه استاندارد YouTube",
|
"Standard YouTube license": "پروانه استاندارد YouTube",
|
||||||
"search_message_use_another_instance": "همچنین میتوانید <a href=\"`x`\">در نمونهای دیگر هم جستوجو کنید</a>.",
|
"search_message_use_another_instance": " شما همچنین میتوانید <a href=\"`x`\">در نمونه دیگر هم جستجو کنید</a>.",
|
||||||
"Download is disabled": "دریافت غیرفعال است",
|
"Download is disabled": "دریافت غیرفعال است",
|
||||||
"crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:",
|
"crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:",
|
||||||
"playlist_button_add_items": "افزودن ویدیو",
|
"playlist_button_add_items": "افزودن ویدیو",
|
||||||
|
|
|
@ -484,7 +484,7 @@
|
||||||
"search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)",
|
"search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)",
|
||||||
"search_filters_apply_button": "Appliquer les filtres",
|
"search_filters_apply_button": "Appliquer les filtres",
|
||||||
"search_message_no_results": "Aucun résultat.",
|
"search_message_no_results": "Aucun résultat.",
|
||||||
"search_message_use_another_instance": "Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.",
|
"search_message_use_another_instance": " Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.",
|
||||||
"search_filters_type_option_all": "Tous les types",
|
"search_filters_type_option_all": "Tous les types",
|
||||||
"search_filters_date_label": "Date d'ajout",
|
"search_filters_date_label": "Date d'ajout",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
|
|
|
@ -449,30 +449,30 @@
|
||||||
"Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
|
"Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
|
||||||
"Chinese": "Kineski",
|
"Chinese": "Kineski",
|
||||||
"Chinese (Taiwan)": "Kineski (Tajvan)",
|
"Chinese (Taiwan)": "Kineski (Tajvan)",
|
||||||
"Dutch (auto-generated)": "Nizozemski (automatski generirano)",
|
"Dutch (auto-generated)": "Nizozemski (automatski generiran)",
|
||||||
"French (auto-generated)": "Francuski (automatski generirano)",
|
"French (auto-generated)": "Francuski (automatski generiran)",
|
||||||
"Indonesian (auto-generated)": "Indonezijski (automatski generirano)",
|
"Indonesian (auto-generated)": "Indonezijski (automatski generiran)",
|
||||||
"Interlingue": "Interlingua",
|
"Interlingue": "Interlingua",
|
||||||
"Japanese (auto-generated)": "Japanski (automatski generirano)",
|
"Japanese (auto-generated)": "Japanski (automatski generiran)",
|
||||||
"Russian (auto-generated)": "Ruski (automatski generirano)",
|
"Russian (auto-generated)": "Ruski (automatski generiran)",
|
||||||
"Turkish (auto-generated)": "Turski (automatski generirano)",
|
"Turkish (auto-generated)": "Turski (automatski generiran)",
|
||||||
"Vietnamese (auto-generated)": "Vijetnamski (automatski generirano)",
|
"Vietnamese (auto-generated)": "Vijetnamski (automatski generiran)",
|
||||||
"Spanish (Spain)": "Španjolski (Španjolska)",
|
"Spanish (Spain)": "Španjolski (Španjolska)",
|
||||||
"Italian (auto-generated)": "Talijanski (automatski generirano)",
|
"Italian (auto-generated)": "Talijanski (automatski generiran)",
|
||||||
"Portuguese (Brazil)": "Portugalski (Brazil)",
|
"Portuguese (Brazil)": "Portugalski (Brazil)",
|
||||||
"Spanish (Mexico)": "Španjolski (Meksiko)",
|
"Spanish (Mexico)": "Španjolski (Meksiko)",
|
||||||
"German (auto-generated)": "Njemački (automatski generirano)",
|
"German (auto-generated)": "Njemački (automatski generiran)",
|
||||||
"Chinese (China)": "Kineski (Kina)",
|
"Chinese (China)": "Kineski (Kina)",
|
||||||
"Chinese (Hong Kong)": "Kineski (Hong Kong)",
|
"Chinese (Hong Kong)": "Kineski (Hong Kong)",
|
||||||
"Korean (auto-generated)": "Korejski (automatski generirano)",
|
"Korean (auto-generated)": "Korejski (automatski generiran)",
|
||||||
"Portuguese (auto-generated)": "Portugalski (automatski generirano)",
|
"Portuguese (auto-generated)": "Portugalski (automatski generiran)",
|
||||||
"Spanish (auto-generated)": "Španjolski (automatski generirano)",
|
"Spanish (auto-generated)": "Španjolski (automatski generiran)",
|
||||||
"preferences_watch_history_label": "Aktiviraj povijest gledanja: ",
|
"preferences_watch_history_label": "Aktiviraj povijest gledanja: ",
|
||||||
"search_filters_title": "Filtri",
|
"search_filters_title": "Filtri",
|
||||||
"search_filters_date_option_none": "Bilo koji datum",
|
"search_filters_date_option_none": "Bilo koji datum",
|
||||||
"search_filters_date_label": "Datum prijenosa",
|
"search_filters_date_label": "Datum prijenosa",
|
||||||
"search_message_no_results": "Nema rezultata.",
|
"search_message_no_results": "Nema rezultata.",
|
||||||
"search_message_use_another_instance": "Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.",
|
"search_message_use_another_instance": " Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.",
|
||||||
"search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.",
|
"search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
"search_filters_duration_option_none": "Bilo koje duljine",
|
"search_filters_duration_option_none": "Bilo koje duljine",
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
"invidious": "Invidious",
|
"invidious": "Invidious",
|
||||||
"Image CAPTCHA": "Imagine CAPTCHA",
|
"Image CAPTCHA": "Imagine CAPTCHA",
|
||||||
"newest": "plus nove",
|
"newest": "plus nove",
|
||||||
"generic_button_save": "Salveguardar",
|
"generic_button_save": "Salvar",
|
||||||
"Dark mode: ": "Modo obscur: ",
|
"Dark mode: ": "Modo obscur: ",
|
||||||
"preferences_dark_mode_label": "Thema: ",
|
"preferences_dark_mode_label": "Thema: ",
|
||||||
"preferences_category_subscription": "Preferentias de subscription",
|
"preferences_category_subscription": "Preferentias de subscription",
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
"light": "clar",
|
"light": "clar",
|
||||||
"No": "Non",
|
"No": "Non",
|
||||||
"youtube": "YouTube",
|
"youtube": "YouTube",
|
||||||
"LIVE": "IN DIRECTO",
|
"LIVE": "IN DIRECTE",
|
||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"preferences_category_player": "Preferentias de reproductor",
|
"preferences_category_player": "Preferentias de reproductor",
|
||||||
"Preferences": "Preferentias",
|
"Preferences": "Preferentias",
|
||||||
|
|
|
@ -396,7 +396,7 @@
|
||||||
"toggle_theme": "Víxla þema",
|
"toggle_theme": "Víxla þema",
|
||||||
"carousel_skip": "Sleppa hringekjunni",
|
"carousel_skip": "Sleppa hringekjunni",
|
||||||
"preferences_quality_option_medium": "Miðlungs",
|
"preferences_quality_option_medium": "Miðlungs",
|
||||||
"search_message_use_another_instance": "Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.",
|
"search_message_use_another_instance": " Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.",
|
||||||
"footer_source_code": "Grunnkóði",
|
"footer_source_code": "Grunnkóði",
|
||||||
"English (United Kingdom)": "Enska (Bretland)",
|
"English (United Kingdom)": "Enska (Bretland)",
|
||||||
"English (United States)": "Enska (Bandarísk)",
|
"English (United States)": "Enska (Bandarísk)",
|
||||||
|
|
|
@ -449,7 +449,7 @@
|
||||||
"Portuguese (Brazil)": "Portoghese (Brasile)",
|
"Portuguese (Brazil)": "Portoghese (Brasile)",
|
||||||
"preferences_watch_history_label": "Attiva cronologia di riproduzione: ",
|
"preferences_watch_history_label": "Attiva cronologia di riproduzione: ",
|
||||||
"French (auto-generated)": "Francese (generati automaticamente)",
|
"French (auto-generated)": "Francese (generati automaticamente)",
|
||||||
"search_message_use_another_instance": "Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
|
"search_message_use_another_instance": " Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
|
||||||
"search_message_no_results": "Nessun risultato trovato.",
|
"search_message_no_results": "Nessun risultato trovato.",
|
||||||
"search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.",
|
"search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.",
|
||||||
"English (United States)": "Inglese (Stati Uniti)",
|
"English (United States)": "Inglese (Stati Uniti)",
|
||||||
|
|
|
@ -363,7 +363,7 @@
|
||||||
"search_filters_features_option_location": "場所",
|
"search_filters_features_option_location": "場所",
|
||||||
"search_filters_features_option_hdr": "HDR",
|
"search_filters_features_option_hdr": "HDR",
|
||||||
"Current version: ": "現在のバージョン: ",
|
"Current version: ": "現在のバージョン: ",
|
||||||
"next_steps_error_message": "以下をお試しください: ",
|
"next_steps_error_message": "以下をお試してください: ",
|
||||||
"next_steps_error_message_refresh": "再読み込み",
|
"next_steps_error_message_refresh": "再読み込み",
|
||||||
"next_steps_error_message_go_to_youtube": "YouTubeを開く",
|
"next_steps_error_message_go_to_youtube": "YouTubeを開く",
|
||||||
"search_filters_duration_option_short": "4分未満",
|
"search_filters_duration_option_short": "4分未満",
|
||||||
|
@ -396,7 +396,7 @@
|
||||||
"download_subtitles": "字幕 - `x` (.vtt)",
|
"download_subtitles": "字幕 - `x` (.vtt)",
|
||||||
"search_filters_features_option_purchased": "購入済み",
|
"search_filters_features_option_purchased": "購入済み",
|
||||||
"preferences_quality_option_dash": "DASH (適応的画質)",
|
"preferences_quality_option_dash": "DASH (適応的画質)",
|
||||||
"preferences_quality_dash_option_worst": "最低",
|
"preferences_quality_dash_option_worst": "最悪",
|
||||||
"preferences_quality_dash_option_best": "最高",
|
"preferences_quality_dash_option_best": "最高",
|
||||||
"videoinfo_started_streaming_x_ago": "`x`前に配信を開始",
|
"videoinfo_started_streaming_x_ago": "`x`前に配信を開始",
|
||||||
"videoinfo_watch_on_youTube": "YouTubeで視聴",
|
"videoinfo_watch_on_youTube": "YouTubeで視聴",
|
||||||
|
@ -434,7 +434,7 @@
|
||||||
"crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す",
|
"crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す",
|
||||||
"crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
|
"crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
|
||||||
"Popular enabled: ": "人気動画を有効化 ",
|
"Popular enabled: ": "人気動画を有効化 ",
|
||||||
"search_message_use_another_instance": "<a href=\"`x`\">別のインスタンス上での検索</a>も可能です。",
|
"search_message_use_another_instance": " <a href=\"`x`\">別のインスタンス上での検索</a>も可能です。",
|
||||||
"search_filters_apply_button": "選択したフィルターを適用",
|
"search_filters_apply_button": "選択したフィルターを適用",
|
||||||
"user_saved_playlists": "`x`個の保存済みの再生リスト",
|
"user_saved_playlists": "`x`個の保存済みの再生リスト",
|
||||||
"crash_page_you_found_a_bug": "Invidious のバグのようです!",
|
"crash_page_you_found_a_bug": "Invidious のバグのようです!",
|
||||||
|
|
|
@ -18,8 +18,8 @@
|
||||||
"preferences_related_videos_label": "관련 동영상 보기: ",
|
"preferences_related_videos_label": "관련 동영상 보기: ",
|
||||||
"Fallback captions: ": "대체 자막: ",
|
"Fallback captions: ": "대체 자막: ",
|
||||||
"preferences_captions_label": "기본 자막: ",
|
"preferences_captions_label": "기본 자막: ",
|
||||||
"reddit": "레딧",
|
"reddit": "Reddit",
|
||||||
"youtube": "유튜브",
|
"youtube": "YouTube",
|
||||||
"preferences_comments_label": "기본 댓글: ",
|
"preferences_comments_label": "기본 댓글: ",
|
||||||
"preferences_volume_label": "플레이어 볼륨: ",
|
"preferences_volume_label": "플레이어 볼륨: ",
|
||||||
"preferences_quality_label": "선호하는 비디오 품질: ",
|
"preferences_quality_label": "선호하는 비디오 품질: ",
|
||||||
|
@ -48,7 +48,7 @@
|
||||||
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
|
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
|
||||||
"History": "시청 기록",
|
"History": "시청 기록",
|
||||||
"Delete account?": "계정을 삭제 하시겠습니까?",
|
"Delete account?": "계정을 삭제 하시겠습니까?",
|
||||||
"Export data as JSON": "인비디어스 데이터 내보내기 (.json)",
|
"Export data as JSON": "JSON으로 데이터 내보내기",
|
||||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)",
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)",
|
||||||
"Export subscriptions as OPML": "OPML로 구독 내보내기",
|
"Export subscriptions as OPML": "OPML로 구독 내보내기",
|
||||||
"Export": "내보내기",
|
"Export": "내보내기",
|
||||||
|
@ -78,10 +78,10 @@
|
||||||
"Subscribe": "구독",
|
"Subscribe": "구독",
|
||||||
"Unsubscribe": "구독 취소",
|
"Unsubscribe": "구독 취소",
|
||||||
"LIVE": "실시간",
|
"LIVE": "실시간",
|
||||||
"generic_views_count_0": "{{count}} 조회수",
|
"generic_views_count_0": "조회수 {{count}}회",
|
||||||
"generic_videos_count_0": "{{count}} 동영상",
|
"generic_videos_count_0": "동영상 {{count}}개",
|
||||||
"generic_playlists_count_0": "{{count}} 재생목록",
|
"generic_playlists_count_0": "재생목록 {{count}}개",
|
||||||
"generic_subscribers_count_0": "{{count}} 구독자",
|
"generic_subscribers_count_0": "구독자 {{count}}명",
|
||||||
"generic_subscriptions_count_0": "{{count}} 구독",
|
"generic_subscriptions_count_0": "{{count}} 구독",
|
||||||
"search_filters_type_option_playlist": "재생목록",
|
"search_filters_type_option_playlist": "재생목록",
|
||||||
"Korean": "한국어",
|
"Korean": "한국어",
|
||||||
|
@ -109,14 +109,14 @@
|
||||||
"This channel does not exist.": "이 채널은 존재하지 않습니다.",
|
"This channel does not exist.": "이 채널은 존재하지 않습니다.",
|
||||||
"Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널",
|
"Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널",
|
||||||
"channel:`x`": "채널:`x`",
|
"channel:`x`": "채널:`x`",
|
||||||
"Show replies": "댓글 보기",
|
"Show replies": "댓글 보이기",
|
||||||
"Hide replies": "댓글 숨기기",
|
"Hide replies": "댓글 숨기기",
|
||||||
"Incorrect password": "잘못된 비밀번호",
|
"Incorrect password": "잘못된 비밀번호",
|
||||||
"License: ": "라이선스: ",
|
"License: ": "라이선스: ",
|
||||||
"Genre: ": "장르: ",
|
"Genre: ": "장르: ",
|
||||||
"Editing playlist `x`": "재생목록 `x` 수정하기",
|
"Editing playlist `x`": "재생목록 `x` 수정하기",
|
||||||
"Playlist privacy": "재생목록 공개 범위",
|
"Playlist privacy": "재생목록 공개 범위",
|
||||||
"Watch on YouTube": "유튜브에서 보기",
|
"Watch on YouTube": "YouTube에서 보기",
|
||||||
"Show less": "간략히",
|
"Show less": "간략히",
|
||||||
"Show more": "더보기",
|
"Show more": "더보기",
|
||||||
"Title": "제목",
|
"Title": "제목",
|
||||||
|
@ -125,7 +125,7 @@
|
||||||
"Delete playlist": "재생목록 삭제",
|
"Delete playlist": "재생목록 삭제",
|
||||||
"Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?",
|
"Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?",
|
||||||
"Updated `x` ago": "`x` 전에 업데이트됨",
|
"Updated `x` ago": "`x` 전에 업데이트됨",
|
||||||
"Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.",
|
"Released under the AGPLv3 on Github.": "GitHub에 AGPLv3 으로 배포됩니다.",
|
||||||
"View all playlists": "모든 재생목록 보기",
|
"View all playlists": "모든 재생목록 보기",
|
||||||
"Private": "비공개",
|
"Private": "비공개",
|
||||||
"Unlisted": "목록에 없음",
|
"Unlisted": "목록에 없음",
|
||||||
|
@ -135,12 +135,12 @@
|
||||||
"Source available here.": "소스는 여기에서 사용할 수 있습니다.",
|
"Source available here.": "소스는 여기에서 사용할 수 있습니다.",
|
||||||
"Log out": "로그아웃",
|
"Log out": "로그아웃",
|
||||||
"search": "검색",
|
"search": "검색",
|
||||||
"subscriptions_unseen_notifs_count_0": "{{count}} 읽지 않은 알림",
|
"subscriptions_unseen_notifs_count_0": "읽지 않은 알림 {{count}}개",
|
||||||
"Subscriptions": "구독",
|
"Subscriptions": "구독",
|
||||||
"revoke": "철회",
|
"revoke": "철회",
|
||||||
"unsubscribe": "구독 취소",
|
"unsubscribe": "구독 취소",
|
||||||
"Import/export": "가져오기/내보내기",
|
"Import/export": "가져오기/내보내기",
|
||||||
"tokens_count_0": "{{count}} 토큰",
|
"tokens_count_0": "토큰 {{count}}개",
|
||||||
"Token": "토큰",
|
"Token": "토큰",
|
||||||
"Token manager": "토큰 관리자",
|
"Token manager": "토큰 관리자",
|
||||||
"Subscription manager": "구독 관리자",
|
"Subscription manager": "구독 관리자",
|
||||||
|
@ -163,7 +163,7 @@
|
||||||
"Clear watch history": "시청 기록 지우기",
|
"Clear watch history": "시청 기록 지우기",
|
||||||
"preferences_category_data": "데이터 설정",
|
"preferences_category_data": "데이터 설정",
|
||||||
"`x` is live": "`x` 이(가) 라이브 중입니다",
|
"`x` is live": "`x` 이(가) 라이브 중입니다",
|
||||||
"`x` uploaded a video": "`x` 동영상 게시됨",
|
"`x` uploaded a video": "`x` 이(가) 동영상을 게시했습니다",
|
||||||
"Enable web notifications": "웹 알림 활성화",
|
"Enable web notifications": "웹 알림 활성화",
|
||||||
"preferences_notifications_only_label": "알림만 표시 (있는 경우): ",
|
"preferences_notifications_only_label": "알림만 표시 (있는 경우): ",
|
||||||
"preferences_unseen_only_label": "시청하지 않은 것만 표시: ",
|
"preferences_unseen_only_label": "시청하지 않은 것만 표시: ",
|
||||||
|
@ -241,7 +241,7 @@
|
||||||
"Could not create mix.": "믹스를 생성할 수 없습니다.",
|
"Could not create mix.": "믹스를 생성할 수 없습니다.",
|
||||||
"`x` ago": "`x` 전",
|
"`x` ago": "`x` 전",
|
||||||
"comments_view_x_replies_0": "답글 {{count}}개 보기",
|
"comments_view_x_replies_0": "답글 {{count}}개 보기",
|
||||||
"View Reddit comments": "레딧 댓글 보기",
|
"View Reddit comments": "Reddit 댓글 보기",
|
||||||
"Engagement: ": "약속: ",
|
"Engagement: ": "약속: ",
|
||||||
"Wilson score: ": "Wilson Score: ",
|
"Wilson score: ": "Wilson Score: ",
|
||||||
"Family friendly? ": "전연령 영상입니까? ",
|
"Family friendly? ": "전연령 영상입니까? ",
|
||||||
|
@ -267,8 +267,8 @@
|
||||||
"Bulgarian": "불가리아어",
|
"Bulgarian": "불가리아어",
|
||||||
"Bosnian": "보스니아어",
|
"Bosnian": "보스니아어",
|
||||||
"Belarusian": "벨라루스어",
|
"Belarusian": "벨라루스어",
|
||||||
"View more comments on Reddit": "레딧에서 댓글 더 보기",
|
"View more comments on Reddit": "Reddit에서 댓글 더 보기",
|
||||||
"View YouTube comments": "유튜브 댓글 보기",
|
"View YouTube comments": "YouTube 댓글 보기",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
"Shared `x`": "`x` 업로드",
|
"Shared `x`": "`x` 업로드",
|
||||||
"Whitelisted regions: ": "차단되지 않은 지역: ",
|
"Whitelisted regions: ": "차단되지 않은 지역: ",
|
||||||
|
@ -289,7 +289,7 @@
|
||||||
"Empty playlist": "재생목록 비어 있음",
|
"Empty playlist": "재생목록 비어 있음",
|
||||||
"Show annotations": "주석 보이기",
|
"Show annotations": "주석 보이기",
|
||||||
"Hide annotations": "주석 숨기기",
|
"Hide annotations": "주석 숨기기",
|
||||||
"Switch Invidious Instance": "인비디어스 인스턴스 변경",
|
"Switch Invidious Instance": "Invidious 인스턴스 변경",
|
||||||
"Spanish": "스페인어",
|
"Spanish": "스페인어",
|
||||||
"Southern Sotho": "소토어",
|
"Southern Sotho": "소토어",
|
||||||
"Somali": "소말리어",
|
"Somali": "소말리어",
|
||||||
|
@ -329,7 +329,7 @@
|
||||||
"Swedish": "스웨덴어",
|
"Swedish": "스웨덴어",
|
||||||
"Spanish (Latin America)": "스페인어 (라틴 아메리카)",
|
"Spanish (Latin America)": "스페인어 (라틴 아메리카)",
|
||||||
"comments_points_count_0": "{{count}} 포인트",
|
"comments_points_count_0": "{{count}} 포인트",
|
||||||
"Invidious Private Feed for `x`": "`x` 에 대한 인비디어스 비공개 피드",
|
"Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드",
|
||||||
"Premieres `x`": "최초 공개 `x`",
|
"Premieres `x`": "최초 공개 `x`",
|
||||||
"Premieres in `x`": "`x` 후 최초 공개",
|
"Premieres in `x`": "`x` 후 최초 공개",
|
||||||
"next_steps_error_message": "다음 방법을 시도해 보세요: ",
|
"next_steps_error_message": "다음 방법을 시도해 보세요: ",
|
||||||
|
@ -408,7 +408,7 @@
|
||||||
"preferences_quality_dash_option_1080p": "1080p",
|
"preferences_quality_dash_option_1080p": "1080p",
|
||||||
"preferences_quality_dash_option_worst": "최저",
|
"preferences_quality_dash_option_worst": "최저",
|
||||||
"preferences_watch_history_label": "시청 기록 저장: ",
|
"preferences_watch_history_label": "시청 기록 저장: ",
|
||||||
"invidious": "인비디어스",
|
"invidious": "Invidious",
|
||||||
"preferences_quality_option_small": "낮음",
|
"preferences_quality_option_small": "낮음",
|
||||||
"preferences_quality_dash_option_auto": "자동",
|
"preferences_quality_dash_option_auto": "자동",
|
||||||
"preferences_quality_dash_option_480p": "480p",
|
"preferences_quality_dash_option_480p": "480p",
|
||||||
|
@ -453,7 +453,7 @@
|
||||||
"channel_tab_streams_label": "실시간 스트리밍",
|
"channel_tab_streams_label": "실시간 스트리밍",
|
||||||
"channel_tab_channels_label": "채널",
|
"channel_tab_channels_label": "채널",
|
||||||
"channel_tab_playlists_label": "재생목록",
|
"channel_tab_playlists_label": "재생목록",
|
||||||
"Standard YouTube license": "표준 유튜브 라이선스",
|
"Standard YouTube license": "표준 YouTube 라이선스",
|
||||||
"Song: ": "제목: ",
|
"Song: ": "제목: ",
|
||||||
"Channel Sponsor": "채널 스폰서",
|
"Channel Sponsor": "채널 스폰서",
|
||||||
"Album: ": "앨범: ",
|
"Album: ": "앨범: ",
|
||||||
|
|
|
@ -322,13 +322,13 @@
|
||||||
"channel_tab_community_label": "Gemenskap",
|
"channel_tab_community_label": "Gemenskap",
|
||||||
"search_filters_sort_option_relevance": "relevans",
|
"search_filters_sort_option_relevance": "relevans",
|
||||||
"search_filters_sort_option_rating": "vurdering",
|
"search_filters_sort_option_rating": "vurdering",
|
||||||
"search_filters_sort_option_date": "Opplastingsdato",
|
"search_filters_sort_option_date": "dato",
|
||||||
"search_filters_sort_option_views": "visninger",
|
"search_filters_sort_option_views": "visninger",
|
||||||
"search_filters_type_label": "innholdstype",
|
"search_filters_type_label": "innholdstype",
|
||||||
"search_filters_duration_label": "varighet",
|
"search_filters_duration_label": "varighet",
|
||||||
"search_filters_features_label": "funksjoner",
|
"search_filters_features_label": "funksjoner",
|
||||||
"search_filters_sort_label": "sorter",
|
"search_filters_sort_label": "sorter",
|
||||||
"search_filters_date_option_hour": "Siste time",
|
"search_filters_date_option_hour": "time",
|
||||||
"search_filters_date_option_today": "i dag",
|
"search_filters_date_option_today": "i dag",
|
||||||
"search_filters_date_option_week": "uke",
|
"search_filters_date_option_week": "uke",
|
||||||
"search_filters_date_option_month": "måned",
|
"search_filters_date_option_month": "måned",
|
||||||
|
@ -459,7 +459,7 @@
|
||||||
"search_message_no_results": "Resultatløst.",
|
"search_message_no_results": "Resultatløst.",
|
||||||
"search_filters_type_option_all": "Alle typer",
|
"search_filters_type_option_all": "Alle typer",
|
||||||
"search_filters_duration_option_none": "Enhver varighet",
|
"search_filters_duration_option_none": "Enhver varighet",
|
||||||
"search_message_use_another_instance": "Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
|
"search_message_use_another_instance": " Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
|
||||||
"search_filters_date_label": "Opplastningsdato",
|
"search_filters_date_label": "Opplastningsdato",
|
||||||
"search_filters_apply_button": "Bruk valgte filtre",
|
"search_filters_apply_button": "Bruk valgte filtre",
|
||||||
"search_filters_date_option_none": "Siden begynnelsen",
|
"search_filters_date_option_none": "Siden begynnelsen",
|
||||||
|
@ -494,7 +494,5 @@
|
||||||
"carousel_slide": "Lysark {{current}} av {{total}}",
|
"carousel_slide": "Lysark {{current}} av {{total}}",
|
||||||
"carousel_skip": "Hopp over karusellen",
|
"carousel_skip": "Hopp over karusellen",
|
||||||
"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.",
|
|
||||||
"toggle_theme": "Endre utseende"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -317,13 +317,13 @@
|
||||||
"channel_tab_community_label": "Gemeenschap",
|
"channel_tab_community_label": "Gemeenschap",
|
||||||
"search_filters_sort_option_relevance": "relevantie",
|
"search_filters_sort_option_relevance": "relevantie",
|
||||||
"search_filters_sort_option_rating": "beoordeling",
|
"search_filters_sort_option_rating": "beoordeling",
|
||||||
"search_filters_sort_option_date": "Upload datum",
|
"search_filters_sort_option_date": "datum",
|
||||||
"search_filters_sort_option_views": "keren bekeken",
|
"search_filters_sort_option_views": "keren bekeken",
|
||||||
"search_filters_type_label": "Type inhoud",
|
"search_filters_type_label": "Type inhoud",
|
||||||
"search_filters_duration_label": "duur",
|
"search_filters_duration_label": "duur",
|
||||||
"search_filters_features_label": "eigenschappen",
|
"search_filters_features_label": "eigenschappen",
|
||||||
"search_filters_sort_label": "sorteren",
|
"search_filters_sort_label": "sorteren",
|
||||||
"search_filters_date_option_hour": "Laatste uur",
|
"search_filters_date_option_hour": "uur",
|
||||||
"search_filters_date_option_today": "vandaag",
|
"search_filters_date_option_today": "vandaag",
|
||||||
"search_filters_date_option_week": "week",
|
"search_filters_date_option_week": "week",
|
||||||
"search_filters_date_option_month": "maand",
|
"search_filters_date_option_month": "maand",
|
||||||
|
@ -357,7 +357,7 @@
|
||||||
"footer_original_source_code": "Originele bron-code",
|
"footer_original_source_code": "Originele bron-code",
|
||||||
"footer_modfied_source_code": "Gewijzigde bron-code",
|
"footer_modfied_source_code": "Gewijzigde bron-code",
|
||||||
"adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats",
|
"adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats",
|
||||||
"next_steps_error_message": "Waarna u zou kunnen proberen om: ",
|
"next_steps_error_message": "Daarna moet u proberen om: ",
|
||||||
"footer_source_code": "Bron-code",
|
"footer_source_code": "Bron-code",
|
||||||
"search_filters_duration_option_long": "Lang (> 20 minuten)",
|
"search_filters_duration_option_long": "Lang (> 20 minuten)",
|
||||||
"preferences_quality_option_dash": "DASH (adaptieve kwaliteit)",
|
"preferences_quality_option_dash": "DASH (adaptieve kwaliteit)",
|
||||||
|
@ -450,7 +450,7 @@
|
||||||
"Chinese (Hong Kong)": "Chinees (Hongkong)",
|
"Chinese (Hong Kong)": "Chinees (Hongkong)",
|
||||||
"Korean (auto-generated)": "Koreaans (automatisch gegenereerd)",
|
"Korean (auto-generated)": "Koreaans (automatisch gegenereerd)",
|
||||||
"search_filters_apply_button": "Geselecteerde filters toepassen",
|
"search_filters_apply_button": "Geselecteerde filters toepassen",
|
||||||
"search_message_use_another_instance": "Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
|
"search_message_use_another_instance": " Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
|
||||||
"Cantonese (Hong Kong)": "Kantonees (Hongkong)",
|
"Cantonese (Hong Kong)": "Kantonees (Hongkong)",
|
||||||
"Chinese (China)": "Chinees (China)",
|
"Chinese (China)": "Chinees (China)",
|
||||||
"crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt",
|
"crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt",
|
||||||
|
@ -477,7 +477,7 @@
|
||||||
"Song: ": "Lied: ",
|
"Song: ": "Lied: ",
|
||||||
"generic_channels_count": "{{count}} kanaal",
|
"generic_channels_count": "{{count}} kanaal",
|
||||||
"generic_channels_count_plural": "{{count}} kanalen",
|
"generic_channels_count_plural": "{{count}} kanalen",
|
||||||
"Popular enabled: ": "Populair ingeschakeld: ",
|
"Popular enabled: ": "Populair geactiveerd: ",
|
||||||
"channel_tab_playlists_label": "Afspeellijsten",
|
"channel_tab_playlists_label": "Afspeellijsten",
|
||||||
"generic_button_edit": "Bewerken",
|
"generic_button_edit": "Bewerken",
|
||||||
"Music in this video": "Muziek in deze video",
|
"Music in this video": "Muziek in deze video",
|
||||||
|
|
|
@ -478,7 +478,7 @@
|
||||||
"search_filters_date_label": "Data przesłania",
|
"search_filters_date_label": "Data przesłania",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
"search_filters_date_option_none": "Dowolna data",
|
"search_filters_date_option_none": "Dowolna data",
|
||||||
"search_message_use_another_instance": "Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.",
|
"search_message_use_another_instance": " Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.",
|
||||||
"search_filters_type_option_all": "Dowolny typ",
|
"search_filters_type_option_all": "Dowolny typ",
|
||||||
"search_filters_duration_option_none": "Dowolna długość",
|
"search_filters_duration_option_none": "Dowolna długość",
|
||||||
"search_filters_duration_option_medium": "Średnia (4-20 minut)",
|
"search_filters_duration_option_medium": "Średnia (4-20 minut)",
|
||||||
|
|
|
@ -474,7 +474,7 @@
|
||||||
"Spanish (auto-generated)": "Espanhol (gerado automaticamente)",
|
"Spanish (auto-generated)": "Espanhol (gerado automaticamente)",
|
||||||
"Spanish (Mexico)": "Espanhol (México)",
|
"Spanish (Mexico)": "Espanhol (México)",
|
||||||
"search_filters_duration_option_none": "Qualquer duração",
|
"search_filters_duration_option_none": "Qualquer duração",
|
||||||
"search_message_use_another_instance": "Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.",
|
"search_message_use_another_instance": " Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.",
|
||||||
"Spanish (Spain)": "Espanhol (Espanha)",
|
"Spanish (Spain)": "Espanhol (Espanha)",
|
||||||
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
|
"Turkish (auto-generated)": "Turco (gerado automaticamente)",
|
||||||
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
|
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)",
|
||||||
|
|
|
@ -448,7 +448,7 @@
|
||||||
"Chinese (Taiwan)": "Chinês (Taiwan)",
|
"Chinese (Taiwan)": "Chinês (Taiwan)",
|
||||||
"search_message_no_results": "Nenhum resultado encontrado.",
|
"search_message_no_results": "Nenhum resultado encontrado.",
|
||||||
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
|
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
|
||||||
"search_message_use_another_instance": "Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
|
"search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
|
||||||
"English (United Kingdom)": "Inglês (Reino Unido)",
|
"English (United Kingdom)": "Inglês (Reino Unido)",
|
||||||
"English (United States)": "Inglês (Estados Unidos)",
|
"English (United States)": "Inglês (Estados Unidos)",
|
||||||
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
|
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
|
||||||
|
@ -508,7 +508,7 @@
|
||||||
"toggle_theme": "Trocar tema",
|
"toggle_theme": "Trocar tema",
|
||||||
"Add to playlist": "Adicionar à lista de reprodução",
|
"Add to playlist": "Adicionar à lista de reprodução",
|
||||||
"Add to playlist: ": "Adicionar à lista de reprodução: ",
|
"Add to playlist: ": "Adicionar à lista de reprodução: ",
|
||||||
"Answer": "Responder",
|
"Answer": "Resposta",
|
||||||
"Search for videos": "Procurar vídeos",
|
"Search for videos": "Procurar vídeos",
|
||||||
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
||||||
"carousel_skip": "Ignorar carrossel",
|
"carousel_skip": "Ignorar carrossel",
|
||||||
|
|
|
@ -509,9 +509,6 @@
|
||||||
"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.": "Популярная лента была отключена администратором.",
|
||||||
"toggle_theme": "Переключатель тем",
|
"toggle_theme": "Переключатель тем"
|
||||||
"carousel_slide": "Пролистано {{current}} из {{total}}",
|
|
||||||
"carousel_skip": "Пропустить всё",
|
|
||||||
"carousel_go_to": "Перейти к странице `x`"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -257,13 +257,13 @@
|
||||||
"Video mode": "Mënyrë video",
|
"Video mode": "Mënyrë video",
|
||||||
"channel_tab_videos_label": "Video",
|
"channel_tab_videos_label": "Video",
|
||||||
"search_filters_sort_option_rating": "Vlerësim",
|
"search_filters_sort_option_rating": "Vlerësim",
|
||||||
"search_filters_sort_option_date": "Datë ngarkimi",
|
"search_filters_sort_option_date": "Datë Ngarkimi",
|
||||||
"search_filters_sort_option_views": "Numër parjesh",
|
"search_filters_sort_option_views": "Numër parjesh",
|
||||||
"search_filters_type_label": "Lloj",
|
"search_filters_type_label": "Lloj",
|
||||||
"search_filters_duration_label": "Kohëzgjatje",
|
"search_filters_duration_label": "Kohëzgjatje",
|
||||||
"search_filters_features_label": "Veçori",
|
"search_filters_features_label": "Veçori",
|
||||||
"search_filters_sort_label": "Renditi Sipas",
|
"search_filters_sort_label": "Renditi Sipas",
|
||||||
"search_filters_date_option_hour": "Orën e fundit",
|
"search_filters_date_option_hour": "Orën e Fundit",
|
||||||
"search_filters_date_option_today": "Sot",
|
"search_filters_date_option_today": "Sot",
|
||||||
"search_filters_duration_option_long": "E gjatë (> 20 minuta)",
|
"search_filters_duration_option_long": "E gjatë (> 20 minuta)",
|
||||||
"search_filters_features_option_hd": "HD",
|
"search_filters_features_option_hd": "HD",
|
||||||
|
@ -435,14 +435,14 @@
|
||||||
"tokens_count_plural": "{{count}} tokenë",
|
"tokens_count_plural": "{{count}} tokenë",
|
||||||
"preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ",
|
"preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ",
|
||||||
"Import Invidious data": "Importoni të dhëna JSON Invidious",
|
"Import Invidious data": "Importoni të dhëna JSON Invidious",
|
||||||
"Import YouTube subscriptions": "Importoni pajtime YouTube CSV ose OPML",
|
"Import YouTube subscriptions": "Importoni pajtime YouTube/OPML",
|
||||||
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
|
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
|
||||||
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
|
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
|
||||||
"Shared `x`": "Ndarë me të tjerë më `x`",
|
"Shared `x`": "Ndarë me të tjerë më `x`",
|
||||||
"search_filters_title": "Filtra",
|
"search_filters_title": "Filtra",
|
||||||
"Popular enabled: ": "Me populloret të aktivizuara: ",
|
"Popular enabled: ": "Me populloret të aktivizuara: ",
|
||||||
"error_video_not_in_playlist": "Videoja e kërkuar s’ekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
|
"error_video_not_in_playlist": "Videoja e kërkuar s’ekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
|
||||||
"search_message_use_another_instance": "Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
|
"search_message_use_another_instance": " Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
|
||||||
"search_filters_date_label": "Datë ngarkimi",
|
"search_filters_date_label": "Datë ngarkimi",
|
||||||
"preferences_watch_history_label": "Aktivizo historik parjesh: ",
|
"preferences_watch_history_label": "Aktivizo historik parjesh: ",
|
||||||
"Top enabled: ": "Me kryesueset të aktivizuara: ",
|
"Top enabled: ": "Me kryesueset të aktivizuara: ",
|
||||||
|
@ -484,13 +484,5 @@
|
||||||
"Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)",
|
"Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)",
|
||||||
"preferences_local_label": "Video përmes ndërmjetësi: ",
|
"preferences_local_label": "Video përmes ndërmjetësi: ",
|
||||||
"Fallback captions: ": "Titra nga halli: ",
|
"Fallback captions: ": "Titra nga halli: ",
|
||||||
"Erroneous challenge": "Zgjidhje e gabuar",
|
"Erroneous challenge": "Zgjidhje e gabuar"
|
||||||
"Add to playlist: ": "Shtoje te luajlistë: ",
|
|
||||||
"Add to playlist": "Shtoje te luajlistë",
|
|
||||||
"Answer": "Përgjigje",
|
|
||||||
"Search for videos": "Kërko për video",
|
|
||||||
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
|
|
||||||
"carousel_skip": "Anashkaloje Rrotullamen",
|
|
||||||
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
|
|
||||||
"carousel_go_to": "Kalo te diapozitivi `x`"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -404,7 +404,7 @@
|
||||||
"generic_count_months_0": "{{count}} mesec",
|
"generic_count_months_0": "{{count}} mesec",
|
||||||
"generic_count_months_1": "{{count}} meseca",
|
"generic_count_months_1": "{{count}} meseca",
|
||||||
"generic_count_months_2": "{{count}} meseci",
|
"generic_count_months_2": "{{count}} meseci",
|
||||||
"search_message_use_another_instance": "Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.",
|
"search_message_use_another_instance": " Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.",
|
||||||
"generic_subscribers_count_0": "{{count}} pratilac",
|
"generic_subscribers_count_0": "{{count}} pratilac",
|
||||||
"generic_subscribers_count_1": "{{count}} pratioca",
|
"generic_subscribers_count_1": "{{count}} pratioca",
|
||||||
"generic_subscribers_count_2": "{{count}} pratilaca",
|
"generic_subscribers_count_2": "{{count}} pratilaca",
|
||||||
|
|
|
@ -404,7 +404,7 @@
|
||||||
"generic_count_months_0": "{{count}} месец",
|
"generic_count_months_0": "{{count}} месец",
|
||||||
"generic_count_months_1": "{{count}} месеца",
|
"generic_count_months_1": "{{count}} месеца",
|
||||||
"generic_count_months_2": "{{count}} месеци",
|
"generic_count_months_2": "{{count}} месеци",
|
||||||
"search_message_use_another_instance": "Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
|
"search_message_use_another_instance": " Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
|
||||||
"generic_subscribers_count_0": "{{count}} пратилац",
|
"generic_subscribers_count_0": "{{count}} пратилац",
|
||||||
"generic_subscribers_count_1": "{{count}} пратиоца",
|
"generic_subscribers_count_1": "{{count}} пратиоца",
|
||||||
"generic_subscribers_count_2": "{{count}} пратилаца",
|
"generic_subscribers_count_2": "{{count}} пратилаца",
|
||||||
|
|
|
@ -320,13 +320,13 @@
|
||||||
"channel_tab_community_label": "Gemenskap",
|
"channel_tab_community_label": "Gemenskap",
|
||||||
"search_filters_sort_option_relevance": "Relevans",
|
"search_filters_sort_option_relevance": "Relevans",
|
||||||
"search_filters_sort_option_rating": "Rankning",
|
"search_filters_sort_option_rating": "Rankning",
|
||||||
"search_filters_sort_option_date": "Uppladdnings datum",
|
"search_filters_sort_option_date": "Uppladdnings Datum",
|
||||||
"search_filters_sort_option_views": "Visningar",
|
"search_filters_sort_option_views": "Visningar",
|
||||||
"search_filters_type_label": "Typ",
|
"search_filters_type_label": "Typ",
|
||||||
"search_filters_duration_label": "Varaktighet",
|
"search_filters_duration_label": "Varaktighet",
|
||||||
"search_filters_features_label": "Funktioner",
|
"search_filters_features_label": "Funktioner",
|
||||||
"search_filters_sort_label": "Sortera efter",
|
"search_filters_sort_label": "Sortera efter",
|
||||||
"search_filters_date_option_hour": "Senaste timmen",
|
"search_filters_date_option_hour": "Senaste Timmen",
|
||||||
"search_filters_date_option_today": "Idag",
|
"search_filters_date_option_today": "Idag",
|
||||||
"search_filters_date_option_week": "Denna vecka",
|
"search_filters_date_option_week": "Denna vecka",
|
||||||
"search_filters_date_option_month": "Denna månad",
|
"search_filters_date_option_month": "Denna månad",
|
||||||
|
@ -393,7 +393,7 @@
|
||||||
"Artist: ": "Artist: ",
|
"Artist: ": "Artist: ",
|
||||||
"generic_count_months": "{{count}}månad",
|
"generic_count_months": "{{count}}månad",
|
||||||
"generic_count_months_plural": "{{count}}månader",
|
"generic_count_months_plural": "{{count}}månader",
|
||||||
"search_message_use_another_instance": "Du kan också <a href=\"`x`\">söka på en annan instans</a>.",
|
"search_message_use_another_instance": " Du kan också <a href=\"`x`\">söka på en annan instans</a>.",
|
||||||
"generic_subscribers_count": "{{count}} prenumerant",
|
"generic_subscribers_count": "{{count}} prenumerant",
|
||||||
"generic_subscribers_count_plural": "{{count}} prenumeranter",
|
"generic_subscribers_count_plural": "{{count}} prenumeranter",
|
||||||
"download_subtitles": "Undertexter - `x` (.vtt)",
|
"download_subtitles": "Undertexter - `x` (.vtt)",
|
||||||
|
|
|
@ -322,13 +322,13 @@
|
||||||
"channel_tab_community_label": "Topluluk",
|
"channel_tab_community_label": "Topluluk",
|
||||||
"search_filters_sort_option_relevance": "İlgi",
|
"search_filters_sort_option_relevance": "İlgi",
|
||||||
"search_filters_sort_option_rating": "Değerlendirme",
|
"search_filters_sort_option_rating": "Değerlendirme",
|
||||||
"search_filters_sort_option_date": "Yükleme tarihi",
|
"search_filters_sort_option_date": "Yükleme Tarihi",
|
||||||
"search_filters_sort_option_views": "Görüntüleme Sayısı",
|
"search_filters_sort_option_views": "Görüntüleme Sayısı",
|
||||||
"search_filters_type_label": "Tür",
|
"search_filters_type_label": "Tür",
|
||||||
"search_filters_duration_label": "Süre",
|
"search_filters_duration_label": "Süre",
|
||||||
"search_filters_features_label": "Özellikler",
|
"search_filters_features_label": "Özellikler",
|
||||||
"search_filters_sort_label": "Sıralama Ölçütü",
|
"search_filters_sort_label": "Sıralama Ölçütü",
|
||||||
"search_filters_date_option_hour": "Son saat",
|
"search_filters_date_option_hour": "Son Saat",
|
||||||
"search_filters_date_option_today": "Bugün",
|
"search_filters_date_option_today": "Bugün",
|
||||||
"search_filters_date_option_week": "Bu Hafta",
|
"search_filters_date_option_week": "Bu Hafta",
|
||||||
"search_filters_date_option_month": "Bu Ay",
|
"search_filters_date_option_month": "Bu Ay",
|
||||||
|
@ -452,7 +452,7 @@
|
||||||
"Spanish (Spain)": "İspanyolca (İspanya)",
|
"Spanish (Spain)": "İspanyolca (İspanya)",
|
||||||
"Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)",
|
"Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)",
|
||||||
"preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ",
|
"preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ",
|
||||||
"search_message_use_another_instance": "Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
|
"search_message_use_another_instance": " Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
|
||||||
"search_filters_type_option_all": "Herhangi Bir Tür",
|
"search_filters_type_option_all": "Herhangi Bir Tür",
|
||||||
"search_filters_duration_option_none": "Herhangi Bir Süre",
|
"search_filters_duration_option_none": "Herhangi Bir Süre",
|
||||||
"search_message_no_results": "Sonuç bulunamadı.",
|
"search_message_no_results": "Sonuç bulunamadı.",
|
||||||
|
|
|
@ -455,7 +455,7 @@
|
||||||
"search_filters_date_option_week": "Цей тиждень",
|
"search_filters_date_option_week": "Цей тиждень",
|
||||||
"search_filters_type_label": "Тип",
|
"search_filters_type_label": "Тип",
|
||||||
"search_filters_type_option_channel": "Канал",
|
"search_filters_type_option_channel": "Канал",
|
||||||
"search_message_use_another_instance": "Можете також <a href=\"`x`\">пошукати на іншому сервері</a>.",
|
"search_message_use_another_instance": " Можете також <a href=\"`x`\">пошукати іншим сервером</a>.",
|
||||||
"search_filters_title": "Фільтри",
|
"search_filters_title": "Фільтри",
|
||||||
"search_filters_date_option_hour": "Остання година",
|
"search_filters_date_option_hour": "Остання година",
|
||||||
"search_filters_date_option_month": "Цей місяць",
|
"search_filters_date_option_month": "Цей місяць",
|
||||||
|
@ -472,7 +472,7 @@
|
||||||
"search_filters_features_option_three_sixty": "360°",
|
"search_filters_features_option_three_sixty": "360°",
|
||||||
"search_filters_features_option_hdr": "HDR",
|
"search_filters_features_option_hdr": "HDR",
|
||||||
"search_filters_sort_label": "Спершу",
|
"search_filters_sort_label": "Спершу",
|
||||||
"search_filters_sort_option_date": "Дата вивантаження",
|
"search_filters_sort_option_date": "Нещодавні",
|
||||||
"search_filters_apply_button": "Застосувати фільтри",
|
"search_filters_apply_button": "Застосувати фільтри",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
"search_filters_features_option_purchased": "Придбано",
|
"search_filters_features_option_purchased": "Придбано",
|
||||||
|
|
|
@ -436,7 +436,7 @@
|
||||||
"Turkish (auto-generated)": "土耳其语 (自动生成)",
|
"Turkish (auto-generated)": "土耳其语 (自动生成)",
|
||||||
"Spanish (Spain)": "西班牙语 (西班牙)",
|
"Spanish (Spain)": "西班牙语 (西班牙)",
|
||||||
"preferences_watch_history_label": "启用观看历史: ",
|
"preferences_watch_history_label": "启用观看历史: ",
|
||||||
"search_message_use_another_instance": "你也可以 <a href=\"`x`\">在另一实例上搜索</a>。",
|
"search_message_use_another_instance": " 你也可以 <a href=\"`x`\">在另一实例上搜索</a>。",
|
||||||
"search_filters_title": "过滤器",
|
"search_filters_title": "过滤器",
|
||||||
"search_filters_date_label": "上传日期",
|
"search_filters_date_label": "上传日期",
|
||||||
"search_filters_apply_button": "应用所选过滤器",
|
"search_filters_apply_button": "应用所选过滤器",
|
||||||
|
|
|
@ -338,13 +338,13 @@
|
||||||
"channel_tab_community_label": "社群",
|
"channel_tab_community_label": "社群",
|
||||||
"search_filters_sort_option_relevance": "關聯",
|
"search_filters_sort_option_relevance": "關聯",
|
||||||
"search_filters_sort_option_rating": "評分",
|
"search_filters_sort_option_rating": "評分",
|
||||||
"search_filters_sort_option_date": "上傳日期",
|
"search_filters_sort_option_date": "日期",
|
||||||
"search_filters_sort_option_views": "檢視",
|
"search_filters_sort_option_views": "檢視",
|
||||||
"search_filters_type_label": "內容類型",
|
"search_filters_type_label": "內容類型",
|
||||||
"search_filters_duration_label": "時長",
|
"search_filters_duration_label": "時長",
|
||||||
"search_filters_features_label": "特色",
|
"search_filters_features_label": "特色",
|
||||||
"search_filters_sort_label": "排序",
|
"search_filters_sort_label": "排序",
|
||||||
"search_filters_date_option_hour": "最後一小時",
|
"search_filters_date_option_hour": "小時",
|
||||||
"search_filters_date_option_today": "今天",
|
"search_filters_date_option_today": "今天",
|
||||||
"search_filters_date_option_week": "週",
|
"search_filters_date_option_week": "週",
|
||||||
"search_filters_date_option_month": "月",
|
"search_filters_date_option_month": "月",
|
||||||
|
@ -442,7 +442,7 @@
|
||||||
"search_filters_duration_option_none": "任何時長",
|
"search_filters_duration_option_none": "任何時長",
|
||||||
"search_filters_duration_option_medium": "中等(4到20分鐘)",
|
"search_filters_duration_option_medium": "中等(4到20分鐘)",
|
||||||
"search_filters_features_option_vr180": "VR180",
|
"search_filters_features_option_vr180": "VR180",
|
||||||
"search_message_use_another_instance": "您也可以<a href=\"`x`\">在其他站台上搜尋</a>。",
|
"search_message_use_another_instance": " 您也可以<a href=\"`x`\">在其他站台上搜尋</a>。",
|
||||||
"search_filters_title": "過濾條件",
|
"search_filters_title": "過濾條件",
|
||||||
"search_filters_date_label": "上傳日期",
|
"search_filters_date_label": "上傳日期",
|
||||||
"search_filters_type_option_all": "任何類型",
|
"search_filters_type_option_all": "任何類型",
|
||||||
|
|
2
mocks
2
mocks
|
@ -1 +1 @@
|
||||||
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872
|
Subproject commit 11ec372f72747c09d48ffef04843f72be67d5b54
|
17
shard.lock
17
shard.lock
|
@ -10,23 +10,16 @@ shards:
|
||||||
|
|
||||||
backtracer:
|
backtracer:
|
||||||
git: https://github.com/sija/backtracer.cr.git
|
git: https://github.com/sija/backtracer.cr.git
|
||||||
version: 1.2.2
|
version: 1.2.1
|
||||||
|
|
||||||
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.2.2
|
version: 0.2.2
|
||||||
|
|
||||||
inotify:
|
|
||||||
git: https://github.com/petoem/inotify.cr.git
|
|
||||||
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.1.2
|
version: 1.1.2
|
||||||
|
@ -37,7 +30,7 @@ shards:
|
||||||
|
|
||||||
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:
|
pool:
|
||||||
git: https://github.com/ysbaddaden/pool.git
|
git: https://github.com/ysbaddaden/pool.git
|
||||||
|
@ -57,9 +50,9 @@ shards:
|
||||||
|
|
||||||
spectator:
|
spectator:
|
||||||
git: https://github.com/icy-arctic-fox/spectator.git
|
git: https://github.com/icy-arctic-fox/spectator.git
|
||||||
version: 0.10.6
|
version: 0.10.4
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
27
shard.yml
27
shard.yml
|
@ -1,20 +1,21 @@
|
||||||
name: invidious
|
name: invidious
|
||||||
version: 2.20241110.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.1.2
|
version: ~> 1.1.2
|
||||||
|
@ -29,12 +30,6 @@ dependencies:
|
||||||
version: ~> 0.1.1
|
version: ~> 0.1.1
|
||||||
redis:
|
redis:
|
||||||
github: stefanwille/crystal-redis
|
github: stefanwille/crystal-redis
|
||||||
inotify:
|
|
||||||
github: petoem/inotify.cr
|
|
||||||
version: 1.0.3
|
|
||||||
http_proxy:
|
|
||||||
github: mamantoha/http_proxy
|
|
||||||
version: ~> 0.10.3
|
|
||||||
|
|
||||||
development_dependencies:
|
development_dependencies:
|
||||||
spectator:
|
spectator:
|
||||||
|
@ -44,10 +39,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
|
|
||||||
|
|
|
@ -27,8 +27,8 @@ Spectator.describe Invidious::Hashtag do
|
||||||
expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32)
|
expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32)
|
||||||
expect(video_11.views).to eq(40_504_893)
|
expect(video_11.views).to eq(40_504_893)
|
||||||
|
|
||||||
expect(video_11.badges.live_now?).to be_false
|
expect(video_11.live_now).to be_false
|
||||||
expect(video_11.badges.premium?).to be_false
|
expect(video_11.premium).to be_false
|
||||||
expect(video_11.premiere_timestamp).to be_nil
|
expect(video_11.premiere_timestamp).to be_nil
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -49,8 +49,8 @@ Spectator.describe Invidious::Hashtag do
|
||||||
expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32)
|
expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32)
|
||||||
expect(video_35.views).to eq(30_790_049)
|
expect(video_35.views).to eq(30_790_049)
|
||||||
|
|
||||||
expect(video_35.badges.live_now?).to be_false
|
expect(video_35.live_now).to be_false
|
||||||
expect(video_35.badges.premium?).to be_false
|
expect(video_35.premium).to be_false
|
||||||
expect(video_35.premiere_timestamp).to be_nil
|
expect(video_35.premiere_timestamp).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -80,8 +80,8 @@ Spectator.describe Invidious::Hashtag do
|
||||||
expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32)
|
expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32)
|
||||||
expect(video_41.views).to eq(63_240)
|
expect(video_41.views).to eq(63_240)
|
||||||
|
|
||||||
expect(video_41.badges.live_now?).to be_false
|
expect(video_41.live_now).to be_false
|
||||||
expect(video_41.badges.premium?).to be_false
|
expect(video_41.premium).to be_false
|
||||||
expect(video_41.premiere_timestamp).to be_nil
|
expect(video_41.premiere_timestamp).to be_nil
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -102,8 +102,8 @@ Spectator.describe Invidious::Hashtag do
|
||||||
expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32)
|
expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32)
|
||||||
expect(video_48.views).to eq(68_704)
|
expect(video_48.views).to eq(68_704)
|
||||||
|
|
||||||
expect(video_48.badges.live_now?).to be_false
|
expect(video_48.live_now).to be_false
|
||||||
expect(video_48.badges.premium?).to be_false
|
expect(video_48.premium).to be_false
|
||||||
expect(video_48.premiere_timestamp).to be_nil
|
expect(video_48.premiere_timestamp).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,8 +17,8 @@ Spectator.describe "parse_video_info" do
|
||||||
# Basic video infos
|
# Basic video infos
|
||||||
|
|
||||||
expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island")
|
expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island")
|
||||||
expect(info["views"].as_i).to eq(220_226_287)
|
expect(info["views"].as_i).to eq(126_573_823)
|
||||||
expect(info["likes"].as_i).to eq(6_870_691)
|
expect(info["likes"].as_i).to eq(5_157_654)
|
||||||
|
|
||||||
# For some reason the video length from VideoDetails and the
|
# For some reason the video length from VideoDetails and the
|
||||||
# one from microformat differs by 1s...
|
# one from microformat differs by 1s...
|
||||||
|
@ -48,12 +48,12 @@ Spectator.describe "parse_video_info" do
|
||||||
|
|
||||||
expect(info["relatedVideos"].as_a.size).to eq(20)
|
expect(info["relatedVideos"].as_a.size).to eq(20)
|
||||||
|
|
||||||
expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4")
|
expect(info["relatedVideos"][0]["id"]).to eq("Hwybp38GnZw")
|
||||||
expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!")
|
expect(info["relatedVideos"][0]["title"]).to eq("I Built Willy Wonka's Chocolate Factory!")
|
||||||
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
|
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
|
||||||
expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
|
expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
|
||||||
expect(info["relatedVideos"][0]["view_count"]).to eq("230617484")
|
expect(info["relatedVideos"][0]["view_count"]).to eq("179877630")
|
||||||
expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M")
|
expect(info["relatedVideos"][0]["short_view_count"]).to eq("179M")
|
||||||
expect(info["relatedVideos"][0]["author_verified"]).to eq("true")
|
expect(info["relatedVideos"][0]["author_verified"]).to eq("true")
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
|
@ -76,11 +76,11 @@ Spectator.describe "parse_video_info" do
|
||||||
expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
|
expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
|
||||||
|
|
||||||
expect(info["authorThumbnail"].as_s).to eq(
|
expect(info["authorThumbnail"].as_s).to eq(
|
||||||
"https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj"
|
"https://yt3.ggpht.com/ytc/AL5GRJVuqw82ERvHzsmBxL7avr1dpBtsVIXcEzBPZaloFg=s48-c-k-c0x00ffffff-no-rj"
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(info["authorVerified"].as_bool).to be_true
|
expect(info["authorVerified"].as_bool).to be_true
|
||||||
expect(info["subCountText"].as_s).to eq("320M")
|
expect(info["subCountText"].as_s).to eq("143M")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "parses a regular video with no descrition/comments" do
|
it "parses a regular video with no descrition/comments" do
|
||||||
|
@ -99,8 +99,8 @@ Spectator.describe "parse_video_info" do
|
||||||
# Basic video infos
|
# Basic video infos
|
||||||
|
|
||||||
expect(info["title"].as_s).to eq("Chris Rea - Auberge")
|
expect(info["title"].as_s).to eq("Chris Rea - Auberge")
|
||||||
expect(info["views"].as_i).to eq(14_324_584)
|
expect(info["views"].as_i).to eq(10_943_126)
|
||||||
expect(info["likes"].as_i).to eq(35_870)
|
expect(info["likes"].as_i).to eq(0)
|
||||||
expect(info["lengthSeconds"].as_i).to eq(283_i64)
|
expect(info["lengthSeconds"].as_i).to eq(283_i64)
|
||||||
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
|
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
|
||||||
|
|
||||||
|
@ -132,14 +132,14 @@ Spectator.describe "parse_video_info" do
|
||||||
|
|
||||||
# Related videos
|
# Related videos
|
||||||
|
|
||||||
expect(info["relatedVideos"].as_a.size).to eq(20)
|
expect(info["relatedVideos"].as_a.size).to eq(19)
|
||||||
|
|
||||||
expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4")
|
expect(info["relatedVideos"][0]["id"]).to eq("Ww3KeZ2_Yv4")
|
||||||
expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version")
|
expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea")
|
||||||
expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH")
|
expect(info["relatedVideos"][0]["author"]).to eq("PanMusic")
|
||||||
expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ")
|
expect(info["relatedVideos"][0]["ucid"]).to eq("UCsKAPSuh1iNbLWUga_igPyA")
|
||||||
expect(info["relatedVideos"][0]["view_count"]).to eq("53298661")
|
expect(info["relatedVideos"][0]["view_count"]).to eq("31581")
|
||||||
expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M")
|
expect(info["relatedVideos"][0]["short_view_count"]).to eq("31K")
|
||||||
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
|
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
|
@ -156,13 +156,11 @@ Spectator.describe "parse_video_info" do
|
||||||
|
|
||||||
# Author infos
|
# Author infos
|
||||||
|
|
||||||
expect(info["author"].as_s).to eq("ChrisReaVideos")
|
expect(info["author"].as_s).to eq("ChrisReaOfficial")
|
||||||
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
|
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
|
||||||
|
|
||||||
expect(info["authorThumbnail"].as_s).to eq(
|
expect(info["authorThumbnail"].as_s).to be_empty
|
||||||
"https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj"
|
|
||||||
)
|
|
||||||
expect(info["authorVerified"].as_bool).to be_false
|
expect(info["authorVerified"].as_bool).to be_false
|
||||||
expect(info["subCountText"].as_s).to eq("3.11K")
|
expect(info["subCountText"].as_s).to eq("-")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,7 +23,6 @@ require "kilt"
|
||||||
require "./ext/kemal_content_for.cr"
|
require "./ext/kemal_content_for.cr"
|
||||||
require "./ext/kemal_static_file_handler.cr"
|
require "./ext/kemal_static_file_handler.cr"
|
||||||
|
|
||||||
require "http_proxy"
|
|
||||||
require "athena-negotiation"
|
require "athena-negotiation"
|
||||||
require "openssl/hmac"
|
require "openssl/hmac"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
|
@ -33,7 +32,6 @@ require "yaml"
|
||||||
require "compress/zip"
|
require "compress/zip"
|
||||||
require "protodec/utils"
|
require "protodec/utils"
|
||||||
require "redis"
|
require "redis"
|
||||||
require "inotify"
|
|
||||||
|
|
||||||
require "./invidious/database/*"
|
require "./invidious/database/*"
|
||||||
require "./invidious/database/migrations/*"
|
require "./invidious/database/migrations/*"
|
||||||
|
@ -60,20 +58,7 @@ end
|
||||||
# Simple alias to make code easier to read
|
# Simple alias to make code easier to read
|
||||||
alias IV = Invidious
|
alias IV = Invidious
|
||||||
|
|
||||||
CONFIG = Config.load
|
CONFIG = Config.load
|
||||||
|
|
||||||
Signal::HUP.trap do
|
|
||||||
Config.reload
|
|
||||||
end
|
|
||||||
|
|
||||||
{% if flag?(:linux) %}
|
|
||||||
if CONFIG.reload_config_automatically
|
|
||||||
Inotify.watch("config/config.yml") do |event|
|
|
||||||
Config.reload
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{% 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
|
||||||
|
@ -82,12 +67,11 @@ REDIS_DB = Redis::PooledClient.new(unixsocket: CONFIG.redis_socket || nil, url:
|
||||||
if REDIS_DB.ping
|
if REDIS_DB.ping
|
||||||
puts "Connected to redis"
|
puts "Connected to redis"
|
||||||
end
|
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
|
HOST_URL = make_host_url(Kemal.config)
|
||||||
HOST_URL = make_host_url(Kemal.config)
|
|
||||||
|
|
||||||
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"}
|
||||||
|
@ -114,10 +98,6 @@ SOFTWARE = {
|
||||||
|
|
||||||
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
|
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
|
||||||
|
|
||||||
# Image request pool
|
|
||||||
|
|
||||||
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
|
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
Kemal.config.extra_options do |parser|
|
Kemal.config.extra_options do |parser|
|
||||||
parser.banner = "Usage: invidious [arguments]"
|
parser.banner = "Usage: invidious [arguments]"
|
||||||
|
@ -208,20 +188,6 @@ Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
|
||||||
|
|
||||||
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
|
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
|
||||||
|
|
||||||
if !CONFIG.external_videoplayback_proxy.empty?
|
|
||||||
Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
|
|
||||||
else
|
|
||||||
# Invidious will it's own videoplayback proxy unless the admin decides to rewrite
|
|
||||||
# the /videoplayback location in the reverse proxy configuration (NGINX, Caddy, etc)
|
|
||||||
LOGGER.info("jobs: Disabling CheckExternalProxy job. Invidious will it's own videoplayback proxy")
|
|
||||||
end
|
|
||||||
|
|
||||||
if !CONFIG.tokens_server.empty?
|
|
||||||
Invidious::Jobs.register Invidious::Jobs::RefreshSessionTokens.new
|
|
||||||
else
|
|
||||||
LOGGER.info("jobs: Disabling RefreshSessionTokens job. Invidious will use the tokens that are on the configuration file")
|
|
||||||
end
|
|
||||||
|
|
||||||
Invidious::Jobs.start_all
|
Invidious::Jobs.start_all
|
||||||
|
|
||||||
def popular_videos
|
def popular_videos
|
||||||
|
|
|
@ -223,7 +223,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
length_seconds = channel_video.try &.length_seconds
|
length_seconds = channel_video.try &.length_seconds
|
||||||
length_seconds ||= 0
|
length_seconds ||= 0
|
||||||
|
|
||||||
live_now = channel_video.try &.badges.live_now?
|
live_now = channel_video.try &.live_now
|
||||||
live_now ||= false
|
live_now ||= false
|
||||||
|
|
||||||
premiere_timestamp = channel_video.try &.premiere_timestamp
|
premiere_timestamp = channel_video.try &.premiere_timestamp
|
||||||
|
@ -251,8 +251,8 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
|
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
|
||||||
if CONFIG.enable_user_notifications
|
if CONFIG.enable_user_notifications
|
||||||
Invidious::Database::Users.add_notification(video)
|
Invidious::Database::Users.add_notification(video)
|
||||||
# else
|
else
|
||||||
# Invidious::Database::Users.feed_needs_update(video)
|
Invidious::Database::Users.feed_needs_update(video)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
|
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
|
||||||
|
@ -275,7 +275,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
ucid: video.ucid,
|
ucid: video.ucid,
|
||||||
author: video.author,
|
author: video.author,
|
||||||
length_seconds: video.length_seconds,
|
length_seconds: video.length_seconds,
|
||||||
live_now: video.badges.live_now?,
|
live_now: video.live_now,
|
||||||
premiere_timestamp: video.premiere_timestamp,
|
premiere_timestamp: video.premiere_timestamp,
|
||||||
views: video.views,
|
views: video.views,
|
||||||
})
|
})
|
||||||
|
@ -287,8 +287,8 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
if was_insert
|
if was_insert
|
||||||
if CONFIG.enable_user_notifications
|
if CONFIG.enable_user_notifications
|
||||||
Invidious::Database::Users.add_notification(video)
|
Invidious::Database::Users.add_notification(video)
|
||||||
# else
|
else
|
||||||
# Invidious::Database::Users.feed_needs_update(video)
|
Invidious::Database::Users.feed_needs_update(video)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -13,7 +13,6 @@ struct ConfigPreferences
|
||||||
|
|
||||||
property annotations : Bool = false
|
property annotations : Bool = false
|
||||||
property annotations_subscribed : Bool = false
|
property annotations_subscribed : Bool = false
|
||||||
property preload : Bool = true
|
|
||||||
property autoplay : Bool = false
|
property autoplay : Bool = false
|
||||||
property captions : Array(String) = ["", "", ""]
|
property captions : Array(String) = ["", "", ""]
|
||||||
property comments : Array(String) = ["youtube", ""]
|
property comments : Array(String) = ["youtube", ""]
|
||||||
|
@ -55,28 +54,9 @@ struct ConfigPreferences
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
struct HTTPProxyConfig
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
property user : String
|
|
||||||
property password : String
|
|
||||||
property host : String
|
|
||||||
property port : Int32
|
|
||||||
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("")
|
|
||||||
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).
|
||||||
|
@ -106,22 +86,18 @@ class Config
|
||||||
|
|
||||||
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||||
property https_only : Bool?
|
property https_only : Bool?
|
||||||
# Enable or disable CSP
|
|
||||||
property csp : Bool? = true
|
|
||||||
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||||
property hmac_key : String = ""
|
property hmac_key : String = ""
|
||||||
# Domain to be used for links to resources on the site where an absolute URL is required
|
# Domain to be used for links to resources on the site where an absolute URL is required
|
||||||
property domain : String?
|
property domain : String?
|
||||||
# Materialious redirects
|
|
||||||
property materialious_domain : String?
|
|
||||||
# Alternative domains. You can add other domains, like TOR and I2P addresses
|
# Alternative domains. You can add other domains, like TOR and I2P addresses
|
||||||
property alternative_domains : Array(String) = [] of String
|
property alternative_domains : Array(String) = [] of String
|
||||||
# Backend domains. Domains for numbered backends
|
property donation_url : String?
|
||||||
property backend_domains : Array(String) = [] of 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
|
||||||
|
@ -174,8 +150,6 @@ class Config
|
||||||
property host_binding : String = "0.0.0.0"
|
property host_binding : String = "0.0.0.0"
|
||||||
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
# 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
|
|
||||||
property http_proxy : HTTPProxyConfig? = nil
|
|
||||||
|
|
||||||
# Use Innertube's transcripts API instead of timedtext for closed captions
|
# Use Innertube's transcripts API instead of timedtext for closed captions
|
||||||
property use_innertube_for_captions : Bool = false
|
property use_innertube_for_captions : Bool = false
|
||||||
|
@ -185,12 +159,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
|
||||||
|
@ -203,25 +171,11 @@ class Config
|
||||||
|
|
||||||
# List of names of the backends
|
# List of names of the backends
|
||||||
property backends : Array(String) = [] of String
|
property backends : Array(String) = [] of String
|
||||||
# Character used to separate the backend number from the description/note
|
|
||||||
# of the backend
|
|
||||||
property backends_delimiter : String = "|"
|
|
||||||
|
|
||||||
# External videoplayback proxies list. They should include `https://`
|
property external_videoplayback_proxy : String?
|
||||||
# at the start of the URI
|
|
||||||
property external_videoplayback_proxy : Array(String) = [] of String
|
|
||||||
|
|
||||||
property pubsub_domain : String = ""
|
# Materialious redirects
|
||||||
|
property materialious_domain : String?
|
||||||
property ignore_user_tokens : Bool = false
|
|
||||||
|
|
||||||
property server_id_cookie_name : String = "INVIDIOUS_SERVER_ID"
|
|
||||||
|
|
||||||
property tokens_server : String = ""
|
|
||||||
|
|
||||||
{% if flag?(:linux) %}
|
|
||||||
property reload_config_automatically : Bool = true
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
def disabled?(option)
|
def disabled?(option)
|
||||||
case disabled = CONFIG.disable_proxy
|
case disabled = CONFIG.disable_proxy
|
||||||
|
@ -238,64 +192,6 @@ class Config
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.reload
|
|
||||||
LOGGER.info("Config: Reloading configuration")
|
|
||||||
# Load config from file or YAML string env var
|
|
||||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
|
||||||
env_config_yaml = "INVIDIOUS_CONFIG"
|
|
||||||
|
|
||||||
config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
|
|
||||||
config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
|
|
||||||
|
|
||||||
begin
|
|
||||||
config = Config.from_yaml(config_yaml)
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error("Config: Error when reloading configuration: '#{ex.message}'")
|
|
||||||
config = CONFIG
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: Preserve old config and don't exit on fail
|
|
||||||
{% for ivar in Config.instance_vars %}
|
|
||||||
CONFIG.{{ivar}} = config.{{ivar}}
|
|
||||||
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
|
||||||
|
|
||||||
if ENV.has_key?({{env_id}})
|
|
||||||
env_value = ENV.fetch({{env_id}})
|
|
||||||
success = false
|
|
||||||
|
|
||||||
# Use YAML converter if specified
|
|
||||||
{% ann = ivar.annotation(::YAML::Field) %}
|
|
||||||
{% if ann && ann[:converter] %}
|
|
||||||
CONFIG.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
|
|
||||||
success = true
|
|
||||||
|
|
||||||
# Use regular YAML parser otherwise
|
|
||||||
{% else %}
|
|
||||||
{% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
|
|
||||||
# Sort types to avoid parsing nulls and numbers as strings
|
|
||||||
{% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
|
|
||||||
{{ivar_types}}.each do |ivar_type|
|
|
||||||
if !success
|
|
||||||
begin
|
|
||||||
CONFIG.{{ivar.id}} = ivar_type.from_yaml(env_value)
|
|
||||||
success = true
|
|
||||||
rescue
|
|
||||||
# nop
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
# Exit on fail
|
|
||||||
if !success
|
|
||||||
LOGGER.error("Config: Error when reloading environment variables for the configuration, exiting (fixme!)")
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
LOGGER.info("Config: Reload successfull")
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.load
|
def self.load
|
||||||
# Load config from file or YAML string env var
|
# Load config from file or YAML string env var
|
||||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
||||||
|
@ -345,23 +241,6 @@ class Config
|
||||||
end
|
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 or more."
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
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?
|
||||||
|
|
|
@ -154,17 +154,15 @@ module Invidious::Database::Users
|
||||||
# Update (misc)
|
# Update (misc)
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
# Feeds never need update. PubSubHubBub is the one that sends videos to
|
def feed_needs_update(video : ChannelVideo)
|
||||||
# invidious.
|
request = <<-SQL
|
||||||
# def feed_needs_update(video : ChannelVideo)
|
UPDATE users
|
||||||
# request = <<-SQL
|
SET feed_needs_update = true
|
||||||
# UPDATE users
|
WHERE $1 = ANY(subscriptions)
|
||||||
# SET feed_needs_update = true
|
SQL
|
||||||
# WHERE $1 = ANY(subscriptions)
|
|
||||||
# SQL
|
|
||||||
|
|
||||||
# PG_DB.exec(request, video.ucid)
|
PG_DB.exec(request, video.ucid)
|
||||||
# end
|
end
|
||||||
|
|
||||||
def update_preferences(user : User)
|
def update_preferences(user : User)
|
||||||
request = <<-SQL
|
request = <<-SQL
|
||||||
|
|
|
@ -10,8 +10,8 @@ module Invidious::Database::Videos
|
||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
REDIS_DB.set(video.id, video.info.to_json, ex: 14400)
|
REDIS_DB.set(video.id, video.info.to_json, ex: 3600)
|
||||||
REDIS_DB.set(video.id + ":time", video.updated, ex: 14400)
|
REDIS_DB.set(video.id + ":time", video.updated, ex: 3600)
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(id)
|
def delete(id)
|
||||||
|
|
|
@ -18,40 +18,6 @@ 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
|
||||||
|
|
|
@ -43,8 +43,6 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
|
||||||
# URLs for the error message below
|
# URLs for the error message below
|
||||||
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
|
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
|
||||||
url_search_issues = "https://github.com/iv-org/invidious/issues"
|
url_search_issues = "https://github.com/iv-org/invidious/issues"
|
||||||
url_search_issues += "?q=is:issue+is:open+"
|
|
||||||
url_search_issues += URI.encode_www_form("[Bug] #{issue_title}")
|
|
||||||
|
|
||||||
url_switch = "https://redirect.invidious.io" + env.request.resource
|
url_switch = "https://redirect.invidious.io" + env.request.resource
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -1,16 +1,3 @@
|
||||||
@[Flags]
|
|
||||||
enum VideoBadges
|
|
||||||
LiveNow
|
|
||||||
Premium
|
|
||||||
ThreeD
|
|
||||||
FourK
|
|
||||||
New
|
|
||||||
EightK
|
|
||||||
VR180
|
|
||||||
VR360
|
|
||||||
ClosedCaptions
|
|
||||||
end
|
|
||||||
|
|
||||||
struct SearchVideo
|
struct SearchVideo
|
||||||
include DB::Serializable
|
include DB::Serializable
|
||||||
|
|
||||||
|
@ -22,9 +9,10 @@ struct SearchVideo
|
||||||
property views : Int64
|
property views : Int64
|
||||||
property description_html : String
|
property description_html : String
|
||||||
property length_seconds : Int32
|
property length_seconds : Int32
|
||||||
|
property live_now : Bool
|
||||||
|
property premium : Bool
|
||||||
property premiere_timestamp : Time?
|
property premiere_timestamp : Time?
|
||||||
property author_verified : Bool
|
property author_verified : Bool
|
||||||
property badges : VideoBadges
|
|
||||||
|
|
||||||
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
||||||
query_params["v"] = self.id
|
query_params["v"] = self.id
|
||||||
|
@ -100,20 +88,13 @@ struct SearchVideo
|
||||||
json.field "published", self.published.to_unix
|
json.field "published", self.published.to_unix
|
||||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||||
json.field "lengthSeconds", self.length_seconds
|
json.field "lengthSeconds", self.length_seconds
|
||||||
json.field "liveNow", self.badges.live_now?
|
json.field "liveNow", self.live_now
|
||||||
json.field "premium", self.badges.premium?
|
json.field "premium", self.premium
|
||||||
json.field "isUpcoming", self.upcoming?
|
json.field "isUpcoming", self.upcoming?
|
||||||
|
|
||||||
if self.premiere_timestamp
|
if self.premiere_timestamp
|
||||||
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
||||||
end
|
end
|
||||||
json.field "isNew", self.badges.new?
|
|
||||||
json.field "is4k", self.badges.four_k?
|
|
||||||
json.field "is8k", self.badges.eight_k?
|
|
||||||
json.field "isVr180", self.badges.vr180?
|
|
||||||
json.field "isVr360", self.badges.vr360?
|
|
||||||
json.field "is3d", self.badges.three_d?
|
|
||||||
json.field "hasCaptions", self.badges.closed_captions?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -175,6 +175,7 @@ module Invidious::SigHelper
|
||||||
@queue = {} of TransactionID => Transaction
|
@queue = {} of TransactionID => Transaction
|
||||||
|
|
||||||
@conn : Connection
|
@conn : Connection
|
||||||
|
|
||||||
@uri_or_path : String
|
@uri_or_path : String
|
||||||
|
|
||||||
def initialize(@uri_or_path)
|
def initialize(@uri_or_path)
|
||||||
|
@ -191,6 +192,7 @@ module Invidious::SigHelper
|
||||||
loop do
|
loop do
|
||||||
begin
|
begin
|
||||||
receive_data
|
receive_data
|
||||||
|
# Handle all errors
|
||||||
rescue ex
|
rescue ex
|
||||||
LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...")
|
LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...")
|
||||||
# We close the socket because for some reason is not closed.
|
# We close the socket because for some reason is not closed.
|
||||||
|
@ -200,8 +202,8 @@ module Invidious::SigHelper
|
||||||
@conn = Connection.new(@uri_or_path)
|
@conn = Connection.new(@uri_or_path)
|
||||||
LOGGER.info("SigHelper: Reconnected to SigHelper!")
|
LOGGER.info("SigHelper: Reconnected to SigHelper!")
|
||||||
rescue ex
|
rescue ex
|
||||||
LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying")
|
LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}' retrying")
|
||||||
sleep 500.milliseconds
|
sleep 0.5
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
break if !@conn.closed?
|
break if !@conn.closed?
|
||||||
|
@ -323,6 +325,11 @@ module Invidious::SigHelper
|
||||||
return @socket.closed?
|
return @socket.closed?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def remote_address : Socket::IPAddress
|
||||||
|
|
||||||
|
return @socket.remote_address
|
||||||
|
end
|
||||||
|
|
||||||
def close : Nil
|
def close : Nil
|
||||||
@socket.close if !@socket.closed?
|
@socket.close if !@socket.closed?
|
||||||
end
|
end
|
||||||
|
|
|
@ -294,7 +294,7 @@ def subscribe_pubsub(topic, key)
|
||||||
signature = "#{time}:#{nonce}"
|
signature = "#{time}:#{nonce}"
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"hub.callback" => "#{PUBSUB_HOST_URL}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
"hub.callback" => "#{HOST_URL}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
||||||
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?#{topic}",
|
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?#{topic}",
|
||||||
"hub.verify" => "async",
|
"hub.verify" => "async",
|
||||||
"hub.mode" => "subscribe",
|
"hub.mode" => "subscribe",
|
||||||
|
@ -383,36 +383,3 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
|
||||||
end
|
end
|
||||||
return text
|
return text
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generates a list of external videoplayback proxies for
|
|
||||||
# CSP
|
|
||||||
def gen_videoplayback_proxy_list
|
|
||||||
if !CONFIG.external_videoplayback_proxy.empty?
|
|
||||||
external_videoplayback_proxy = ""
|
|
||||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
|
||||||
external_videoplayback_proxy += " #{proxy}"
|
|
||||||
end
|
|
||||||
else
|
|
||||||
external_videoplayback_proxy = ""
|
|
||||||
end
|
|
||||||
return external_videoplayback_proxy
|
|
||||||
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
|
|
||||||
|
|
|
@ -4,30 +4,6 @@ module Invidious::HttpServer
|
||||||
module Utils
|
module Utils
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
@@proxy_alive : String = ""
|
|
||||||
|
|
||||||
def check_external_proxy
|
|
||||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
|
||||||
begin
|
|
||||||
response = HTTP::Client.get("#{proxy}/health")
|
|
||||||
if response.status_code == 200
|
|
||||||
@@proxy_alive = proxy
|
|
||||||
LOGGER.debug("CheckExternalProxy: Proxy set to: '#{proxy}'")
|
|
||||||
break
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
LOGGER.debug("CheckExternalProxy: Proxy '#{proxy}' is not available")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if @@proxy_alive.empty?
|
|
||||||
LOGGER.warn("CheckExternalProxy: No proxies alive! Using own server proxy")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_external_proxy
|
|
||||||
return @@proxy_alive
|
|
||||||
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)
|
||||||
|
|
||||||
|
@ -38,11 +14,7 @@ module Invidious::HttpServer
|
||||||
url.query_params = params
|
url.query_params = params
|
||||||
|
|
||||||
if absolute
|
if absolute
|
||||||
if !@@proxy_alive.empty?
|
return "#{HOST_URL}#{url.request_target}"
|
||||||
return "#{@@proxy_alive}#{url.request_target}"
|
|
||||||
else
|
|
||||||
return "#{HOST_URL}#{url.request_target}"
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
return url.request_target
|
return url.request_target
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
|
|
||||||
def initialize
|
|
||||||
end
|
|
||||||
|
|
||||||
def begin
|
|
||||||
loop do
|
|
||||||
HttpServer::Utils.check_external_proxy
|
|
||||||
LOGGER.info("CheckExternalProxy: Done, sleeping for 10 seconds")
|
|
||||||
sleep 10.seconds
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,13 +0,0 @@
|
||||||
class Invidious::Jobs::RefreshSessionTokens < Invidious::Jobs::BaseJob
|
|
||||||
def initialize
|
|
||||||
end
|
|
||||||
|
|
||||||
def begin
|
|
||||||
loop do
|
|
||||||
SessionTokens.refresh_tokens
|
|
||||||
LOGGER.info("RefreshTokens: Done, sleeping for 5 seconds")
|
|
||||||
sleep 5.seconds
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -30,8 +30,6 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob
|
||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
response = subscribe_pubsub(ucid, hmac_key)
|
response = subscribe_pubsub(ucid, hmac_key)
|
||||||
LOGGER.debug("SubscribeToFeedsJob: Subscribed to #{ucid}.")
|
|
||||||
LOGGER.trace("SubscribeToFeedsJob: response.body: #{response.body}")
|
|
||||||
|
|
||||||
if response.status_code >= 400
|
if response.status_code >= 400
|
||||||
LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{response.body}")
|
LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{response.body}")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -270,7 +270,7 @@ end
|
||||||
|
|
||||||
def subscribe_playlist(user, playlist)
|
def subscribe_playlist(user, playlist)
|
||||||
playlist = InvidiousPlaylist.new({
|
playlist = InvidiousPlaylist.new({
|
||||||
title: playlist.title[..150],
|
title: playlist.title.byte_slice(0, 150),
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
author: user.email,
|
author: user.email,
|
||||||
description: "", # Max 5000 characters
|
description: "", # Max 5000 characters
|
||||||
|
|
|
@ -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?
|
|
||||||
invidious_companion = CONFIG.invidious_companion.sample
|
|
||||||
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Since some implementations create playlists based on resolution regardless of different codecs,
|
# 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 }
|
||||||
|
@ -32,21 +27,36 @@ module Invidious::Routes::API::Manifest
|
||||||
haltf env, status_code: response.status_code
|
haltf env, status_code: response.status_code
|
||||||
end
|
end
|
||||||
|
|
||||||
# Proxy URLs for video playback on invidious.
|
manifest = response.body
|
||||||
# Other API clients can get the original URLs by omiting `local=true`.
|
|
||||||
manifest = response.body.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
||||||
url = baseurl.lchop("<BaseURL>").rchop("</BaseURL>")
|
url = baseurl.lchop("<BaseURL>")
|
||||||
url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local
|
url = url.rchop("</BaseURL>")
|
||||||
|
|
||||||
|
if local
|
||||||
|
uri = URI.parse(url)
|
||||||
|
if CONFIG.external_videoplayback_proxy
|
||||||
|
url = "#{CONFIG.external_videoplayback_proxy}#{uri.request_target}host/#{uri.host}/"
|
||||||
|
else
|
||||||
|
url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
"<BaseURL>#{url}</BaseURL>"
|
"<BaseURL>#{url}</BaseURL>"
|
||||||
end
|
end
|
||||||
|
|
||||||
return manifest
|
return manifest
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ditto, only proxify URLs if `local=true` is used
|
adaptive_fmts = video.adaptive_fmts
|
||||||
|
|
||||||
if local
|
if local
|
||||||
video.adaptive_fmts.each do |fmt|
|
adaptive_fmts.each do |fmt|
|
||||||
fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true))
|
if CONFIG.external_videoplayback_proxy
|
||||||
|
fmt["url"] = JSON::Any.new("#{CONFIG.external_videoplayback_proxy}#{URI.parse(fmt["url"].as_s).request_target}")
|
||||||
|
else
|
||||||
|
fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -60,10 +70,6 @@ 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",
|
||||||
|
@ -218,19 +224,7 @@ module Invidious::Routes::API::Manifest
|
||||||
|
|
||||||
raw_params["local"] = "true"
|
raw_params["local"] = "true"
|
||||||
|
|
||||||
proxy = Invidious::HttpServer::Utils.get_external_proxy
|
"#{HOST_URL}/videoplayback?#{raw_params}"
|
||||||
|
|
||||||
if CONFIG.https_only
|
|
||||||
scheme = "https://"
|
|
||||||
else
|
|
||||||
scheme = "http://"
|
|
||||||
end
|
|
||||||
|
|
||||||
if !proxy.empty?
|
|
||||||
"#{proxy}/videoplayback?#{raw_params}"
|
|
||||||
else
|
|
||||||
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -253,12 +247,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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -263,60 +263,59 @@ 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)
|
||||||
|
|
|
@ -20,25 +20,18 @@ 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?
|
|
||||||
extra_media_csp = " #{CONFIG.invidious_companion.sample.public_url}"
|
|
||||||
extra_connect_csp = " #{CONFIG.invidious_companion.sample.public_url}"
|
|
||||||
end
|
|
||||||
|
|
||||||
if !CONFIG.external_videoplayback_proxy.empty?
|
|
||||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
|
||||||
extra_media_csp += " #{proxy}"
|
|
||||||
extra_connect_csp += " #{proxy}"
|
|
||||||
end
|
|
||||||
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
|
||||||
|
|
||||||
|
if CONFIG.external_videoplayback_proxy
|
||||||
|
external_videoplayback_proxy = " #{CONFIG.external_videoplayback_proxy}"
|
||||||
|
else
|
||||||
|
external_videoplayback_proxy = ""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only allow the pages at /embed/* to be embedded
|
# Only allow the pages at /embed/* to be embedded
|
||||||
|
@ -56,7 +49,7 @@ module Invidious::Routes::BeforeAll
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self'" + extra_connect_csp,
|
"connect-src 'self'" + external_videoplayback_proxy,
|
||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
"media-src 'self' blob:" + extra_media_csp,
|
"media-src 'self' blob:" + extra_media_csp,
|
||||||
"child-src 'self' blob:",
|
"child-src 'self' blob:",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -157,12 +157,10 @@ module Invidious::Routes::Embed
|
||||||
adaptive_fmts = video.adaptive_fmts
|
adaptive_fmts = video.adaptive_fmts
|
||||||
|
|
||||||
if params.local
|
if params.local
|
||||||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
||||||
|
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
|
|
||||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
|
||||||
|
|
||||||
video_streams = video.video_streams
|
video_streams = video.video_streams
|
||||||
audio_streams = video.audio_streams
|
audio_streams = video.audio_streams
|
||||||
|
|
||||||
|
|
|
@ -192,9 +192,11 @@ module Invidious::Routes::Feeds
|
||||||
views: views,
|
views: views,
|
||||||
description_html: description_html,
|
description_html: description_html,
|
||||||
length_seconds: 0,
|
length_seconds: 0,
|
||||||
|
live_now: false,
|
||||||
|
paid: false,
|
||||||
|
premium: false,
|
||||||
premiere_timestamp: nil,
|
premiere_timestamp: nil,
|
||||||
author_verified: false,
|
author_verified: false,
|
||||||
badges: VideoBadges::None,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -416,22 +418,18 @@ 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
|
end
|
||||||
|
|
||||||
if CONFIG.enable_user_notifications
|
if CONFIG.enable_user_notifications
|
||||||
# Deliver notifications to `/api/v1/auth/notifications`
|
# Deliver notifications to `/api/v1/auth/notifications`
|
||||||
payload = {
|
payload = {
|
||||||
"topic" => ucid,
|
"topic" => video.ucid,
|
||||||
"videoId" => id,
|
"videoId" => video.id,
|
||||||
"published" => published.to_unix,
|
"published" => published.to_unix,
|
||||||
}.to_json
|
}.to_json
|
||||||
PG_DB.exec("NOTIFY notifications, E'#{payload}'")
|
PG_DB.exec("NOTIFY notifications, E'#{payload}'")
|
||||||
|
@ -439,23 +437,23 @@ module Invidious::Routes::Feeds
|
||||||
|
|
||||||
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
|
||||||
if CONFIG.enable_user_notifications
|
if CONFIG.enable_user_notifications
|
||||||
Invidious::Database::Users.add_notification(video)
|
Invidious::Database::Users.add_notification(video)
|
||||||
# else
|
else
|
||||||
# Invidious::Database::Users.feed_needs_update(video)
|
Invidious::Database::Users.feed_needs_update(video)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,9 +11,29 @@ module Invidious::Routes::Images
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# We're encapsulating this into a proc in order to easily reuse this
|
||||||
|
# portion of the code for each request block below.
|
||||||
|
request_proc = ->(response : HTTP::Client::Response) {
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
response.headers.each do |key, value|
|
||||||
|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||||
|
env.response.headers[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
if response.status_code >= 300
|
||||||
|
env.response.headers.delete("Transfer-Encoding")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
proxy_file(response, env)
|
||||||
|
}
|
||||||
|
|
||||||
begin
|
begin
|
||||||
GGPHT_POOL.client &.get(url, headers) do |resp|
|
HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
|
||||||
return self.proxy_image(env, resp)
|
return request_proc.call(resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -41,10 +61,27 @@ module Invidious::Routes::Images
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
request_proc = ->(response : HTTP::Client::Response) {
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
response.headers.each do |key, value|
|
||||||
|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||||
|
env.response.headers[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.headers["Connection"] = "close"
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
if response.status_code >= 300
|
||||||
|
return env.response.headers.delete("Transfer-Encoding")
|
||||||
|
end
|
||||||
|
|
||||||
|
proxy_file(response, env)
|
||||||
|
}
|
||||||
|
|
||||||
begin
|
begin
|
||||||
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
|
HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
|
||||||
env.response.headers["Connection"] = "close"
|
return request_proc.call(resp)
|
||||||
return self.proxy_image(env, resp)
|
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -64,9 +101,26 @@ module Invidious::Routes::Images
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
request_proc = ->(response : HTTP::Client::Response) {
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
response.headers.each do |key, value|
|
||||||
|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||||
|
env.response.headers[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
if response.status_code >= 300 && response.status_code != 404
|
||||||
|
return env.response.headers.delete("Transfer-Encoding")
|
||||||
|
end
|
||||||
|
|
||||||
|
proxy_file(response, env)
|
||||||
|
}
|
||||||
|
|
||||||
begin
|
begin
|
||||||
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
|
HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
|
||||||
return self.proxy_image(env, resp)
|
return request_proc.call(resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -111,7 +165,8 @@ 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("i9").client &.head(thumbnail_resource_path, headers).status_code == 200
|
# This can likely be optimized into a (small) pool sometime in the future.
|
||||||
|
if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200
|
||||||
name = thumb[:url] + ".jpg"
|
name = thumb[:url] + ".jpg"
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
@ -126,28 +181,29 @@ module Invidious::Routes::Images
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
request_proc = ->(response : HTTP::Client::Response) {
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
response.headers.each do |key, value|
|
||||||
|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||||
|
env.response.headers[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
if response.status_code >= 300 && response.status_code != 404
|
||||||
|
return env.response.headers.delete("Transfer-Encoding")
|
||||||
|
end
|
||||||
|
|
||||||
|
proxy_file(response, env)
|
||||||
|
}
|
||||||
|
|
||||||
begin
|
begin
|
||||||
get_ytimg_pool("i").client &.get(url, headers) do |resp|
|
# This can likely be optimized into a (small) pool sometime in the future.
|
||||||
return self.proxy_image(env, resp)
|
HTTP::Client.get("https://i.ytimg.com#{url}") do |resp|
|
||||||
|
return request_proc.call(resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private def self.proxy_image(env, response)
|
|
||||||
env.response.status_code = response.status_code
|
|
||||||
response.headers.each do |key, value|
|
|
||||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
|
||||||
env.response.headers[key] = value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
|
|
||||||
if response.status_code >= 300
|
|
||||||
return env.response.headers.delete("Transfer-Encoding")
|
|
||||||
end
|
|
||||||
|
|
||||||
return proxy_file(response, env)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -64,8 +64,6 @@ module Invidious::Routes::Login
|
||||||
# TOR or I2P address
|
# TOR or I2P address
|
||||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
||||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
|
|
||||||
else
|
else
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||||
end
|
end
|
||||||
|
@ -172,8 +170,6 @@ module Invidious::Routes::Login
|
||||||
# TOR or I2P address
|
# TOR or I2P address
|
||||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
||||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
|
|
||||||
else
|
else
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,10 +27,6 @@ module Invidious::Routes::PreferencesRoute
|
||||||
annotations_subscribed ||= "off"
|
annotations_subscribed ||= "off"
|
||||||
annotations_subscribed = annotations_subscribed == "on"
|
annotations_subscribed = annotations_subscribed == "on"
|
||||||
|
|
||||||
preload = env.params.body["preload"]?.try &.as(String)
|
|
||||||
preload ||= "off"
|
|
||||||
preload = preload == "on"
|
|
||||||
|
|
||||||
autoplay = env.params.body["autoplay"]?.try &.as(String)
|
autoplay = env.params.body["autoplay"]?.try &.as(String)
|
||||||
autoplay ||= "off"
|
autoplay ||= "off"
|
||||||
autoplay = autoplay == "on"
|
autoplay = autoplay == "on"
|
||||||
|
@ -148,7 +144,6 @@ module Invidious::Routes::PreferencesRoute
|
||||||
preferences = Preferences.from_json({
|
preferences = Preferences.from_json({
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
annotations_subscribed: annotations_subscribed,
|
annotations_subscribed: annotations_subscribed,
|
||||||
preload: preload,
|
|
||||||
autoplay: autoplay,
|
autoplay: autoplay,
|
||||||
captions: captions,
|
captions: captions,
|
||||||
comments: comments,
|
comments: comments,
|
||||||
|
@ -228,8 +223,6 @@ module Invidious::Routes::PreferencesRoute
|
||||||
# TOR or I2P address
|
# TOR or I2P address
|
||||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
||||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
|
|
||||||
else
|
else
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||||
end
|
end
|
||||||
|
@ -271,8 +264,6 @@ module Invidious::Routes::PreferencesRoute
|
||||||
# TOR or I2P address
|
# TOR or I2P address
|
||||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
||||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
|
|
||||||
else
|
else
|
||||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||||
end
|
end
|
||||||
|
|
18
src/invidious/routes/switch_backend.cr
Normal file
18
src/invidious/routes/switch_backend.cr
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% 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"]
|
||||||
|
|
||||||
|
# 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["SERVER_ID"] = Invidious::User::Cookies.server_id(CONFIG.alternative_domains[alt], backend_id)
|
||||||
|
else
|
||||||
|
env.response.cookies["SERVER_ID"] = Invidious::User::Cookies.server_id(CONFIG.domain, backend_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
env.redirect referer
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,11 +3,6 @@ module Invidious::Routes::VideoPlayback
|
||||||
def self.get_video_playback(env)
|
def self.get_video_playback(env)
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
query_params = env.params.query
|
query_params = env.params.query
|
||||||
array = UInt8[0x78, 0]
|
|
||||||
protobuf = Bytes.new(array.size)
|
|
||||||
array.each_with_index do |byte, index|
|
|
||||||
protobuf[index] = byte
|
|
||||||
end
|
|
||||||
|
|
||||||
fvip = query_params["fvip"]? || "3"
|
fvip = query_params["fvip"]? || "3"
|
||||||
mns = query_params["mn"]?.try &.split(",")
|
mns = query_params["mn"]?.try &.split(",")
|
||||||
|
@ -47,7 +42,11 @@ module Invidious::Routes::VideoPlayback
|
||||||
headers["Range"] = "bytes=#{range_for_head}"
|
headers["Range"] = "bytes=#{range_for_head}"
|
||||||
end
|
end
|
||||||
|
|
||||||
client = make_client(URI.parse(host), region, force_resolve: true)
|
headers["Origin"] = "https://www.youtube.com"
|
||||||
|
headers["Referer"] = "https://www.youtube.com/"
|
||||||
|
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0"
|
||||||
|
|
||||||
|
client = make_client(URI.parse(host), region, force_resolve = true)
|
||||||
response = HTTP::Client::Response.new(500)
|
response = HTTP::Client::Response.new(500)
|
||||||
error = ""
|
error = ""
|
||||||
5.times do
|
5.times do
|
||||||
|
@ -62,7 +61,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
if new_host != host
|
if new_host != host
|
||||||
host = new_host
|
host = new_host
|
||||||
client.close
|
client.close
|
||||||
client = make_client(URI.parse(new_host), region, force_resolve: true)
|
client = make_client(URI.parse(new_host), region, force_resolve = true)
|
||||||
end
|
end
|
||||||
|
|
||||||
url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
|
url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
|
||||||
|
@ -76,7 +75,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
fvip = "3"
|
fvip = "3"
|
||||||
|
|
||||||
host = "https://r#{fvip}---#{mn}.googlevideo.com"
|
host = "https://r#{fvip}---#{mn}.googlevideo.com"
|
||||||
client = make_client(URI.parse(host), region, force_resolve: true)
|
client = make_client(URI.parse(host), region, force_resolve = true)
|
||||||
rescue ex
|
rescue ex
|
||||||
error = ex.message
|
error = ex.message
|
||||||
end
|
end
|
||||||
|
@ -101,7 +100,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
client.post(url, headers, protobuf) do |resp|
|
client.get(url, headers) do |resp|
|
||||||
resp.headers.each do |key, value|
|
resp.headers.each do |key, value|
|
||||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||||
env.response.headers[key] = value
|
env.response.headers[key] = value
|
||||||
|
@ -152,7 +151,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
|
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
client.post(url, headers, protobuf) do |resp|
|
client.get(url, headers) do |resp|
|
||||||
if first_chunk
|
if first_chunk
|
||||||
if !env.request.headers["Range"]? && resp.status_code == 206
|
if !env.request.headers["Range"]? && resp.status_code == 206
|
||||||
env.response.status_code = 200
|
env.response.status_code = 200
|
||||||
|
@ -201,7 +200,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
client.close
|
client.close
|
||||||
client = make_client(URI.parse(host), region, force_resolve: true)
|
client = make_client(URI.parse(host), region, force_resolve = true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -258,11 +257,6 @@ module Invidious::Routes::VideoPlayback
|
||||||
# YouTube /videoplayback links expire after 6 hours,
|
# YouTube /videoplayback links expire after 6 hours,
|
||||||
# so we have a mechanism here to redirect to the latest version
|
# so we have a mechanism here to redirect to the latest version
|
||||||
def self.latest_version(env)
|
def self.latest_version(env)
|
||||||
if CONFIG.invidious_companion.present?
|
|
||||||
invidious_companion = CONFIG.invidious_companion.sample
|
|
||||||
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
|
|
||||||
end
|
|
||||||
|
|
||||||
id = env.params.query["id"]?
|
id = env.params.query["id"]?
|
||||||
itag = env.params.query["itag"]?.try &.to_i?
|
itag = env.params.query["itag"]?.try &.to_i?
|
||||||
|
|
||||||
|
@ -304,16 +298,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
end
|
end
|
||||||
|
|
||||||
if local
|
if local
|
||||||
external_proxy = Invidious::HttpServer::Utils.get_external_proxy
|
url = URI.parse(url).request_target.not_nil!
|
||||||
if !external_proxy.empty?
|
|
||||||
url = URI.parse(url)
|
|
||||||
external_proxy = URI.parse(external_proxy)
|
|
||||||
url.host = external_proxy.host
|
|
||||||
url.port = external_proxy.port
|
|
||||||
url = url.to_s
|
|
||||||
else
|
|
||||||
url = URI.parse(url).request_target.not_nil!
|
|
||||||
end
|
|
||||||
url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title
|
url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -128,12 +128,10 @@ module Invidious::Routes::Watch
|
||||||
end
|
end
|
||||||
|
|
||||||
if params.local
|
if params.local
|
||||||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
||||||
|
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
|
|
||||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
|
||||||
|
|
||||||
video_streams = video.video_streams
|
video_streams = video.video_streams
|
||||||
audio_streams = video.audio_streams
|
audio_streams = video.audio_streams
|
||||||
|
|
||||||
|
@ -144,11 +142,6 @@ module Invidious::Routes::Watch
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Removes non default audio tracks
|
|
||||||
audio_streams.reject! do |z|
|
|
||||||
z if z.dig?("audioTrack", "audioIsDefault") == false
|
|
||||||
end
|
|
||||||
|
|
||||||
# Older videos may not have audio sources available.
|
# Older videos may not have audio sources available.
|
||||||
# We redirect here so they're not unplayable
|
# We redirect here so they're not unplayable
|
||||||
if audio_streams.empty? && !video.live_now
|
if audio_streams.empty? && !video.live_now
|
||||||
|
@ -211,12 +204,6 @@ module Invidious::Routes::Watch
|
||||||
captions: video.captions
|
captions: video.captions
|
||||||
)
|
)
|
||||||
|
|
||||||
begin
|
|
||||||
video_url = fmt_stream[0]["url"].to_s
|
|
||||||
rescue
|
|
||||||
video_url = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
templated "watch"
|
templated "watch"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -347,18 +334,14 @@ module Invidious::Routes::Watch
|
||||||
env.params.query["label"] = URI.decode_www_form(label.as_s)
|
env.params.query["label"] = URI.decode_www_form(label.as_s)
|
||||||
|
|
||||||
return Invidious::Routes::API::V1::Videos.captions(env)
|
return Invidious::Routes::API::V1::Videos.captions(env)
|
||||||
elsif itag = download_widget["itag"]?.try &.as_i.to_s
|
elsif itag = download_widget["itag"]?.try &.as_i
|
||||||
# URL params specific to /latest_version
|
# URL params specific to /latest_version
|
||||||
env.params.query["id"] = video_id
|
env.params.query["id"] = video_id
|
||||||
|
env.params.query["itag"] = itag.to_s
|
||||||
env.params.query["title"] = filename
|
env.params.query["title"] = filename
|
||||||
env.params.query["local"] = "true"
|
env.params.query["local"] = "true"
|
||||||
|
|
||||||
if (CONFIG.invidious_companion.present?)
|
return Invidious::Routes::VideoPlayback.latest_version(env)
|
||||||
video = get_video(video_id)
|
|
||||||
return env.redirect "#{video.invidious_companion["baseUrl"].as_s}/latest_version?#{env.params.query}"
|
|
||||||
else
|
|
||||||
return Invidious::Routes::VideoPlayback.latest_version(env)
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
return error_template(400, "Invalid label or itag")
|
return error_template(400, "Invalid label or itag")
|
||||||
end
|
end
|
||||||
|
|
|
@ -21,6 +21,7 @@ module Invidious::Routing
|
||||||
get "/privacy", Routes::Misc, :privacy
|
get "/privacy", Routes::Misc, :privacy
|
||||||
get "/licenses", Routes::Misc, :licenses
|
get "/licenses", Routes::Misc, :licenses
|
||||||
get "/redirect", Routes::Misc, :cross_instance_redirect
|
get "/redirect", Routes::Misc, :cross_instance_redirect
|
||||||
|
get "/switchbackend", Routes::BackendSwitcher, :switch
|
||||||
|
|
||||||
self.register_channel_routes
|
self.register_channel_routes
|
||||||
self.register_watch_routes
|
self.register_watch_routes
|
||||||
|
@ -243,16 +244,17 @@ module Invidious::Routing
|
||||||
|
|
||||||
# Channels
|
# Channels
|
||||||
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
|
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
|
||||||
get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest
|
|
||||||
get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos
|
|
||||||
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
|
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
|
||||||
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
|
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
|
||||||
get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
|
get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
|
||||||
get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
|
get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
|
||||||
get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists
|
|
||||||
get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community
|
|
||||||
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
|
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
|
||||||
get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search
|
|
||||||
|
{% for route in {"videos", "latest", "playlists", "community", "search"} %}
|
||||||
|
get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
|
||||||
|
get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
|
||||||
|
{% end %}
|
||||||
|
|
||||||
# Posts
|
# Posts
|
||||||
get "/api/v1/post/:id", {{namespace}}::Channels, :post
|
get "/api/v1/post/:id", {{namespace}}::Channels, :post
|
||||||
|
@ -270,6 +272,11 @@ module Invidious::Routing
|
||||||
|
|
||||||
# Authenticated
|
# Authenticated
|
||||||
|
|
||||||
|
# The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
|
||||||
|
#
|
||||||
|
# Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||||
|
# Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
|
||||||
|
|
||||||
get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
|
get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
|
||||||
post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
|
post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
|
||||||
|
|
||||||
|
|
|
@ -45,5 +45,18 @@ struct Invidious::User
|
||||||
samesite: HTTP::Cookie::SameSite::Lax
|
samesite: HTTP::Cookie::SameSite::Lax
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Server ID (SERVER_ID) cookie used for Sticky Sessions
|
||||||
|
# Parameter "domain" comes from the global config
|
||||||
|
def server_id(domain : String?, server_id) : HTTP::Cookie
|
||||||
|
return HTTP::Cookie.new(
|
||||||
|
name: "SERVER_ID",
|
||||||
|
domain: domain,
|
||||||
|
value: server_id,
|
||||||
|
secure: false,
|
||||||
|
http_only: true,
|
||||||
|
samesite: HTTP::Cookie::SameSite::Lax
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,6 @@ struct Preferences
|
||||||
|
|
||||||
property annotations : Bool = CONFIG.default_user_preferences.annotations
|
property annotations : Bool = CONFIG.default_user_preferences.annotations
|
||||||
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
|
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
|
||||||
property preload : Bool = CONFIG.default_user_preferences.preload
|
|
||||||
property autoplay : Bool = CONFIG.default_user_preferences.autoplay
|
property autoplay : Bool = CONFIG.default_user_preferences.autoplay
|
||||||
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
|
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ struct Video
|
||||||
# NOTE: don't forget to bump this number if any change is made to
|
# NOTE: don't forget to bump this number if any change is made to
|
||||||
# the `params` structure in videos/parser.cr!!!
|
# the `params` structure in videos/parser.cr!!!
|
||||||
#
|
#
|
||||||
SCHEMA_VERSION = 3
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
property id : String
|
property id : String
|
||||||
|
|
||||||
|
@ -26,6 +26,12 @@ struct Video
|
||||||
@[DB::Field(ignore: true)]
|
@[DB::Field(ignore: true)]
|
||||||
@captions = [] of Invidious::Videos::Captions::Metadata
|
@captions = [] of Invidious::Videos::Captions::Metadata
|
||||||
|
|
||||||
|
@[DB::Field(ignore: true)]
|
||||||
|
property adaptive_fmts : Array(Hash(String, JSON::Any))?
|
||||||
|
|
||||||
|
@[DB::Field(ignore: true)]
|
||||||
|
property fmt_stream : Array(Hash(String, JSON::Any))?
|
||||||
|
|
||||||
@[DB::Field(ignore: true)]
|
@[DB::Field(ignore: true)]
|
||||||
property description : String?
|
property description : String?
|
||||||
|
|
||||||
|
@ -92,24 +98,72 @@ struct Video
|
||||||
|
|
||||||
# Methods for parsing streaming data
|
# Methods for parsing streaming data
|
||||||
|
|
||||||
def fmt_stream : Array(Hash(String, JSON::Any))
|
def convert_url(fmt)
|
||||||
if formats = info.dig?("streamingData", "formats")
|
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
|
||||||
return formats
|
sp = cfr["sp"]
|
||||||
.as_a.map(&.as_h)
|
url = URI.parse(cfr["url"])
|
||||||
.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
params = url.query_params
|
||||||
|
|
||||||
|
LOGGER.debug("Videos: Decoding '#{cfr}'")
|
||||||
|
|
||||||
|
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
|
||||||
|
params[sp] = unsig if unsig
|
||||||
else
|
else
|
||||||
return [] of Hash(String, JSON::Any)
|
url = URI.parse(fmt["url"].as_s)
|
||||||
|
params = url.query_params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
|
||||||
|
params["n"] = n if n
|
||||||
|
|
||||||
|
if token = CONFIG.po_token
|
||||||
|
params["pot"] = token
|
||||||
|
end
|
||||||
|
|
||||||
|
params["host"] = url.host.not_nil!
|
||||||
|
if region = self.info["region"]?.try &.as_s
|
||||||
|
params["region"] = region
|
||||||
|
end
|
||||||
|
|
||||||
|
url.query_params = params
|
||||||
|
LOGGER.trace("Videos: new url is '#{url}'")
|
||||||
|
|
||||||
|
return url.to_s
|
||||||
|
rescue ex
|
||||||
|
LOGGER.debug("Videos: Error when parsing video URL")
|
||||||
|
LOGGER.trace(ex.inspect_with_backtrace)
|
||||||
|
return ""
|
||||||
end
|
end
|
||||||
|
|
||||||
def adaptive_fmts : Array(Hash(String, JSON::Any))
|
def fmt_stream
|
||||||
if formats = info.dig?("streamingData", "adaptiveFormats")
|
return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
|
||||||
return formats
|
|
||||||
.as_a.map(&.as_h)
|
fmt_stream = info.dig?("streamingData", "formats")
|
||||||
.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
|
||||||
else
|
|
||||||
return [] of Hash(String, JSON::Any)
|
fmt_stream.each do |fmt|
|
||||||
|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
||||||
|
@fmt_stream = fmt_stream
|
||||||
|
return @fmt_stream.as(Array(Hash(String, JSON::Any)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def adaptive_fmts
|
||||||
|
return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts
|
||||||
|
|
||||||
|
fmt_stream = info.dig("streamingData", "adaptiveFormats")
|
||||||
|
.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
|
||||||
|
|
||||||
|
fmt_stream.each do |fmt|
|
||||||
|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
|
||||||
|
end
|
||||||
|
|
||||||
|
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
||||||
|
@adaptive_fmts = fmt_stream
|
||||||
|
|
||||||
|
return @adaptive_fmts.as(Array(Hash(String, JSON::Any)))
|
||||||
end
|
end
|
||||||
|
|
||||||
def video_streams
|
def video_streams
|
||||||
|
@ -192,10 +246,6 @@ struct Video
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def invidious_companion : Hash(String, JSON::Any)?
|
|
||||||
info["invidiousCompanion"]?.try &.as_h || {} of String => JSON::Any
|
|
||||||
end
|
|
||||||
|
|
||||||
# Macros defining getters/setters for various types of data
|
# Macros defining getters/setters for various types of data
|
||||||
|
|
||||||
private macro getset_string(name)
|
private macro getset_string(name)
|
||||||
|
|
|
@ -123,7 +123,6 @@ module Invidious::Videos
|
||||||
"Esperanto",
|
"Esperanto",
|
||||||
"Estonian",
|
"Estonian",
|
||||||
"Filipino",
|
"Filipino",
|
||||||
"Filipino (auto-generated)",
|
|
||||||
"Finnish",
|
"Finnish",
|
||||||
"French",
|
"French",
|
||||||
"French (auto-generated)",
|
"French (auto-generated)",
|
||||||
|
|
|
@ -100,46 +100,42 @@ def extract_video_info(video_id : String)
|
||||||
params = parse_video_info(video_id, player_response)
|
params = parse_video_info(video_id, player_response)
|
||||||
params["reason"] = JSON::Any.new(reason) if reason
|
params["reason"] = JSON::Any.new(reason) if reason
|
||||||
|
|
||||||
if CONFIG.invidious_companion.present?
|
new_player_response = nil
|
||||||
new_player_response = nil
|
|
||||||
|
|
||||||
# Don't use Android test suite client if po_token is passed because po_token doesn't
|
# Don't use Android client if po_token is passed because po_token doesn't
|
||||||
# work for Android test suite client.
|
# work for Android client.
|
||||||
if reason.nil? && CONFIG.po_token.nil?
|
if reason.nil? && CONFIG.po_token.nil?
|
||||||
# Fetch the video streams using an Android client in order to get the
|
# Fetch the video streams using an Android client in order to get the
|
||||||
# decrypted URLs and maybe fix throttling issues (#2194). See the
|
# decrypted URLs and maybe fix throttling issues (#2194). See the
|
||||||
# following issue for an explanation about decrypted URLs:
|
# following issue for an explanation about decrypted URLs:
|
||||||
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
|
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
|
||||||
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
|
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
|
||||||
new_player_response = try_fetch_streaming_data(video_id, client_config)
|
new_player_response = try_fetch_streaming_data(video_id, client_config)
|
||||||
end
|
|
||||||
|
|
||||||
# Replace player response and reset reason
|
|
||||||
if !new_player_response.nil?
|
|
||||||
# Preserve captions & storyboard data before replacement
|
|
||||||
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
|
|
||||||
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
|
|
||||||
|
|
||||||
player_response = new_player_response
|
|
||||||
params.delete("reason")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{"captions", "playabilityStatus", "playerConfig", "storyboards", "invidiousCompanion"}.each do |f|
|
# Last hope
|
||||||
|
# Only trigger if reason found and po_token or didn't work wth Android client.
|
||||||
|
# TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required
|
||||||
|
# if the IP address is not blocked.
|
||||||
|
if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil?
|
||||||
|
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
|
||||||
|
new_player_response = try_fetch_streaming_data(video_id, client_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Replace player response and reset reason
|
||||||
|
if !new_player_response.nil?
|
||||||
|
# Preserve captions & storyboard data before replacement
|
||||||
|
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
|
||||||
|
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
|
||||||
|
|
||||||
|
player_response = new_player_response
|
||||||
|
params.delete("reason")
|
||||||
|
end
|
||||||
|
|
||||||
|
{"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f|
|
||||||
params[f] = player_response[f] if player_response[f]?
|
params[f] = player_response[f] if player_response[f]?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert URLs, if those are present
|
|
||||||
if streaming_data = player_response["streamingData"]?
|
|
||||||
%w[formats adaptiveFormats].each do |key|
|
|
||||||
streaming_data.as_h[key]?.try &.as_a.each do |format|
|
|
||||||
format.as_h["url"] = JSON::Any.new(convert_url(format))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
params["streamingData"] = streaming_data
|
|
||||||
end
|
|
||||||
|
|
||||||
# Data structure version, for cache control
|
# Data structure version, for cache control
|
||||||
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
||||||
|
|
||||||
|
@ -217,17 +213,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
|
||||||
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
|
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
|
||||||
.try { |t| Time.parse_rfc3339(t.as_s) }
|
.try { |t| Time.parse_rfc3339(t.as_s) }
|
||||||
|
|
||||||
premiere_timestamp ||= player_response.dig?(
|
|
||||||
"playabilityStatus", "liveStreamability",
|
|
||||||
"liveStreamabilityRenderer", "offlineSlate",
|
|
||||||
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
|
|
||||||
)
|
|
||||||
.try &.as_s.to_i64
|
|
||||||
.try { |t| Time.unix(t) }
|
|
||||||
|
|
||||||
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
|
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
|
||||||
.try &.as_bool
|
.try &.as_bool || false
|
||||||
live_now ||= video_details.dig?("isLive").try &.as_bool || false
|
|
||||||
|
|
||||||
post_live_dvr = video_details.dig?("isPostLiveDvr")
|
post_live_dvr = video_details.dig?("isPostLiveDvr")
|
||||||
.try &.as_bool || false
|
.try &.as_bool || false
|
||||||
|
@ -456,35 +443,3 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
|
||||||
|
|
||||||
return params
|
return params
|
||||||
end
|
end
|
||||||
|
|
||||||
private def convert_url(fmt)
|
|
||||||
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
|
|
||||||
sp = cfr["sp"]
|
|
||||||
url = URI.parse(cfr["url"])
|
|
||||||
params = url.query_params
|
|
||||||
|
|
||||||
LOGGER.debug("convert_url: Decoding '#{cfr}'")
|
|
||||||
|
|
||||||
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
|
|
||||||
params[sp] = unsig if unsig
|
|
||||||
else
|
|
||||||
url = URI.parse(fmt["url"].as_s)
|
|
||||||
params = url.query_params
|
|
||||||
end
|
|
||||||
|
|
||||||
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
|
|
||||||
params["n"] = n if n
|
|
||||||
|
|
||||||
if token = CONFIG.po_token
|
|
||||||
params["pot"] = token
|
|
||||||
end
|
|
||||||
|
|
||||||
url.query_params = params
|
|
||||||
LOGGER.trace("convert_url: new url is '#{url}'")
|
|
||||||
|
|
||||||
return url.to_s
|
|
||||||
rescue ex
|
|
||||||
LOGGER.debug("convert_url: Error when parsing video URL")
|
|
||||||
LOGGER.trace(ex.inspect_with_backtrace)
|
|
||||||
return ""
|
|
||||||
end
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ struct VideoPreferences
|
||||||
include JSON::Serializable
|
include JSON::Serializable
|
||||||
|
|
||||||
property annotations : Bool
|
property annotations : Bool
|
||||||
property preload : Bool
|
|
||||||
property autoplay : Bool
|
property autoplay : Bool
|
||||||
property comments : Array(String)
|
property comments : Array(String)
|
||||||
property continue : Bool
|
property continue : Bool
|
||||||
|
@ -29,7 +28,6 @@ end
|
||||||
|
|
||||||
def process_video_params(query, preferences)
|
def process_video_params(query, preferences)
|
||||||
annotations = query["iv_load_policy"]?.try &.to_i?
|
annotations = query["iv_load_policy"]?.try &.to_i?
|
||||||
preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
|
||||||
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
||||||
comments = query["comments"]?.try &.split(",").map(&.downcase)
|
comments = query["comments"]?.try &.split(",").map(&.downcase)
|
||||||
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
||||||
|
@ -52,7 +50,6 @@ def process_video_params(query, preferences)
|
||||||
if preferences
|
if preferences
|
||||||
# region ||= preferences.region
|
# region ||= preferences.region
|
||||||
annotations ||= preferences.annotations.to_unsafe
|
annotations ||= preferences.annotations.to_unsafe
|
||||||
preload ||= preferences.preload.to_unsafe
|
|
||||||
autoplay ||= preferences.autoplay.to_unsafe
|
autoplay ||= preferences.autoplay.to_unsafe
|
||||||
comments ||= preferences.comments
|
comments ||= preferences.comments
|
||||||
continue ||= preferences.continue.to_unsafe
|
continue ||= preferences.continue.to_unsafe
|
||||||
|
@ -73,7 +70,6 @@ def process_video_params(query, preferences)
|
||||||
end
|
end
|
||||||
|
|
||||||
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
|
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
|
||||||
preload ||= CONFIG.default_user_preferences.preload.to_unsafe
|
|
||||||
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
|
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
|
||||||
comments ||= CONFIG.default_user_preferences.comments
|
comments ||= CONFIG.default_user_preferences.comments
|
||||||
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
|
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
|
||||||
|
@ -93,7 +89,6 @@ def process_video_params(query, preferences)
|
||||||
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
|
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
|
||||||
|
|
||||||
annotations = annotations == 1
|
annotations = annotations == 1
|
||||||
preload = preload == 1
|
|
||||||
autoplay = autoplay == 1
|
autoplay = autoplay == 1
|
||||||
continue = continue == 1
|
continue = continue == 1
|
||||||
continue_autoplay = continue_autoplay == 1
|
continue_autoplay = continue_autoplay == 1
|
||||||
|
@ -133,7 +128,6 @@ def process_video_params(query, preferences)
|
||||||
|
|
||||||
params = VideoPreferences.new({
|
params = VideoPreferences.new({
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
preload: preload,
|
|
||||||
autoplay: autoplay,
|
autoplay: autoplay,
|
||||||
comments: comments,
|
comments: comments,
|
||||||
continue: continue,
|
continue: continue,
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
ucid = channel.ucid
|
ucid = channel.ucid
|
||||||
author = HTML.escape(channel.author)
|
author = HTML.escape(channel.author)
|
||||||
channel_profile_pic = URI.parse(channel.author_thumbnail).request_target
|
channel_profile_pic = URI.parse(channel.author_thumbnail).request_target
|
||||||
host = env.request.headers["Host"]
|
|
||||||
|
|
||||||
relative_url =
|
relative_url =
|
||||||
case selected_tab
|
case selected_tab
|
||||||
|
@ -29,15 +28,15 @@
|
||||||
<%- if selected_tab.videos? -%>
|
<%- if selected_tab.videos? -%>
|
||||||
<meta name="description" content="<%= channel.description %>">
|
<meta name="description" content="<%= channel.description %>">
|
||||||
<meta property="og:site_name" content="Invidious">
|
<meta property="og:site_name" content="Invidious">
|
||||||
<meta property="og:url" content="<%= host %>/channel/<%= ucid %>">
|
<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
|
||||||
<meta property="og:title" content="<%= author %>">
|
<meta property="og:title" content="<%= author %>">
|
||||||
<meta property="og:image" content="<%= host %>/ggpht<%= channel_profile_pic %>">
|
<meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
|
||||||
<meta property="og:description" content="<%= channel.description %>">
|
<meta property="og:description" content="<%= channel.description %>">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:url" content="<%= host %>/channel/<%= ucid %>">
|
<meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
|
||||||
<meta name="twitter:title" content="<%= author %>">
|
<meta name="twitter:title" content="<%= author %>">
|
||||||
<meta name="twitter:description" content="<%= channel.description %>">
|
<meta name="twitter:description" content="<%= channel.description %>">
|
||||||
<meta name="twitter:image" content="<%= host %>/ggpht<%= channel_profile_pic %>">
|
<meta name="twitter:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
|
||||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
|
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
|
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
|
||||||
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
|
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
|
||||||
preload="<% if params.preload %>auto<% else %>none<% end %>"
|
|
||||||
<% if params.autoplay %>autoplay<% end %>
|
<% if params.autoplay %>autoplay<% end %>
|
||||||
<% if params.video_loop %>loop<% end %>
|
<% if params.video_loop %>loop<% end %>
|
||||||
<% if params.controls %>controls<% end %>>
|
<% if params.controls %>controls<% end %>>
|
||||||
|
@ -22,8 +21,6 @@
|
||||||
audio_streams.each_with_index do |fmt, i|
|
audio_streams.each_with_index do |fmt, i|
|
||||||
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
||||||
src_url += "&local=true" if params.local
|
src_url += "&local=true" if params.local
|
||||||
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
|
|
||||||
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
|
|
||||||
|
|
||||||
bitrate = fmt["bitrate"]
|
bitrate = fmt["bitrate"]
|
||||||
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
||||||
|
@ -36,12 +33,8 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<% if params.quality == "dash"
|
<% if params.quality == "dash" %>
|
||||||
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1"
|
<source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
|
||||||
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
|
|
||||||
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
|
|
||||||
%>
|
|
||||||
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
|
@ -50,8 +43,6 @@
|
||||||
fmt_stream.each_with_index do |fmt, i|
|
fmt_stream.each_with_index do |fmt, i|
|
||||||
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
|
||||||
src_url += "&local=true" if params.local
|
src_url += "&local=true" if params.local
|
||||||
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
|
|
||||||
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
|
|
||||||
|
|
||||||
quality = fmt["quality"]
|
quality = fmt["quality"]
|
||||||
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
mimetype = HTML.escape(fmt["mimeType"].as_s)
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
<%
|
<%
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
dark_mode = env.get("preferences").as(Preferences).dark_mode
|
dark_mode = env.get("preferences").as(Preferences).dark_mode
|
||||||
current_backend = env.request.cookies[CONFIG.server_id_cookie_name]?.try &.value || env.request.headers["Host"]
|
|
||||||
current_external_videoplayback_proxy = Invidious::HttpServer::Utils.get_external_proxy()
|
|
||||||
%>
|
%>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="<%= locale %>">
|
<html lang="<%= locale %>">
|
||||||
|
@ -106,31 +104,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if !CONFIG.backends.empty? %>
|
<% if CONFIG.backends %>
|
||||||
<% if !CONFIG.backend_domains.includes?(env.request.headers["Host"]) %>
|
|
||||||
<div class="h-box">
|
<div class="h-box">
|
||||||
<b>Switch Backend:</b>
|
<b>Switch Backend:</b>
|
||||||
<% CONFIG.backends.each do | backend | %>
|
<% CONFIG.backends.each do | backend | %>
|
||||||
<% backend = backend.split(CONFIG.backends_delimiter) %>
|
<a href="/switchbackend?backend_id=<%= backend %>">
|
||||||
<% if current_backend == backend[0] %>
|
Backend<%= HTML.escape(backend) %>
|
||||||
<a href="/switchbackend?backend_id=<%= backend[0] %>" style="text-decoration-line: underline; display: inline-block;">
|
</a> |
|
||||||
Backend<%= HTML.escape(backend[0]) %>
|
|
||||||
<% if backend.size == 2 %>
|
|
||||||
<%= HTML.escape(backend[1]) %>
|
|
||||||
<% end %>
|
|
||||||
</a> <span> | </span>
|
|
||||||
<% else %>
|
|
||||||
<a href="/switchbackend?backend_id=<%= backend[0] %>" style="display: inline-block;">
|
|
||||||
Backend<%= HTML.escape(backend[0]) %>
|
|
||||||
<% if backend.size == 2 %>
|
|
||||||
<%= HTML.escape(backend[1]) %>
|
|
||||||
<% end %>
|
|
||||||
</a> <span> | </span>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if CONFIG.banner %>
|
<% if CONFIG.banner %>
|
||||||
<div class="h-box">
|
<div class="h-box">
|
||||||
|
@ -313,10 +296,6 @@
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="footer-footer">
|
<div class="footer-footer">
|
||||||
<div class="box">You are currently using Backend: <%= current_backend %></div>
|
|
||||||
<% if !current_external_videoplayback_proxy.empty? %>
|
|
||||||
<div class="box">External Videoplayback Proxy: <%= current_external_videoplayback_proxy %></div>
|
|
||||||
<% end %>
|
|
||||||
<span class="left">
|
<span class="left">
|
||||||
<% if CONFIG.modified_source_code_url %>
|
<% if CONFIG.modified_source_code_url %>
|
||||||
<%= translate(locale, "footer_current_version_modified") %>
|
<%= translate(locale, "footer_current_version_modified") %>
|
||||||
|
|
|
@ -12,11 +12,6 @@
|
||||||
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
|
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
|
||||||
<label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
|
|
||||||
<input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
|
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
|
||||||
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
|
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<% ucid = video.ucid %>
|
<% ucid = video.ucid %>
|
||||||
<% title = HTML.escape(video.title) %>
|
<% title = HTML.escape(video.title) %>
|
||||||
<% author = HTML.escape(video.author) %>
|
<% author = HTML.escape(video.author) %>
|
||||||
<% host = env.request.headers["Host"] %>
|
|
||||||
|
|
||||||
|
|
||||||
<% content_for "header" do %>
|
<% content_for "header" do %>
|
||||||
|
@ -9,24 +8,22 @@
|
||||||
<meta name="description" content="<%= HTML.escape(video.short_description) %>">
|
<meta name="description" content="<%= HTML.escape(video.short_description) %>">
|
||||||
<meta name="keywords" content="<%= video.keywords.join(",") %>">
|
<meta name="keywords" content="<%= video.keywords.join(",") %>">
|
||||||
<meta property="og:site_name" content="<%= author %> | Invidious">
|
<meta property="og:site_name" content="<%= author %> | Invidious">
|
||||||
<meta property="og:url" content="<%= host %>/watch?v=<%= video.id %>">
|
<meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
|
||||||
<meta property="og:title" content="<%= title %>">
|
<meta property="og:title" content="<%= title %>">
|
||||||
<meta property="og:image" content="<%= host %>/vi/<%= video.id %>/maxres.jpg">
|
<meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
|
||||||
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
|
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
|
||||||
<meta property="og:type" content="video.other">
|
<meta property="og:type" content="video.other">
|
||||||
<!-- This shouldn't be empty, ever. -->
|
<meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
||||||
<meta property="og:video" content="<%= video_url %>">
|
<meta property="og:video:secure_url" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
||||||
<meta property="og:video:url" content="<%= video_url %>">
|
<meta property="og:video:type" content="text/html">
|
||||||
<meta property="og:video:secure_url" content="<%= video_url %>">
|
<meta property="og:video:width" content="1280">
|
||||||
<meta property="og:video:type" content="video/mp4">
|
<meta property="og:video:height" content="720">
|
||||||
<meta property="og:video:width" content="640">
|
|
||||||
<meta property="og:video:height" content="360">
|
|
||||||
<meta name="twitter:card" content="player">
|
<meta name="twitter:card" content="player">
|
||||||
<meta name="twitter:url" content="<%= host %>/watch?v=<%= video.id %>">
|
<meta name="twitter:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
|
||||||
<meta name="twitter:title" content="<%= title %>">
|
<meta name="twitter:title" content="<%= title %>">
|
||||||
<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>">
|
<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>">
|
||||||
<meta name="twitter:image" content="<%= host %>/vi/<%= video.id %>/maxres.jpg">
|
<meta name="twitter:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
|
||||||
<meta name="twitter:player" content="<%= host %>/embed/<%= video.id %>">
|
<meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
||||||
<meta name="twitter:player:width" content="1280">
|
<meta name="twitter:player:width" content="1280">
|
||||||
<meta name="twitter:player:height" content="720">
|
<meta name="twitter:player:height" content="720">
|
||||||
<link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>">
|
<link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>">
|
||||||
|
|
|
@ -1,51 +1,3 @@
|
||||||
# Mapping of subdomain => YoutubeConnectionPool
|
|
||||||
# This is needed as we may need to access arbitrary subdomains of ytimg
|
|
||||||
private YTIMG_POOLS = {} of String => YoutubeConnectionPool
|
|
||||||
|
|
||||||
struct YoutubeConnectionPool
|
|
||||||
property! url : URI
|
|
||||||
property! capacity : Int32
|
|
||||||
property! timeout : Float64
|
|
||||||
property pool : DB::Pool(HTTP::Client)
|
|
||||||
|
|
||||||
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
|
|
||||||
@url = url
|
|
||||||
@pool = build_pool()
|
|
||||||
end
|
|
||||||
|
|
||||||
def client(&)
|
|
||||||
conn = pool.checkout
|
|
||||||
# Proxy needs to be reinstated every time we get a client from the pool
|
|
||||||
conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
|
|
||||||
|
|
||||||
begin
|
|
||||||
response = yield conn
|
|
||||||
rescue ex
|
|
||||||
conn.close
|
|
||||||
conn = make_client(url, force_resolve: true)
|
|
||||||
|
|
||||||
response = yield conn
|
|
||||||
ensure
|
|
||||||
pool.release(conn)
|
|
||||||
end
|
|
||||||
|
|
||||||
response
|
|
||||||
end
|
|
||||||
|
|
||||||
private def build_pool
|
|
||||||
options = DB::Pool::Options.new(
|
|
||||||
initial_pool_size: 0,
|
|
||||||
max_pool_size: capacity,
|
|
||||||
max_idle_pool_size: capacity,
|
|
||||||
checkout_timeout: timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
DB::Pool(HTTP::Client).new(options) do
|
|
||||||
next make_client(url, force_resolve: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_yt_headers(request)
|
def add_yt_headers(request)
|
||||||
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
|
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
|
||||||
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||||
|
@ -61,56 +13,67 @@ def add_yt_headers(request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
|
struct YoutubeConnectionPool
|
||||||
|
property! url : URI
|
||||||
|
property! capacity : Int32
|
||||||
|
property! timeout : Float64
|
||||||
|
property pool : DB::Pool(HTTP::Client)
|
||||||
|
|
||||||
|
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
|
||||||
|
@url = url
|
||||||
|
@pool = build_pool()
|
||||||
|
end
|
||||||
|
|
||||||
|
def client(&)
|
||||||
|
conn = pool.checkout
|
||||||
|
begin
|
||||||
|
response = yield conn
|
||||||
|
rescue ex
|
||||||
|
conn.close
|
||||||
|
conn = HTTP::Client.new(url)
|
||||||
|
|
||||||
|
conn.family = CONFIG.force_resolve
|
||||||
|
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||||
|
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||||
|
response = yield conn
|
||||||
|
ensure
|
||||||
|
pool.release(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
private def build_pool
|
||||||
|
DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
|
||||||
|
conn = HTTP::Client.new(url)
|
||||||
|
conn.family = CONFIG.force_resolve
|
||||||
|
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||||
|
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def make_client(url : URI, region = nil, force_resolve : Bool = false)
|
||||||
client = HTTP::Client.new(url)
|
client = HTTP::Client.new(url)
|
||||||
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
|
|
||||||
|
|
||||||
# Force the usage of a specific configured IP Family
|
# Force the usage of a specific configured IP Family
|
||||||
if force_resolve
|
if force_resolve
|
||||||
client.family = CONFIG.force_resolve
|
client.family = CONFIG.force_resolve
|
||||||
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
|
|
||||||
end
|
end
|
||||||
|
|
||||||
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
|
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||||
client.read_timeout = 10.seconds
|
client.read_timeout = 10.seconds
|
||||||
client.connect_timeout = 10.seconds
|
client.connect_timeout = 10.seconds
|
||||||
|
|
||||||
return client
|
return client
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
|
def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
|
||||||
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
|
client = make_client(url, region, force_resolve)
|
||||||
begin
|
begin
|
||||||
yield client
|
yield client
|
||||||
ensure
|
ensure
|
||||||
client.close
|
client.close
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_configured_http_proxy_client
|
|
||||||
# This method is only called when configuration for an HTTP proxy are set
|
|
||||||
config_proxy = CONFIG.http_proxy.not_nil!
|
|
||||||
|
|
||||||
return HTTP::Proxy::Client.new(
|
|
||||||
config_proxy.host,
|
|
||||||
config_proxy.port,
|
|
||||||
|
|
||||||
username: config_proxy.user,
|
|
||||||
password: config_proxy.password,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fetches a HTTP pool for the specified subdomain of ytimg.com
|
|
||||||
#
|
|
||||||
# Creates a new one when the specified pool for the subdomain does not exist
|
|
||||||
def get_ytimg_pool(subdomain)
|
|
||||||
if pool = YTIMG_POOLS[subdomain]?
|
|
||||||
return pool
|
|
||||||
else
|
|
||||||
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
|
|
||||||
pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size)
|
|
||||||
YTIMG_POOLS[subdomain] = pool
|
|
||||||
|
|
||||||
return pool
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
@ -21,7 +21,6 @@ private ITEM_PARSERS = {
|
||||||
Parsers::ItemSectionRendererParser,
|
Parsers::ItemSectionRendererParser,
|
||||||
Parsers::ContinuationItemRendererParser,
|
Parsers::ContinuationItemRendererParser,
|
||||||
Parsers::HashtagRendererParser,
|
Parsers::HashtagRendererParser,
|
||||||
Parsers::LockupViewModelParser,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private alias InitialData = Hash(String, JSON::Any)
|
private alias InitialData = Hash(String, JSON::Any)
|
||||||
|
@ -109,30 +108,21 @@ private module Parsers
|
||||||
length_seconds = 0
|
length_seconds = 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
live_now = false
|
||||||
|
premium = false
|
||||||
|
|
||||||
premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
|
premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
|
||||||
badges = VideoBadges::None
|
|
||||||
item_contents["badges"]?.try &.as_a.each do |badge|
|
item_contents["badges"]?.try &.as_a.each do |badge|
|
||||||
b = badge["metadataBadgeRenderer"]
|
b = badge["metadataBadgeRenderer"]
|
||||||
case b["label"].as_s
|
case b["label"].as_s
|
||||||
when "LIVE"
|
when "LIVE NOW"
|
||||||
badges |= VideoBadges::LiveNow
|
live_now = true
|
||||||
when "New"
|
when "New", "4K", "CC"
|
||||||
badges |= VideoBadges::New
|
# TODO
|
||||||
when "4K"
|
|
||||||
badges |= VideoBadges::FourK
|
|
||||||
when "8K"
|
|
||||||
badges |= VideoBadges::EightK
|
|
||||||
when "VR180"
|
|
||||||
badges |= VideoBadges::VR180
|
|
||||||
when "360°"
|
|
||||||
badges |= VideoBadges::VR360
|
|
||||||
when "3D"
|
|
||||||
badges |= VideoBadges::ThreeD
|
|
||||||
when "CC"
|
|
||||||
badges |= VideoBadges::ClosedCaptions
|
|
||||||
when "Premium"
|
when "Premium"
|
||||||
# TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
|
# TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
|
||||||
badges |= VideoBadges::Premium
|
premium = true
|
||||||
else nil # Ignore
|
else nil # Ignore
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -146,9 +136,10 @@ private module Parsers
|
||||||
views: view_count,
|
views: view_count,
|
||||||
description_html: description_html,
|
description_html: description_html,
|
||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
|
live_now: live_now,
|
||||||
|
premium: premium,
|
||||||
premiere_timestamp: premiere_timestamp,
|
premiere_timestamp: premiere_timestamp,
|
||||||
author_verified: author_verified,
|
author_verified: author_verified,
|
||||||
badges: badges,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -468,9 +459,9 @@ private module Parsers
|
||||||
# Parses an InnerTube richItemRenderer into a SearchVideo.
|
# Parses an InnerTube richItemRenderer into a SearchVideo.
|
||||||
# Returns nil when the given object isn't a RichItemRenderer
|
# Returns nil when the given object isn't a RichItemRenderer
|
||||||
#
|
#
|
||||||
# A richItemRenderer seems to be a simple wrapper for a various other types,
|
# A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
|
||||||
# used on the hashtags result page and the channel podcast tab. It is located
|
# by the result page for hashtags and for the podcast tab on channels.
|
||||||
# itself inside a richGridRenderer container.
|
# It is located inside a continuationItems container for hashtags.
|
||||||
#
|
#
|
||||||
module RichItemRendererParser
|
module RichItemRendererParser
|
||||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||||
|
@ -483,8 +474,6 @@ private module Parsers
|
||||||
child = VideoRendererParser.process(item_contents, author_fallback)
|
child = VideoRendererParser.process(item_contents, author_fallback)
|
||||||
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
|
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
|
||||||
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
|
child ||= PlaylistRendererParser.process(item_contents, author_fallback)
|
||||||
child ||= LockupViewModelParser.process(item_contents, author_fallback)
|
|
||||||
child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback)
|
|
||||||
return child
|
return child
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -499,9 +488,6 @@ private module Parsers
|
||||||
# reelItemRenderer items are used in the new (2022) channel layout,
|
# reelItemRenderer items are used in the new (2022) channel layout,
|
||||||
# in the "shorts" tab.
|
# in the "shorts" tab.
|
||||||
#
|
#
|
||||||
# NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel
|
|
||||||
# TODO: Confirm that hypothesis
|
|
||||||
#
|
|
||||||
module ReelItemRendererParser
|
module ReelItemRendererParser
|
||||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||||
if item_contents = item["reelItemRenderer"]?
|
if item_contents = item["reelItemRenderer"]?
|
||||||
|
@ -577,138 +563,10 @@ private module Parsers
|
||||||
views: view_count,
|
views: view_count,
|
||||||
description_html: "",
|
description_html: "",
|
||||||
length_seconds: duration,
|
length_seconds: duration,
|
||||||
|
live_now: false,
|
||||||
|
premium: false,
|
||||||
premiere_timestamp: Time.unix(0),
|
premiere_timestamp: Time.unix(0),
|
||||||
author_verified: false,
|
author_verified: false,
|
||||||
badges: VideoBadges::None,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.parser_name
|
|
||||||
return {{@type.name}}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
|
|
||||||
# Returns nil when the given object is not a lockupViewModel.
|
|
||||||
#
|
|
||||||
# This structure is present since November 2024 on the "podcasts" and
|
|
||||||
# "playlists" tabs of the channel page. It is usually encapsulated in either
|
|
||||||
# a richItemRenderer or a richGridRenderer.
|
|
||||||
#
|
|
||||||
module LockupViewModelParser
|
|
||||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
|
||||||
if item_contents = item["lockupViewModel"]?
|
|
||||||
return self.parse(item_contents, author_fallback)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.parse(item_contents, author_fallback)
|
|
||||||
playlist_id = item_contents["contentId"].as_s
|
|
||||||
|
|
||||||
thumbnail_view_model = item_contents.dig(
|
|
||||||
"contentImage", "collectionThumbnailViewModel",
|
|
||||||
"primaryThumbnail", "thumbnailViewModel"
|
|
||||||
)
|
|
||||||
|
|
||||||
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
|
||||||
|
|
||||||
# This complicated sequences tries to extract the following data structure:
|
|
||||||
# "overlays": [{
|
|
||||||
# "thumbnailOverlayBadgeViewModel": {
|
|
||||||
# "thumbnailBadges": [{
|
|
||||||
# "thumbnailBadgeViewModel": {
|
|
||||||
# "text": "430 episodes",
|
|
||||||
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
|
|
||||||
# }
|
|
||||||
# }]
|
|
||||||
# }
|
|
||||||
# }]
|
|
||||||
#
|
|
||||||
# NOTE: this simplistic `.to_i` conversion might not work on larger
|
|
||||||
# playlists and hasn't been tested.
|
|
||||||
video_count = thumbnail_view_model.dig("overlays").as_a
|
|
||||||
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
|
|
||||||
.flatten
|
|
||||||
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
|
|
||||||
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
|
|
||||||
})
|
|
||||||
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
|
|
||||||
|
|
||||||
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
|
||||||
title = metadata.dig("title", "content").as_s
|
|
||||||
|
|
||||||
# TODO: Retrieve "updated" info from metadata parts
|
|
||||||
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
|
|
||||||
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
|
||||||
# One of these parts should contain a string like: "Updated 2 days ago"
|
|
||||||
|
|
||||||
# TODO: Maybe add a button to access the first video of the playlist?
|
|
||||||
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
|
||||||
# Available fields: "videoId", "playlistId", "params"
|
|
||||||
|
|
||||||
return SearchPlaylist.new({
|
|
||||||
title: title,
|
|
||||||
id: playlist_id,
|
|
||||||
author: author_fallback.name,
|
|
||||||
ucid: author_fallback.id,
|
|
||||||
video_count: video_count || -1,
|
|
||||||
videos: [] of SearchPlaylistVideo,
|
|
||||||
thumbnail: thumbnail,
|
|
||||||
author_verified: false,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.parser_name
|
|
||||||
return {{@type.name}}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Parses an InnerTube shortsLockupViewModel into a SearchVideo.
|
|
||||||
# Returns nil when the given object is not a shortsLockupViewModel.
|
|
||||||
#
|
|
||||||
# This structure is present since around October 2024 on the "shorts" tab of
|
|
||||||
# the channel page and likely replaces the reelItemRenderer structure. It is
|
|
||||||
# usually (always?) encapsulated in a richItemRenderer.
|
|
||||||
#
|
|
||||||
module ShortsLockupViewModelParser
|
|
||||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
|
||||||
if item_contents = item["shortsLockupViewModel"]?
|
|
||||||
return self.parse(item_contents, author_fallback)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.parse(item_contents, author_fallback)
|
|
||||||
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
|
|
||||||
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
|
|
||||||
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
|
|
||||||
|
|
||||||
video_id = item_contents.dig(
|
|
||||||
"onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"
|
|
||||||
).as_s
|
|
||||||
|
|
||||||
title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s
|
|
||||||
|
|
||||||
view_count = short_text_to_number(
|
|
||||||
item_contents.dig("overlayMetadata", "secondaryText", "content").as_s
|
|
||||||
)
|
|
||||||
|
|
||||||
# Approximate to one minute, as "shorts" generally don't exceed that.
|
|
||||||
# NOTE: The actual duration is not provided by Youtube anymore.
|
|
||||||
# TODO: Maybe use -1 as an error value and handle that on the frontend?
|
|
||||||
duration = 60_i32
|
|
||||||
|
|
||||||
SearchVideo.new({
|
|
||||||
title: title,
|
|
||||||
id: video_id,
|
|
||||||
author: author_fallback.name,
|
|
||||||
ucid: author_fallback.id,
|
|
||||||
published: Time.unix(0),
|
|
||||||
views: view_count,
|
|
||||||
description_html: "",
|
|
||||||
length_seconds: duration,
|
|
||||||
premiere_timestamp: Time.unix(0),
|
|
||||||
author_verified: false,
|
|
||||||
badges: VideoBadges::None,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@ module UrlSanitizer
|
||||||
new_uri.path = "/watch"
|
new_uri.path = "/watch"
|
||||||
|
|
||||||
new_params = copy_params(unsafe_uri.query_params, :watch)
|
new_params = copy_params(unsafe_uri.query_params, :watch)
|
||||||
new_params["v"] = breadcrumbs[0]
|
new_params["id"] = breadcrumbs[0]
|
||||||
|
|
||||||
new_uri.query_params = new_params
|
new_uri.query_params = new_params
|
||||||
end
|
end
|
||||||
|
|
|
@ -455,7 +455,7 @@ module YoutubeAPI
|
||||||
video_id : String,
|
video_id : String,
|
||||||
*, # Force the following parameters to be passed by name
|
*, # Force the following parameters to be passed by name
|
||||||
params : String,
|
params : String,
|
||||||
client_config : ClientConfig | Nil = nil,
|
client_config : ClientConfig | Nil = nil
|
||||||
)
|
)
|
||||||
# Playback context, separate because it can be different between clients
|
# Playback context, separate because it can be different between clients
|
||||||
playback_ctx = {
|
playback_ctx = {
|
||||||
|
@ -491,11 +491,7 @@ module YoutubeAPI
|
||||||
data["params"] = params
|
data["params"] = params
|
||||||
end
|
end
|
||||||
|
|
||||||
if CONFIG.invidious_companion.present?
|
return self._post_json("/youtubei/v1/player", data, client_config)
|
||||||
return self._post_invidious_companion("/youtubei/v1/player", data)
|
|
||||||
else
|
|
||||||
return self._post_json("/youtubei/v1/player", data, client_config)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
####################################################################
|
####################################################################
|
||||||
|
@ -600,7 +596,7 @@ module YoutubeAPI
|
||||||
def _post_json(
|
def _post_json(
|
||||||
endpoint : String,
|
endpoint : String,
|
||||||
data : Hash,
|
data : Hash,
|
||||||
client_config : ClientConfig | Nil,
|
client_config : ClientConfig | Nil
|
||||||
) : Hash(String, JSON::Any)
|
) : Hash(String, JSON::Any)
|
||||||
# Use the default client config if nil is passed
|
# Use the default client config if nil is passed
|
||||||
client_config ||= DEFAULT_CLIENT_CONFIG
|
client_config ||= DEFAULT_CLIENT_CONFIG
|
||||||
|
@ -632,11 +628,6 @@ module YoutubeAPI
|
||||||
# Send the POST request
|
# Send the POST request
|
||||||
body = YT_POOL.client() do |client|
|
body = YT_POOL.client() do |client|
|
||||||
client.post(url, headers: headers, body: data.to_json) do |response|
|
client.post(url, headers: headers, body: data.to_json) do |response|
|
||||||
if response.status_code != 200
|
|
||||||
raise InfoException.new("Error: non 200 status code. Youtube API returned \
|
|
||||||
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
|
|
||||||
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
|
|
||||||
end
|
|
||||||
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
|
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -661,51 +652,6 @@ module YoutubeAPI
|
||||||
return initial_data
|
return initial_data
|
||||||
end
|
end
|
||||||
|
|
||||||
####################################################################
|
|
||||||
# _post_invidious_companion(endpoint, data)
|
|
||||||
#
|
|
||||||
# Internal function that does the actual request to Invidious companion
|
|
||||||
# and handles errors.
|
|
||||||
#
|
|
||||||
# The requested data is an endpoint (URL without the domain part)
|
|
||||||
# and the data as a Hash object.
|
|
||||||
#
|
|
||||||
def _post_invidious_companion(
|
|
||||||
endpoint : String,
|
|
||||||
data : Hash
|
|
||||||
) : Hash(String, JSON::Any)
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"Content-Type" => "application/json; charset=UTF-8",
|
|
||||||
"Authorization" => "Bearer #{CONFIG.invidious_companion_key}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"")
|
|
||||||
LOGGER.trace("Invidious companion: POST data: #{data}")
|
|
||||||
|
|
||||||
# Send the POST request
|
|
||||||
|
|
||||||
begin
|
|
||||||
invidious_companion = CONFIG.invidious_companion.sample
|
|
||||||
response = make_client(invidious_companion.private_url, use_http_proxy: false,
|
|
||||||
&.post(endpoint, headers: headers, body: data.to_json))
|
|
||||||
body = response.body
|
|
||||||
if (response.status_code != 200)
|
|
||||||
raise Exception.new(
|
|
||||||
"Error while communicating with Invidious companion: \
|
|
||||||
status code: #{response.status_code} and body: #{body.dump}"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Convert result to Hash
|
|
||||||
initial_data = JSON.parse(body).as_h
|
|
||||||
|
|
||||||
return initial_data
|
|
||||||
end
|
|
||||||
|
|
||||||
####################################################################
|
####################################################################
|
||||||
# _decompress(body_io, headers)
|
# _decompress(body_io, headers)
|
||||||
#
|
#
|
||||||
|
|
Loading…
Reference in a new issue