forked from Fijxu/invidious
Compare commits
210 commits
Author | SHA1 | Date | |
---|---|---|---|
d75d860888 | |||
f68d7bcbc7 | |||
|
164d764d55 | ||
|
4a31da4000 | ||
|
831017f403 | ||
|
52daafe047 | ||
|
dca130ca6f | ||
|
086c6209ab | ||
|
0d398c9d1a | ||
|
dc38bcdf17 | ||
|
d5442d45bc | ||
|
d4f0560e80 | ||
|
eae3c42dab | ||
|
c0131d8646 | ||
|
21fd717701 | ||
|
8ee73aa0c1 | ||
|
6e3ec10d76 | ||
|
d95ae7e6a5 | ||
|
d36f372bd1 | ||
|
58c65e921f | ||
|
5d9ed95ffd | ||
|
033e42a981 | ||
|
bfa6da2474 | ||
7d02c1827a | |||
842473dd37 | |||
|
097b4f0433 | ||
|
e1378702af | ||
43aa7e7303 | |||
a08fafd852 | |||
b0141d87fa | |||
61106da689 | |||
|
b13f77b5af | ||
25c3153f4c | |||
4a8fffac26 | |||
ebd89acf79 | |||
3bb7924181 | |||
30c0b5059d | |||
391659780d | |||
f248024b65 | |||
df94f1c0b8 | |||
|
047ead8080 | ||
83256b2af1 | |||
31219ce196 | |||
a01c8c63d3 | |||
58c4d8c951 | |||
91bcec72c8 | |||
a63300e284 | |||
79859100a8 | |||
|
bba1769f4b | ||
|
6b0e4e6817 | ||
|
6abee5de99 | ||
47ef5dfe4c | |||
13e00e674b | |||
3615bbd893 | |||
9b9efc6841 | |||
|
9892604758 | ||
|
5d2dd40bc3 | ||
|
699d53ad41 | ||
|
3ac8978e96 | ||
|
e7a93fcc18 | ||
|
aa33d9b7ec | ||
|
2150264d84 | ||
|
d42561d74a | ||
|
7092bb8855 | ||
|
d7c35e6e3d | ||
|
bc86fb8a82 | ||
|
ec82c2f539 | ||
|
4b363e32fa | ||
|
7a15318fbc | ||
|
5fa87cc27c | ||
036ab6ef65 | |||
c27a703544 | |||
5a75ef7f94 | |||
91c9cd45a4 | |||
b953dc1ce7 | |||
|
d2123b4682 | ||
|
0f8f32bca8 | ||
|
f3e93ca83d | ||
|
82b1506ccc | ||
|
b9ad9bd723 | ||
|
8bf7e02978 | ||
|
1a49e798c8 | ||
|
9d54cf903e | ||
|
1333fed26c | ||
|
b173d4acf2 | ||
|
43d5efd9da | ||
|
1480e0089f | ||
|
a5fb78bba5 | ||
|
09f5485889 | ||
|
a760b69cb6 | ||
|
4f7a18a630 | ||
|
42da2547e3 | ||
|
09ccea1d31 | ||
|
2a19dbb1fe | ||
|
6dd662a5b8 | ||
|
301aeffa78 | ||
|
d27a5e7fae | ||
|
afc5b27d83 | ||
|
1a5047aad9 | ||
|
ce910b5269 | ||
|
78f18b257c | ||
|
3196182d4d | ||
|
82248fad02 | ||
|
cbc546f032 | ||
|
792d0d5f6d | ||
|
ac6e796c73 | ||
70dc1a9f11 | |||
fc910b43ba | |||
67998d1f36 | |||
e2276ace1b | |||
|
75c5881c55 | ||
|
6da18ddc41 | ||
|
cdf93b29e6 | ||
|
eed14d08a8 | ||
|
c243d08afb | ||
c61b2963ac | |||
|
2e3a7ad044 | ||
|
c427c184e2 | ||
|
59acf23c0c | ||
|
2eeb6a731d | ||
|
0fb67cc090 | ||
|
9957da28dc | ||
|
f326bcf8db | ||
26bee068eb | |||
486c5845cd | |||
6f10a7c67e | |||
|
b0c7dd9771 | ||
|
dbdf2ad23a | ||
|
dbd96c77e4 | ||
|
e453a2a682 | ||
|
7e4b3b182a | ||
|
711d52d47f | ||
67d7b78ac9 | |||
|
ee72809282 | ||
3afac4d842 | |||
|
d8b893e9ad | ||
448007e5ba | |||
3cc0dbca01 | |||
c3e8721051 | |||
cf5028d09a | |||
|
70e4eb7f5d | ||
|
0d03818700 | ||
|
e6f52eaf00 | ||
|
90544e07b6 | ||
eb2670fe49 | |||
976e1ccf5a | |||
fee2acc666 | |||
b5ab49e8e8 | |||
65f3bbcb10 | |||
|
952b3625a0 | ||
|
f51a3b8d2b | ||
|
fb3ecdad9a | ||
5357c83e00 | |||
8dc0a67be3 | |||
d61043edea | |||
3111158a7c | |||
84e4746265 | |||
d2edd4b63f | |||
17b525f2a6 | |||
|
b2a83991d1 | ||
d77afdcf00 | |||
f8ec312328 | |||
|
9d91ac3b88 | ||
|
75b68618ab | ||
|
003c6f81dc | ||
|
4bc77b81bf | ||
|
06e1a508e8 | ||
|
52bc9aa328 | ||
|
480e073fa9 | ||
|
6e39b9b303 | ||
|
46c58bd84c | ||
|
7521902e88 | ||
|
bd48af825c | ||
|
ee89db49ba | ||
|
3af6681869 | ||
|
1124dd645d | ||
|
b526f48120 | ||
|
e8cd631b2d | ||
|
69ff6def5f | ||
|
26dc9dc99c | ||
|
2d6b46c926 | ||
|
cab02d4959 | ||
|
5f590dda80 | ||
|
c24ed85110 | ||
|
288e1dccda | ||
|
6b7e730100 | ||
|
ccb2a6c58e | ||
|
3b471ae964 | ||
|
eb8fcc9e88 | ||
|
7b7197cde8 | ||
|
6861148290 | ||
|
03f9962a47 | ||
|
d098e5ae9b | ||
|
4c486634e2 | ||
|
3bced4e12b | ||
|
0d22af6564 | ||
|
2a6a32e667 | ||
|
50da6cf3e7 | ||
|
7388e4ca72 | ||
|
be216fff94 | ||
|
a0d24190b8 | ||
|
76369eb599 | ||
|
6236cea33e | ||
|
fa59f41f7b | ||
|
20ca1ebcc0 | ||
|
b0b4f09b3a | ||
|
48af0af9d5 | ||
|
f9460e31bc | ||
|
b7a252b096 | ||
|
6b929da0e1 |
86 changed files with 1439 additions and 698 deletions
|
@ -8,6 +8,8 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
|
- "experimental"
|
||||||
|
- "experimental2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
@ -35,13 +37,15 @@ 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@v5
|
- uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||||
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"
|
||||||
|
|
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -10,8 +10,10 @@ assignees: ''
|
||||||
<!--
|
<!--
|
||||||
BEFORE TRYING TO REPORT A BUG:
|
BEFORE TRYING TO REPORT A BUG:
|
||||||
|
|
||||||
* Read the FAQ!
|
* Read the FAQ: https://docs.invidious.io/faq/!
|
||||||
* Use the search function to check if there is already an issue open for your problem!
|
* Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues!
|
||||||
|
|
||||||
|
MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
|
||||||
|
|
||||||
If you want to suggest a new feature please use "Feature request" instead
|
If you want to suggest a new feature please use "Feature request" instead
|
||||||
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
|
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
|
||||||
|
|
13
.github/workflows/build-nightly-container.yml
vendored
13
.github/workflows/build-nightly-container.yml
vendored
|
@ -23,19 +23,6 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Crystal
|
|
||||||
uses: crystal-lang/install-crystal@v1.8.2
|
|
||||||
with:
|
|
||||||
crystal: 1.12.2
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: |
|
|
||||||
if ! crystal tool format --check; then
|
|
||||||
crystal tool format
|
|
||||||
git diff
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
with:
|
||||||
|
|
13
.github/workflows/build-stable-container.yml
vendored
13
.github/workflows/build-stable-container.yml
vendored
|
@ -14,19 +14,6 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Crystal
|
|
||||||
uses: crystal-lang/install-crystal@v1.8.2
|
|
||||||
with:
|
|
||||||
crystal: 1.12.2
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: |
|
|
||||||
if ! crystal tool format --check; then
|
|
||||||
crystal tool format
|
|
||||||
git diff
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
with:
|
||||||
|
|
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
|
@ -38,10 +38,10 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
stable: [true]
|
stable: [true]
|
||||||
crystal:
|
crystal:
|
||||||
- 1.9.2
|
|
||||||
- 1.10.1
|
|
||||||
- 1.11.2
|
|
||||||
- 1.12.1
|
- 1.12.1
|
||||||
|
- 1.13.2
|
||||||
|
- 1.14.0
|
||||||
|
- 1.15.0
|
||||||
include:
|
include:
|
||||||
- crystal: nightly
|
- crystal: nightly
|
||||||
stable: false
|
stable: false
|
||||||
|
@ -51,6 +51,11 @@ 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:
|
||||||
|
@ -59,7 +64,9 @@ jobs:
|
||||||
- name: Cache Shards
|
- name: Cache Shards
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ./lib
|
path: |
|
||||||
|
./lib
|
||||||
|
./bin
|
||||||
key: shards-${{ hashFiles('shard.lock') }}
|
key: shards-${{ hashFiles('shard.lock') }}
|
||||||
|
|
||||||
- name: Install Shards
|
- name: Install Shards
|
||||||
|
@ -71,14 +78,6 @@ 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,14 +123,19 @@ 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
|
||||||
|
|
||||||
ameba_lint:
|
lint:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Install Crystal
|
- name: Install Crystal
|
||||||
|
id: lint_step_install_crystal
|
||||||
uses: crystal-lang/install-crystal@v1.8.0
|
uses: crystal-lang/install-crystal@v1.8.0
|
||||||
with:
|
with:
|
||||||
crystal: latest
|
crystal: latest
|
||||||
|
@ -142,10 +146,21 @@ jobs:
|
||||||
path: |
|
path: |
|
||||||
./lib
|
./lib
|
||||||
./bin
|
./bin
|
||||||
key: shards-${{ hashFiles('shard.lock') }}
|
key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }}
|
||||||
|
|
||||||
- name: Install Shards
|
- name: Install Shards
|
||||||
run: shards install
|
run: |
|
||||||
|
if ! shards check; then
|
||||||
|
shards install
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check Crystal formatter compliance
|
||||||
|
run: |
|
||||||
|
if ! crystal tool format --check; then
|
||||||
|
crystal tool format
|
||||||
|
git diff
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run Ameba linter
|
- name: Run Ameba linter
|
||||||
run: bin/ameba
|
run: bin/ameba
|
||||||
|
|
13
.github/workflows/stale.yml
vendored
13
.github/workflows/stale.yml
vendored
|
@ -13,14 +13,11 @@ jobs:
|
||||||
- uses: actions/stale@v8
|
- uses: actions/stale@v8
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-stale: 365
|
days-before-stale: 730
|
||||||
days-before-pr-stale: 90
|
days-before-pr-stale: -1
|
||||||
days-before-close: 30
|
days-before-close: 60
|
||||||
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
|
||||||
# Never mark feature requests/enhancements as stale
|
# Exempt the following types of issues from being staled
|
||||||
exempt-issue-labels: "feature-request,enhancement,exempt-stale"
|
exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale"
|
||||||
|
|
110
CHANGELOG.md
110
CHANGELOG.md
|
@ -3,8 +3,98 @@
|
||||||
## vX.Y.0 (future)
|
## vX.Y.0 (future)
|
||||||
|
|
||||||
|
|
||||||
|
## v2.20241110.0
|
||||||
|
|
||||||
|
### Wrap-up
|
||||||
|
|
||||||
|
This release is most importantly here to fix to the annoying "Youtube API returned error 400"
|
||||||
|
error that prevented all channel pages from loading.
|
||||||
|
|
||||||
|
If you're updating from the previous release, it provides no improvements on the ability to play
|
||||||
|
videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
|
||||||
|
by a previous attempt at restoring video playback on large instances.
|
||||||
|
|
||||||
|
In the preferences, a new option allows for control of video preload. When enabled, this option
|
||||||
|
tells the browser to load the video as soon as the page is loaded (this used to be the default).
|
||||||
|
When disabled, the video starts loading only when the "play" button is pressed.
|
||||||
|
|
||||||
|
New interface languages available: Bulgarian, Welsh and Lombard
|
||||||
|
|
||||||
|
New dependency required: `tzdata`.
|
||||||
|
|
||||||
|
An HTTP proxy can be configured directly in Invidious, if needed. \
|
||||||
|
**NOTE:** In that case, it is recommended to comment out `force_resolve`.
|
||||||
|
|
||||||
|
|
||||||
|
### New features & important changes
|
||||||
|
|
||||||
|
#### For users
|
||||||
|
|
||||||
|
* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
|
||||||
|
* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
|
||||||
|
* Preferences: Addition of the new "preload" option
|
||||||
|
* New interface languages available: Bulgarian, Welsh and Lombard
|
||||||
|
* Added "Filipino (auto-generated)" to the list of caption languages available
|
||||||
|
* Lots of new translations from Weblate
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
|
||||||
|
* Allow the configuration of an HTTP proxy to talk to Youtube
|
||||||
|
* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
|
||||||
|
* The instance list is downloaded in the background to improve redirection speed
|
||||||
|
* New `colorize_logs` option makes each log level a different color
|
||||||
|
|
||||||
|
#### For developpers
|
||||||
|
|
||||||
|
* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
|
||||||
|
`newest`, `oldest` and `popular`
|
||||||
|
* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
|
||||||
|
* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
|
||||||
|
`is3d` and `hasCaptions`
|
||||||
|
|
||||||
|
### Bugs fixed
|
||||||
|
|
||||||
|
#### User-side
|
||||||
|
|
||||||
|
* Channels: The second page of shorts now loads as expected
|
||||||
|
* Channels: Fixed intermittent empty "playlists" tab
|
||||||
|
* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
|
||||||
|
* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
|
||||||
|
* Switching to another instance is much faster
|
||||||
|
* Fixed an "invalid byte sequence" error when subscribing to a playlist
|
||||||
|
* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
|
||||||
|
* Fix `force_resolve` being ignored in some cases
|
||||||
|
|
||||||
|
#### API
|
||||||
|
|
||||||
|
* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
|
||||||
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
### 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)
|
* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox)
|
||||||
* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
|
* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
|
||||||
* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
|
* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
|
||||||
|
@ -22,7 +112,12 @@
|
||||||
|
|
||||||
[#4122]: https://github.com/iv-org/invidious/pull/4122
|
[#4122]: https://github.com/iv-org/invidious/pull/4122
|
||||||
[#4193]: https://github.com/iv-org/invidious/pull/4193
|
[#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
|
[#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
|
[#4850]: https://github.com/iv-org/invidious/pull/4850
|
||||||
[#4862]: https://github.com/iv-org/invidious/pull/4862
|
[#4862]: https://github.com/iv-org/invidious/pull/4862
|
||||||
[#4863]: https://github.com/iv-org/invidious/pull/4863
|
[#4863]: https://github.com/iv-org/invidious/pull/4863
|
||||||
|
@ -32,7 +127,22 @@
|
||||||
[#4923]: https://github.com/iv-org/invidious/pull/4923
|
[#4923]: https://github.com/iv-org/invidious/pull/4923
|
||||||
[#4928]: https://github.com/iv-org/invidious/pull/4928
|
[#4928]: https://github.com/iv-org/invidious/pull/4928
|
||||||
[#4930]: https://github.com/iv-org/invidious/pull/4930
|
[#4930]: https://github.com/iv-org/invidious/pull/4930
|
||||||
|
[#4931]: https://github.com/iv-org/invidious/pull/4931
|
||||||
|
[#4934]: https://github.com/iv-org/invidious/pull/4934
|
||||||
[#4942]: https://github.com/iv-org/invidious/pull/4942
|
[#4942]: https://github.com/iv-org/invidious/pull/4942
|
||||||
|
[#4984]: https://github.com/iv-org/invidious/pull/4984
|
||||||
|
[#4991]: https://github.com/iv-org/invidious/pull/4991
|
||||||
|
[#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)
|
## v2.20240825.2 (2024-08-26)
|
||||||
|
|
9
Makefile
9
Makefile
|
@ -7,6 +7,11 @@ 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 ?=
|
||||||
|
|
||||||
|
@ -19,6 +24,10 @@ 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,6 +68,7 @@
|
||||||
|
|
||||||
.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;
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
var count = document.getElementById('count');
|
var count = document.getElementById('count');
|
||||||
count.textContent--;
|
count.textContent--;
|
||||||
|
|
||||||
var url = '/token_ajax?action_revoke_token=1&redirect=false' +
|
var url = '/token_ajax?action=revoke_token&redirect=false' +
|
||||||
'&referer=' + encodeURIComponent(location.href) +
|
'&referer=' + encodeURIComponent(location.href) +
|
||||||
'&session=' + target.getAttribute('data-session');
|
'&session=' + target.getAttribute('data-session');
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@
|
||||||
var count = document.getElementById('count');
|
var count = document.getElementById('count');
|
||||||
count.textContent--;
|
count.textContent--;
|
||||||
|
|
||||||
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
|
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
|
||||||
'&referer=' + encodeURIComponent(location.href) +
|
'&referer=' + encodeURIComponent(location.href) +
|
||||||
'&c=' + target.getAttribute('data-ucid');
|
'&c=' + target.getAttribute('data-ucid');
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ function add_playlist_video(target) {
|
||||||
var select = target.parentNode.children[0].children[1];
|
var select = target.parentNode.children[0].children[1];
|
||||||
var option = select.children[select.selectedIndex];
|
var option = select.children[select.selectedIndex];
|
||||||
|
|
||||||
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
|
var url = '/playlist_ajax?action=add_video&redirect=false' +
|
||||||
'&video_id=' + target.getAttribute('data-id') +
|
'&video_id=' + target.getAttribute('data-id') +
|
||||||
'&playlist_id=' + option.getAttribute('data-plid');
|
'&playlist_id=' + option.getAttribute('data-plid');
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ function add_playlist_item(target) {
|
||||||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||||
tile.style.display = 'none';
|
tile.style.display = 'none';
|
||||||
|
|
||||||
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
|
var url = '/playlist_ajax?action=add_video&redirect=false' +
|
||||||
'&video_id=' + target.getAttribute('data-id') +
|
'&video_id=' + target.getAttribute('data-id') +
|
||||||
'&playlist_id=' + target.getAttribute('data-plid');
|
'&playlist_id=' + target.getAttribute('data-plid');
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ function remove_playlist_item(target) {
|
||||||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||||
tile.style.display = 'none';
|
tile.style.display = 'none';
|
||||||
|
|
||||||
var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
|
var url = '/playlist_ajax?action=remove_video&redirect=false' +
|
||||||
'&set_video_id=' + target.getAttribute('data-index') +
|
'&set_video_id=' + target.getAttribute('data-index') +
|
||||||
'&playlist_id=' + target.getAttribute('data-plid');
|
'&playlist_id=' + target.getAttribute('data-plid');
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ function subscribe() {
|
||||||
subscribe_button.onclick = unsubscribe;
|
subscribe_button.onclick = unsubscribe;
|
||||||
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
||||||
|
|
||||||
var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
|
var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' +
|
||||||
'&c=' + subscribe_data.ucid;
|
'&c=' + subscribe_data.ucid;
|
||||||
|
|
||||||
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
|
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
|
||||||
|
@ -32,7 +32,7 @@ function unsubscribe() {
|
||||||
subscribe_button.onclick = subscribe;
|
subscribe_button.onclick = subscribe;
|
||||||
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
||||||
|
|
||||||
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
|
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
|
||||||
'&c=' + subscribe_data.ucid;
|
'&c=' + subscribe_data.ucid;
|
||||||
|
|
||||||
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
|
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
|
||||||
|
|
|
@ -67,6 +67,10 @@ function get_playlist(plid) {
|
||||||
'&format=html&hl=' + video_data.preferences.locale;
|
'&format=html&hl=' + video_data.preferences.locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (video_data.params.listen) {
|
||||||
|
plid_url += '&listen=1'
|
||||||
|
}
|
||||||
|
|
||||||
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
|
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
|
||||||
on200: function (response) {
|
on200: function (response) {
|
||||||
playlist.innerHTML = response.playlistHtml;
|
playlist.innerHTML = response.playlistHtml;
|
||||||
|
|
|
@ -6,7 +6,7 @@ function mark_watched(target) {
|
||||||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||||
tile.style.display = 'none';
|
tile.style.display = 'none';
|
||||||
|
|
||||||
var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
|
var url = '/watch_ajax?action=mark_watched&redirect=false' +
|
||||||
'&id=' + target.getAttribute('data-id');
|
'&id=' + target.getAttribute('data-id');
|
||||||
|
|
||||||
helpers.xhr('POST', url, {payload: payload}, {
|
helpers.xhr('POST', url, {payload: payload}, {
|
||||||
|
@ -22,7 +22,7 @@ function mark_unwatched(target) {
|
||||||
var count = document.getElementById('count');
|
var count = document.getElementById('count');
|
||||||
count.textContent--;
|
count.textContent--;
|
||||||
|
|
||||||
var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
|
var url = '/watch_ajax?action=mark_unwatched&redirect=false' +
|
||||||
'&id=' + target.getAttribute('data-id');
|
'&id=' + target.getAttribute('data-id');
|
||||||
|
|
||||||
helpers.xhr('POST', url, {payload: payload}, {
|
helpers.xhr('POST', url, {payload: payload}, {
|
||||||
|
|
|
@ -54,6 +54,53 @@ 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!!"
|
||||||
|
|
||||||
#########################################
|
#########################################
|
||||||
#
|
#
|
||||||
|
@ -173,6 +220,17 @@ 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
|
||||||
|
@ -224,11 +282,13 @@ https_only: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## Enables colors in logs. Useful for debugging purposes
|
## Enables colors in logs. Useful for debugging purposes
|
||||||
## This is overridden if "-k" or "--colorize"
|
## This is overridden if "-k" or "--colorize"
|
||||||
## are passed on the command line.
|
## are passed on the command line.
|
||||||
|
## Colors are also disabled if the environment variable
|
||||||
|
## NO_COLOR is present and has any value
|
||||||
##
|
##
|
||||||
## Accepted values: true, false
|
## Accepted values: true, false
|
||||||
## Default: false
|
## Default: true
|
||||||
##
|
##
|
||||||
#colorize_logs: false
|
#colorize_logs: false
|
||||||
|
|
||||||
|
|
62
crystal_formatters.py
Normal file
62
crystal_formatters.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
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 crystallang/crystal:1.12.1-alpine AS builder
|
FROM mirror.gcr.io/crystallang/crystal:1.14.0-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-v3 \
|
--release --mcpu=x86-64-v2 \
|
||||||
--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 alpine:3.18
|
FROM mirror.gcr.io/alpine:3.20
|
||||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini
|
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||||
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,5 +1,6 @@
|
||||||
FROM alpine:3.19 AS builder
|
FROM alpine:3.20 AS builder
|
||||||
RUN apk add --no-cache 'crystal=1.10.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
|
RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \
|
||||||
|
zlib-static openssl-libs-static openssl-dev musl-dev xz-static
|
||||||
|
|
||||||
ARG release
|
ARG release
|
||||||
|
|
||||||
|
@ -32,8 +33,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
|
||||||
--link-flags "-lxml2 -llzma"; \
|
--link-flags "-lxml2 -llzma"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM alpine:3.18
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini
|
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||||
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
|
||||||
|
|
|
@ -287,6 +287,7 @@
|
||||||
"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)",
|
||||||
|
|
2
mocks
2
mocks
|
@ -1 +1 @@
|
||||||
Subproject commit 11ec372f72747c09d48ffef04843f72be67d5b54
|
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872
|
17
shard.lock
17
shard.lock
|
@ -10,16 +10,23 @@ shards:
|
||||||
|
|
||||||
backtracer:
|
backtracer:
|
||||||
git: https://github.com/sija/backtracer.cr.git
|
git: https://github.com/sija/backtracer.cr.git
|
||||||
version: 1.2.1
|
version: 1.2.2
|
||||||
|
|
||||||
db:
|
db:
|
||||||
git: https://github.com/crystal-lang/crystal-db.git
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
version: 0.10.1
|
version: 0.13.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
|
||||||
|
@ -30,7 +37,7 @@ shards:
|
||||||
|
|
||||||
pg:
|
pg:
|
||||||
git: https://github.com/will/crystal-pg.git
|
git: https://github.com/will/crystal-pg.git
|
||||||
version: 0.24.0
|
version: 0.28.0
|
||||||
|
|
||||||
pool:
|
pool:
|
||||||
git: https://github.com/ysbaddaden/pool.git
|
git: https://github.com/ysbaddaden/pool.git
|
||||||
|
@ -50,9 +57,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.4
|
version: 0.10.6
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.18.0
|
version: 0.21.0
|
||||||
|
|
||||||
|
|
27
shard.yml
27
shard.yml
|
@ -1,21 +1,20 @@
|
||||||
name: invidious
|
name: invidious
|
||||||
version: 0.20.1
|
version: 2.20241110.0-dev
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Omar Roth <omarroth@protonmail.com>
|
- Invidious team <contact@invidious.io>
|
||||||
- Invidious team
|
- Contributors!
|
||||||
|
|
||||||
targets:
|
description: |
|
||||||
invidious:
|
Invidious is an alternative front-end to YouTube
|
||||||
main: src/invidious.cr
|
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
pg:
|
pg:
|
||||||
github: will/crystal-pg
|
github: will/crystal-pg
|
||||||
version: ~> 0.24.0
|
version: ~> 0.28.0
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
version: ~> 0.18.0
|
version: ~> 0.21.0
|
||||||
kemal:
|
kemal:
|
||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
version: ~> 1.1.2
|
version: ~> 1.1.2
|
||||||
|
@ -30,6 +29,12 @@ 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:
|
||||||
|
@ -39,6 +44,10 @@ development_dependencies:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
version: ~> 1.6.1
|
version: ~> 1.6.1
|
||||||
|
|
||||||
crystal: ">= 1.0.0, < 2.0.0"
|
crystal: ">= 1.10.0, < 2.0.0"
|
||||||
|
|
||||||
license: AGPLv3
|
license: AGPLv3
|
||||||
|
|
||||||
|
repository: https://github.com/iv-org/invidious
|
||||||
|
homepage: https://invidious.io
|
||||||
|
documentation: https://docs.invidious.io
|
||||||
|
|
|
@ -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(126_573_823)
|
expect(info["views"].as_i).to eq(220_226_287)
|
||||||
expect(info["likes"].as_i).to eq(5_157_654)
|
expect(info["likes"].as_i).to eq(6_870_691)
|
||||||
|
|
||||||
# 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("Hwybp38GnZw")
|
expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4")
|
||||||
expect(info["relatedVideos"][0]["title"]).to eq("I Built Willy Wonka's Chocolate Factory!")
|
expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!")
|
||||||
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
|
expect(info["relatedVideos"][0]["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("179877630")
|
expect(info["relatedVideos"][0]["view_count"]).to eq("230617484")
|
||||||
expect(info["relatedVideos"][0]["short_view_count"]).to eq("179M")
|
expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M")
|
||||||
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/ytc/AL5GRJVuqw82ERvHzsmBxL7avr1dpBtsVIXcEzBPZaloFg=s48-c-k-c0x00ffffff-no-rj"
|
"https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj"
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(info["authorVerified"].as_bool).to be_true
|
expect(info["authorVerified"].as_bool).to be_true
|
||||||
expect(info["subCountText"].as_s).to eq("143M")
|
expect(info["subCountText"].as_s).to eq("320M")
|
||||||
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(10_943_126)
|
expect(info["views"].as_i).to eq(14_324_584)
|
||||||
expect(info["likes"].as_i).to eq(0)
|
expect(info["likes"].as_i).to eq(35_870)
|
||||||
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(19)
|
expect(info["relatedVideos"].as_a.size).to eq(20)
|
||||||
|
|
||||||
expect(info["relatedVideos"][0]["id"]).to eq("Ww3KeZ2_Yv4")
|
expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4")
|
||||||
expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea")
|
expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version")
|
||||||
expect(info["relatedVideos"][0]["author"]).to eq("PanMusic")
|
expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH")
|
||||||
expect(info["relatedVideos"][0]["ucid"]).to eq("UCsKAPSuh1iNbLWUga_igPyA")
|
expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ")
|
||||||
expect(info["relatedVideos"][0]["view_count"]).to eq("31581")
|
expect(info["relatedVideos"][0]["view_count"]).to eq("53298661")
|
||||||
expect(info["relatedVideos"][0]["short_view_count"]).to eq("31K")
|
expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M")
|
||||||
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
|
expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
|
@ -156,11 +156,13 @@ Spectator.describe "parse_video_info" do
|
||||||
|
|
||||||
# Author infos
|
# Author infos
|
||||||
|
|
||||||
expect(info["author"].as_s).to eq("ChrisReaOfficial")
|
expect(info["author"].as_s).to eq("ChrisReaVideos")
|
||||||
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
|
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
|
||||||
|
|
||||||
expect(info["authorThumbnail"].as_s).to be_empty
|
expect(info["authorThumbnail"].as_s).to eq(
|
||||||
|
"https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj"
|
||||||
|
)
|
||||||
expect(info["authorVerified"].as_bool).to be_false
|
expect(info["authorVerified"].as_bool).to be_false
|
||||||
expect(info["subCountText"].as_s).to eq("-")
|
expect(info["subCountText"].as_s).to eq("3.11K")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,6 +23,7 @@ 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"
|
||||||
|
@ -32,6 +33,7 @@ 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/*"
|
||||||
|
@ -58,7 +60,20 @@ 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
|
||||||
|
@ -67,11 +82,12 @@ 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")
|
||||||
HOST_URL = make_host_url(Kemal.config)
|
PUBSUB_HOST_URL = CONFIG.pubsub_domain
|
||||||
|
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"}
|
||||||
|
@ -98,6 +114,10 @@ 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]"
|
||||||
|
@ -188,8 +208,18 @@ 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
|
if !CONFIG.external_videoplayback_proxy.empty?
|
||||||
Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
|
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
|
end
|
||||||
|
|
||||||
Invidious::Jobs.start_all
|
Invidious::Jobs.start_all
|
||||||
|
|
|
@ -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")
|
||||||
|
@ -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,78 +1,3 @@
|
||||||
def produce_channel_content_continuation(ucid, content_type, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
|
|
||||||
object_inner_2 = {
|
|
||||||
"2:0:embedded" => {
|
|
||||||
"1:0:varint" => 0_i64,
|
|
||||||
},
|
|
||||||
"5:varint" => 50_i64,
|
|
||||||
"6:varint" => 1_i64,
|
|
||||||
"7:varint" => (page * 30).to_i64,
|
|
||||||
"9:varint" => 1_i64,
|
|
||||||
"10:varint" => 0_i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
object_inner_2_encoded = object_inner_2
|
|
||||||
.try { |i| Protodec::Any.cast_json(i) }
|
|
||||||
.try { |i| Protodec::Any.from_json(i) }
|
|
||||||
.try { |i| Base64.urlsafe_encode(i) }
|
|
||||||
.try { |i| URI.encode_www_form(i) }
|
|
||||||
|
|
||||||
content_type_numerical =
|
|
||||||
case content_type
|
|
||||||
when "videos" then 15
|
|
||||||
when "livestreams" then 14
|
|
||||||
else 15 # Fallback to "videos"
|
|
||||||
end
|
|
||||||
|
|
||||||
sort_by_numerical =
|
|
||||||
case sort_by
|
|
||||||
when "newest" then 1_i64
|
|
||||||
when "popular" then 2_i64
|
|
||||||
when "oldest" then 4_i64
|
|
||||||
else 1_i64 # Fallback to "newest"
|
|
||||||
end
|
|
||||||
|
|
||||||
object_inner_1 = {
|
|
||||||
"110:embedded" => {
|
|
||||||
"3:embedded" => {
|
|
||||||
"#{content_type_numerical}:embedded" => {
|
|
||||||
"1:embedded" => {
|
|
||||||
"1:string" => object_inner_2_encoded,
|
|
||||||
},
|
|
||||||
"2:embedded" => {
|
|
||||||
"1:string" => "00000000-0000-0000-0000-000000000000",
|
|
||||||
},
|
|
||||||
"3:varint" => sort_by_numerical,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
object_inner_1_encoded = object_inner_1
|
|
||||||
.try { |i| Protodec::Any.cast_json(i) }
|
|
||||||
.try { |i| Protodec::Any.from_json(i) }
|
|
||||||
.try { |i| Base64.urlsafe_encode(i) }
|
|
||||||
.try { |i| URI.encode_www_form(i) }
|
|
||||||
|
|
||||||
object = {
|
|
||||||
"80226972:embedded" => {
|
|
||||||
"2:string" => ucid,
|
|
||||||
"3:string" => object_inner_1_encoded,
|
|
||||||
"35:string" => "browse-feed#{ucid}videos102",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation = object.try { |i| Protodec::Any.cast_json(i) }
|
|
||||||
.try { |i| Protodec::Any.from_json(i) }
|
|
||||||
.try { |i| Base64.urlsafe_encode(i) }
|
|
||||||
.try { |i| URI.encode_www_form(i) }
|
|
||||||
|
|
||||||
return continuation
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_initial_content_ctoken(ucid, content_type, sort_by) : String
|
|
||||||
return produce_channel_content_continuation(ucid, content_type, sort_by: sort_by)
|
|
||||||
end
|
|
||||||
|
|
||||||
module Invidious::Channel::Tabs
|
module Invidious::Channel::Tabs
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
|
@ -101,7 +26,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_content_ctoken(ucid, "videos", sort_by)
|
continuation ||= make_initial_videos_ctoken(ucid, 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)
|
||||||
|
@ -130,14 +55,10 @@ module Invidious::Channel::Tabs
|
||||||
# Shorts
|
# Shorts
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
def get_shorts(channel : AboutChannel, continuation : String? = nil)
|
def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest")
|
||||||
if continuation.nil?
|
continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by)
|
||||||
# EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts"
|
initial_data = YoutubeAPI.browse(continuation: continuation)
|
||||||
# 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
|
||||||
|
|
||||||
|
@ -145,9 +66,8 @@ 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_content_ctoken(channel.ucid, "livestreams", sort_by)
|
continuation ||= make_initial_livestreams_ctoken(channel.ucid, 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)
|
||||||
|
@ -171,4 +91,102 @@ 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
|
||||||
|
|
|
@ -55,9 +55,28 @@ 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).
|
||||||
|
@ -87,18 +106,22 @@ 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
|
||||||
property donation_url : String?
|
# Backend domains. Domains for numbered backends
|
||||||
property contact_url : String?
|
property backend_domains : Array(String) = [] of 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
|
||||||
|
@ -151,6 +174,8 @@ 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
|
||||||
|
@ -160,6 +185,12 @@ 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
|
||||||
|
@ -176,10 +207,21 @@ class Config
|
||||||
# of the backend
|
# of the backend
|
||||||
property backends_delimiter : String = "|"
|
property backends_delimiter : String = "|"
|
||||||
|
|
||||||
property external_videoplayback_proxy : String?
|
# External videoplayback proxies list. They should include `https://`
|
||||||
|
# at the start of the URI
|
||||||
|
property external_videoplayback_proxy : Array(String) = [] of String
|
||||||
|
|
||||||
# Materialious redirects
|
property pubsub_domain : String = ""
|
||||||
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
|
||||||
|
@ -196,6 +238,64 @@ 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"
|
||||||
|
@ -207,6 +307,9 @@ class Config
|
||||||
config = Config.from_yaml(config_yaml)
|
config = Config.from_yaml(config_yaml)
|
||||||
|
|
||||||
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
|
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
|
||||||
|
#
|
||||||
|
# Also checks if any top-level config options are set to "CHANGE_ME!!"
|
||||||
|
# TODO: Support non-top-level config options such as the ones in DBConfig
|
||||||
{% for ivar in Config.instance_vars %}
|
{% for ivar in Config.instance_vars %}
|
||||||
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
||||||
|
|
||||||
|
@ -243,16 +346,36 @@ class Config
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Warn when any config attribute is set to "CHANGE_ME!!"
|
||||||
|
if config.{{ivar.id}} == "CHANGE_ME!!"
|
||||||
|
puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
|
if config.invidious_companion.present?
|
||||||
|
# invidious_companion and signature_server can't work together
|
||||||
|
if config.signature_server
|
||||||
|
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key.empty?
|
||||||
|
puts "Config: Please configure a key if you are using invidious companion."
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key == "CHANGE_ME!!"
|
||||||
|
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key.size < 16
|
||||||
|
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 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?
|
||||||
puts "Config: 'hmac_key' is required/can't be empty"
|
puts "Config: 'hmac_key' is required/can't be empty"
|
||||||
exit(1)
|
exit(1)
|
||||||
elsif config.hmac_key == "CHANGE_ME!!"
|
|
||||||
puts "Config: The value of 'hmac_key' needs to be changed!!"
|
|
||||||
exit(1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build database_url from db.* if it's not set directly
|
# Build database_url from db.* if it's not set directly
|
||||||
|
|
|
@ -154,15 +154,17 @@ module Invidious::Database::Users
|
||||||
# Update (misc)
|
# Update (misc)
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
def feed_needs_update(video : ChannelVideo)
|
# Feeds never need update. PubSubHubBub is the one that sends videos to
|
||||||
request = <<-SQL
|
# invidious.
|
||||||
UPDATE users
|
# def feed_needs_update(video : ChannelVideo)
|
||||||
SET feed_needs_update = true
|
# request = <<-SQL
|
||||||
WHERE $1 = ANY(subscriptions)
|
# UPDATE users
|
||||||
SQL
|
# SET feed_needs_update = true
|
||||||
|
# WHERE $1 = ANY(subscriptions)
|
||||||
|
# SQL
|
||||||
|
|
||||||
PG_DB.exec(request, video.ucid)
|
# PG_DB.exec(request, video.ucid)
|
||||||
end
|
# end
|
||||||
|
|
||||||
def update_preferences(user : User)
|
def update_preferences(user : User)
|
||||||
request = <<-SQL
|
request = <<-SQL
|
||||||
|
|
|
@ -4,22 +4,11 @@ module Invidious::Database::Videos
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
def insert(video : Video)
|
def insert(video : Video)
|
||||||
request = <<-SQL
|
REDIS_DB.set(video.id, video.info.to_json, ex: 14400)
|
||||||
INSERT INTO videos
|
REDIS_DB.set(video.id + ":time", video.updated, ex: 14400)
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
SQL
|
|
||||||
|
|
||||||
REDIS_DB.set(video.id, video.info.to_json, ex: 3600)
|
|
||||||
REDIS_DB.set(video.id + ":time", video.updated, ex: 3600)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(id)
|
def delete(id)
|
||||||
request = <<-SQL
|
|
||||||
DELETE FROM videos *
|
|
||||||
WHERE id = $1
|
|
||||||
SQL
|
|
||||||
|
|
||||||
REDIS_DB.del(id)
|
REDIS_DB.del(id)
|
||||||
REDIS_DB.del(id + ":time")
|
REDIS_DB.del(id + ":time")
|
||||||
end
|
end
|
||||||
|
@ -44,11 +33,6 @@ module Invidious::Database::Videos
|
||||||
end
|
end
|
||||||
|
|
||||||
def select(id : String) : Video?
|
def select(id : String) : Video?
|
||||||
request = <<-SQL
|
|
||||||
SELECT * FROM videos
|
|
||||||
WHERE id = $1
|
|
||||||
SQL
|
|
||||||
|
|
||||||
if ((info = REDIS_DB.get(id)) && (time = REDIS_DB.get(id + ":time")))
|
if ((info = REDIS_DB.get(id)) && (time = REDIS_DB.get(id + ":time")))
|
||||||
return Video.new({
|
return Video.new({
|
||||||
id: id,
|
id: id,
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
|
||||||
@full_videos,
|
@full_videos,
|
||||||
@video_streams,
|
@video_streams,
|
||||||
@audio_streams,
|
@audio_streams,
|
||||||
@captions
|
@captions,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -130,7 +130,7 @@ def error_json_helper(
|
||||||
env : HTTP::Server::Context,
|
env : HTTP::Server::Context,
|
||||||
status_code : Int32,
|
status_code : Int32,
|
||||||
exception : Exception,
|
exception : Exception,
|
||||||
additional_fields : Hash(String, Object) | Nil = nil
|
additional_fields : Hash(String, Object) | Nil = nil,
|
||||||
)
|
)
|
||||||
if exception.is_a?(InfoException)
|
if exception.is_a?(InfoException)
|
||||||
return error_json_helper(env, status_code, exception.message || "", additional_fields)
|
return error_json_helper(env, status_code, exception.message || "", additional_fields)
|
||||||
|
@ -152,7 +152,7 @@ def error_json_helper(
|
||||||
env : HTTP::Server::Context,
|
env : HTTP::Server::Context,
|
||||||
status_code : Int32,
|
status_code : Int32,
|
||||||
message : String,
|
message : String,
|
||||||
additional_fields : Hash(String, Object) | Nil = nil
|
additional_fields : Hash(String, Object) | Nil = nil,
|
||||||
)
|
)
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
env.response.status_code = status_code
|
env.response.status_code = status_code
|
||||||
|
@ -180,6 +180,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
||||||
next_steps_text = translate(locale, "next_steps_error_message")
|
next_steps_text = translate(locale, "next_steps_error_message")
|
||||||
refresh = translate(locale, "next_steps_error_message_refresh")
|
refresh = translate(locale, "next_steps_error_message_refresh")
|
||||||
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
|
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
|
||||||
|
go_to_youtube_embed = translate(locale, "videoinfo_youTube_embed_link")
|
||||||
switch_instance = translate(locale, "Switch Invidious Instance")
|
switch_instance = translate(locale, "Switch Invidious Instance")
|
||||||
|
|
||||||
return <<-END_HTML
|
return <<-END_HTML
|
||||||
|
@ -193,6 +194,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
|
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
|
||||||
|
(<a rel="noreferrer noopener" href="https://youtube.com/embed/#{env.params.query["v"]}">#{go_to_youtube_embed}</a>)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
END_HTML
|
END_HTML
|
||||||
|
|
|
@ -27,6 +27,7 @@ class Kemal::RouteHandler
|
||||||
# Processes the route if it's a match. Otherwise renders 404.
|
# Processes the route if it's a match. Otherwise renders 404.
|
||||||
private def process_request(context)
|
private def process_request(context)
|
||||||
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
|
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
|
||||||
|
return if context.response.closed?
|
||||||
content = context.route.handler.call(context)
|
content = context.route.handler.call(context)
|
||||||
|
|
||||||
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
|
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
|
||||||
|
|
|
@ -1,8 +1,22 @@
|
||||||
|
# Languages requiring a better level of translation (at least 20%)
|
||||||
|
# to be added to the list below:
|
||||||
|
#
|
||||||
|
# "af" => "", # Afrikaans
|
||||||
|
# "az" => "", # Azerbaijani
|
||||||
|
# "be" => "", # Belarusian
|
||||||
|
# "bn_BD" => "", # Bengali (Bangladesh)
|
||||||
|
# "ia" => "", # Interlingua
|
||||||
|
# "or" => "", # Odia
|
||||||
|
# "tk" => "", # Turkmen
|
||||||
|
# "tok => "", # Toki Pona
|
||||||
|
#
|
||||||
LOCALES_LIST = {
|
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
|
||||||
|
@ -23,6 +37,7 @@ 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,7 +12,9 @@ enum LogLevel
|
||||||
end
|
end
|
||||||
|
|
||||||
class Invidious::LogHandler < Kemal::BaseLogHandler
|
class Invidious::LogHandler < Kemal::BaseLogHandler
|
||||||
def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @color : Bool = true)
|
def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_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)
|
||||||
|
@ -56,8 +58,7 @@ 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}})).toggle(@color))
|
puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})))
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
|
@ -24,6 +24,7 @@ struct SearchVideo
|
||||||
property length_seconds : Int32
|
property length_seconds : Int32
|
||||||
property premiere_timestamp : Time?
|
property premiere_timestamp : Time?
|
||||||
property author_verified : Bool
|
property author_verified : Bool
|
||||||
|
property author_thumbnail : String?
|
||||||
property badges : VideoBadges
|
property badges : VideoBadges
|
||||||
|
|
||||||
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
||||||
|
@ -88,6 +89,24 @@ struct SearchVideo
|
||||||
json.field "authorUrl", "/channel/#{self.ucid}"
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
json.field "authorVerified", self.author_verified
|
json.field "authorVerified", self.author_verified
|
||||||
|
|
||||||
|
author_thumbnail = self.author_thumbnail
|
||||||
|
|
||||||
|
if author_thumbnail
|
||||||
|
json.field "authorThumbnails" do
|
||||||
|
json.array do
|
||||||
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
||||||
end
|
end
|
||||||
|
@ -223,7 +242,7 @@ struct SearchChannel
|
||||||
|
|
||||||
qualities.each do |quality|
|
qualities.each do |quality|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
|
json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
||||||
json.field "width", quality
|
json.field "width", quality
|
||||||
json.field "height", quality
|
json.field "height", quality
|
||||||
end
|
end
|
||||||
|
|
35
src/invidious/helpers/session_tokens.cr
Normal file
35
src/invidious/helpers/session_tokens.cr
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
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,7 +175,6 @@ 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)
|
||||||
|
@ -201,7 +200,7 @@ 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 500.milliseconds
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
|
@ -294,7 +294,7 @@ def subscribe_pubsub(topic, key)
|
||||||
signature = "#{time}:#{nonce}"
|
signature = "#{time}:#{nonce}"
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"hub.callback" => "#{HOST_URL}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
"hub.callback" => "#{PUBSUB_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,3 +383,22 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
|
||||||
end
|
end
|
||||||
return text
|
return text
|
||||||
end
|
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,6 +4,30 @@ 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)
|
||||||
|
|
||||||
|
@ -14,7 +38,11 @@ module Invidious::HttpServer
|
||||||
url.query_params = params
|
url.query_params = params
|
||||||
|
|
||||||
if absolute
|
if absolute
|
||||||
return "#{HOST_URL}#{url.request_target}"
|
if !@@proxy_alive.empty?
|
||||||
|
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
|
||||||
|
|
|
@ -4,9 +4,9 @@ class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
|
||||||
|
|
||||||
def begin
|
def begin
|
||||||
loop do
|
loop do
|
||||||
Invidious::Routes::API::Manifest.check_external_proxy
|
HttpServer::Utils.check_external_proxy
|
||||||
LOGGER.info("CheckExternalProxy: Done, sleeping for 1 minute")
|
LOGGER.info("CheckExternalProxy: Done, sleeping for 10 seconds")
|
||||||
sleep 1.minutes
|
sleep 10.seconds
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
13
src/invidious/jobs/refresh_tokens.cr
Normal file
13
src/invidious/jobs/refresh_tokens.cr
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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,6 +30,8 @@ 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", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
|
json.field "url", "/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
|
||||||
|
|
|
@ -267,6 +267,12 @@ module Invidious::JSONify::APIv1
|
||||||
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
|
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
|
||||||
json.field "viewCountText", rv["short_view_count"]?
|
json.field "viewCountText", rv["short_view_count"]?
|
||||||
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
|
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
|
||||||
|
json.field "published", rv["published"]?
|
||||||
|
if !rv["published"]?.nil?
|
||||||
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
|
||||||
|
else
|
||||||
|
json.field "publishedText", ""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
def template_mix(mix)
|
def template_mix(mix, listen)
|
||||||
html = <<-END_HTML
|
html = <<-END_HTML
|
||||||
<h3>
|
<h3>
|
||||||
<a href="/mix?list=#{mix["mixId"]}">
|
<a href="/mix?list=#{mix["mixId"]}">
|
||||||
|
@ -95,7 +95,7 @@ def template_mix(mix)
|
||||||
mix["videos"].as_a.each do |video|
|
mix["videos"].as_a.each do |video|
|
||||||
html += <<-END_HTML
|
html += <<-END_HTML
|
||||||
<li class="pure-menu-item">
|
<li class="pure-menu-item">
|
||||||
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
|
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
||||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||||
|
|
|
@ -505,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
||||||
return videos
|
return videos
|
||||||
end
|
end
|
||||||
|
|
||||||
def template_playlist(playlist)
|
def template_playlist(playlist, listen)
|
||||||
html = <<-END_HTML
|
html = <<-END_HTML
|
||||||
<h3>
|
<h3>
|
||||||
<a href="/playlist?list=#{playlist["playlistId"]}">
|
<a href="/playlist?list=#{playlist["playlistId"]}">
|
||||||
|
@ -519,7 +519,7 @@ def template_playlist(playlist)
|
||||||
playlist["videos"].as_a.each do |video|
|
playlist["videos"].as_a.each do |video|
|
||||||
html += <<-END_HTML
|
html += <<-END_HTML
|
||||||
<li class="pure-menu-item" id="#{video["videoId"]}">
|
<li class="pure-menu-item" id="#{video["videoId"]}">
|
||||||
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
|
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
||||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||||
|
|
|
@ -326,17 +326,9 @@ module Invidious::Routes::Account
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["action_revoke_token"]?
|
case action = env.params.query["action"]?
|
||||||
action = "action_revoke_token"
|
when "revoke_token"
|
||||||
else
|
session = env.params.query["session"]
|
||||||
return env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
session = env.params.query["session"]?
|
|
||||||
session ||= ""
|
|
||||||
|
|
||||||
case action
|
|
||||||
when .starts_with? "action_revoke_token"
|
|
||||||
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
|
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return error_json(400, "Unsupported action #{action}")
|
||||||
|
|
|
@ -1,15 +1,4 @@
|
||||||
module Invidious::Routes::API::Manifest
|
module Invidious::Routes::API::Manifest
|
||||||
@@proxy_alive : Bool = false
|
|
||||||
|
|
||||||
def self.check_external_proxy
|
|
||||||
begin
|
|
||||||
response = HTTP::Client.get("#{CONFIG.external_videoplayback_proxy}")
|
|
||||||
@@proxy_alive = response.status_code == 200
|
|
||||||
rescue
|
|
||||||
@@proxy_alive = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# /api/manifest/dash/id/:id
|
# /api/manifest/dash/id/:id
|
||||||
def self.get_dash_video_id(env)
|
def self.get_dash_video_id(env)
|
||||||
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
||||||
|
@ -19,6 +8,11 @@ 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 }
|
||||||
|
@ -38,36 +32,21 @@ module Invidious::Routes::API::Manifest
|
||||||
haltf env, status_code: response.status_code
|
haltf env, status_code: response.status_code
|
||||||
end
|
end
|
||||||
|
|
||||||
manifest = response.body
|
# Proxy URLs for video playback on invidious.
|
||||||
|
# Other API clients can get the original URLs by omiting `local=true`.
|
||||||
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
manifest = response.body.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
||||||
url = baseurl.lchop("<BaseURL>")
|
url = baseurl.lchop("<BaseURL>").rchop("</BaseURL>")
|
||||||
url = url.rchop("</BaseURL>")
|
url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local
|
||||||
|
|
||||||
if local
|
|
||||||
uri = URI.parse(url)
|
|
||||||
if @@proxy_alive
|
|
||||||
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
|
||||||
|
|
||||||
adaptive_fmts = video.adaptive_fmts
|
# Ditto, only proxify URLs if `local=true` is used
|
||||||
|
|
||||||
if local
|
if local
|
||||||
adaptive_fmts.each do |fmt|
|
video.adaptive_fmts.each do |fmt|
|
||||||
if @@proxy_alive
|
fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true))
|
||||||
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
|
||||||
|
|
||||||
|
@ -96,17 +75,23 @@ module Invidious::Routes::API::Manifest
|
||||||
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
|
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
|
||||||
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
|
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
|
||||||
|
|
||||||
|
audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any
|
||||||
|
lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und"
|
||||||
|
is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0
|
||||||
|
displayname = audio_track["displayName"]?.try &.as_s || "Unknown"
|
||||||
|
bitrate = fmt["bitrate"]
|
||||||
|
|
||||||
# Different representations of the same audio should be groupped into one AdaptationSet.
|
# Different representations of the same audio should be groupped into one AdaptationSet.
|
||||||
# However, most players don't support auto quality switching, so we have to trick them
|
# However, most players don't support auto quality switching, so we have to trick them
|
||||||
# into providing a quality selector.
|
# into providing a quality selector.
|
||||||
# See https://github.com/iv-org/invidious/issues/3074 for more details.
|
# See https://github.com/iv-org/invidious/issues/3074 for more details.
|
||||||
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
|
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do
|
||||||
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
|
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
|
||||||
bandwidth = fmt["bitrate"].as_i
|
bandwidth = fmt["bitrate"].as_i
|
||||||
itag = fmt["itag"].as_i
|
itag = fmt["itag"].as_i
|
||||||
url = fmt["url"].as_s
|
url = fmt["url"].as_s
|
||||||
|
|
||||||
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
|
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate")
|
||||||
|
|
||||||
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
|
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
|
||||||
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
|
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
|
||||||
|
@ -203,8 +188,9 @@ module Invidious::Routes::API::Manifest
|
||||||
manifest = response.body
|
manifest = response.body
|
||||||
|
|
||||||
if local
|
if local
|
||||||
manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
|
manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match|
|
||||||
path = URI.parse(match).path
|
uri = URI.parse(match)
|
||||||
|
path = uri.path
|
||||||
|
|
||||||
path = path.lchop("/videoplayback/")
|
path = path.lchop("/videoplayback/")
|
||||||
path = path.rchop("/")
|
path = path.rchop("/")
|
||||||
|
@ -233,9 +219,21 @@ module Invidious::Routes::API::Manifest
|
||||||
raw_params["fvip"] = fvip["fvip"]
|
raw_params["fvip"] = fvip["fvip"]
|
||||||
end
|
end
|
||||||
|
|
||||||
raw_params["local"] = "true"
|
raw_params["host"] = uri.host.not_nil!
|
||||||
|
|
||||||
"#{HOST_URL}/videoplayback?#{raw_params}"
|
proxy = Invidious::HttpServer::Utils.get_external_proxy
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@ -258,7 +256,12 @@ module Invidious::Routes::API::Manifest
|
||||||
manifest = response.body
|
manifest = response.body
|
||||||
|
|
||||||
if local
|
if local
|
||||||
manifest = manifest.gsub("https://www.youtube.com", HOST_URL)
|
if CONFIG.https_only
|
||||||
|
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"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
|
env.response.headers["Location"] = "#{env.request.headers["Host"]}/api/v1/auth/playlists/#{playlist.id}"
|
||||||
env.response.status_code = 201
|
env.response.status_code = 201
|
||||||
{
|
{
|
||||||
"title" => title,
|
"title" => title,
|
||||||
|
@ -482,7 +482,7 @@ module Invidious::Routes::API::V1::Authenticated
|
||||||
env.response.content_type = "text/event-stream"
|
env.response.content_type = "text/event-stream"
|
||||||
|
|
||||||
raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
|
raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
|
||||||
topics = raw_topics.try &.split(",").uniq.first(1000)
|
topics = raw_topics.try &.split(",").uniq!.first(1000)
|
||||||
topics ||= [] of String
|
topics ||= [] of String
|
||||||
|
|
||||||
create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||||
|
|
|
@ -197,6 +197,7 @@ 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
|
||||||
|
@ -211,7 +212,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
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return error_json(500, ex)
|
||||||
|
|
|
@ -42,6 +42,9 @@ module Invidious::Routes::API::V1::Misc
|
||||||
format = env.params.query["format"]?
|
format = env.params.query["format"]?
|
||||||
format ||= "json"
|
format ||= "json"
|
||||||
|
|
||||||
|
listen_param = env.params.query["listen"]?
|
||||||
|
listen = (listen_param == "true" || listen_param == "1")
|
||||||
|
|
||||||
if plid.starts_with? "RD"
|
if plid.starts_with? "RD"
|
||||||
return env.redirect "/api/v1/mixes/#{plid}"
|
return env.redirect "/api/v1/mixes/#{plid}"
|
||||||
end
|
end
|
||||||
|
@ -85,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
|
||||||
end
|
end
|
||||||
|
|
||||||
if format == "html"
|
if format == "html"
|
||||||
playlist_html = template_playlist(json_response)
|
playlist_html = template_playlist(json_response, listen)
|
||||||
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
|
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
@ -111,6 +114,9 @@ module Invidious::Routes::API::V1::Misc
|
||||||
format = env.params.query["format"]?
|
format = env.params.query["format"]?
|
||||||
format ||= "json"
|
format ||= "json"
|
||||||
|
|
||||||
|
listen_param = env.params.query["listen"]?
|
||||||
|
listen = (listen_param == "true" || listen_param == "1")
|
||||||
|
|
||||||
begin
|
begin
|
||||||
mix = fetch_mix(rdid, continuation, locale: locale)
|
mix = fetch_mix(rdid, continuation, locale: locale)
|
||||||
|
|
||||||
|
@ -141,9 +147,7 @@ module Invidious::Routes::API::V1::Misc
|
||||||
json.field "authorUrl", "/channel/#{video.ucid}"
|
json.field "authorUrl", "/channel/#{video.ucid}"
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
json.array do
|
Invidious::JSONify::APIv1.thumbnails(json, video.id)
|
||||||
Invidious::JSONify::APIv1.thumbnails(json, video.id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "index", video.index
|
json.field "index", video.index
|
||||||
|
@ -157,7 +161,7 @@ module Invidious::Routes::API::V1::Misc
|
||||||
|
|
||||||
if format == "html"
|
if format == "html"
|
||||||
response = JSON.parse(response)
|
response = JSON.parse(response)
|
||||||
playlist_html = template_mix(response)
|
playlist_html = template_mix(response, listen)
|
||||||
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
|
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
|
|
@ -31,9 +31,7 @@ module Invidious::Routes::API::V1::Search
|
||||||
query = env.params.query["q"]? || ""
|
query = env.params.query["q"]? || ""
|
||||||
|
|
||||||
begin
|
begin
|
||||||
client = HTTP::Client.new("suggestqueries-clients6.youtube.com")
|
client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true)
|
||||||
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
|
||||||
|
|
|
@ -20,18 +20,25 @@ 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
|
||||||
|
@ -49,7 +56,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'" + external_videoplayback_proxy,
|
"connect-src 'self'" + extra_connect_csp,
|
||||||
"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,10 +20,11 @@ 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 || "last")
|
channel.ucid, channel.author, continuation, sort_by
|
||||||
)
|
)
|
||||||
|
|
||||||
items.uniq! do |item|
|
items.uniq! do |item|
|
||||||
|
@ -49,9 +50,11 @@ 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(
|
|
||||||
channel, continuation: continuation, sort_by: (sort_by || "newest")
|
items, next_continuation = Channel::Tabs.get_60_videos(
|
||||||
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -82,13 +85,12 @@ module Invidious::Routes::Channels
|
||||||
end
|
end
|
||||||
next_continuation = nil
|
next_continuation = nil
|
||||||
else
|
else
|
||||||
# TODO: support sort option for shorts
|
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
|
||||||
sort_by = ""
|
sort_options = {"newest", "oldest", "popular"}
|
||||||
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
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -157,10 +157,12 @@ 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(URI.parse(fmt["url"].as_s).request_target) }
|
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -194,6 +194,7 @@ module Invidious::Routes::Feeds
|
||||||
length_seconds: 0,
|
length_seconds: 0,
|
||||||
premiere_timestamp: nil,
|
premiere_timestamp: nil,
|
||||||
author_verified: false,
|
author_verified: false,
|
||||||
|
author_thumbnail: nil,
|
||||||
badges: VideoBadges::None,
|
badges: VideoBadges::None,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
@ -416,18 +417,22 @@ 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
|
||||||
|
|
||||||
begin
|
if CONFIG.use_innertube_for_feeds
|
||||||
video = get_video(id, force_refresh: true)
|
begin
|
||||||
rescue
|
video_ = get_video(id, force_refresh: true)
|
||||||
next # skip this video since it raised an exception (e.g. it is a scheduled live event)
|
rescue
|
||||||
|
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" => video.ucid,
|
"topic" => ucid,
|
||||||
"videoId" => video.id,
|
"videoId" => 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}'")
|
||||||
|
@ -435,23 +440,23 @@ module Invidious::Routes::Feeds
|
||||||
|
|
||||||
video = ChannelVideo.new({
|
video = ChannelVideo.new({
|
||||||
id: id,
|
id: id,
|
||||||
title: video.title,
|
title: title,
|
||||||
published: published,
|
published: published,
|
||||||
updated: updated,
|
updated: updated,
|
||||||
ucid: video.ucid,
|
ucid: ucid,
|
||||||
author: author,
|
author: author,
|
||||||
length_seconds: video.length_seconds,
|
length_seconds: video_.try &.length_seconds || 0,
|
||||||
live_now: video.live_now,
|
live_now: video_.try &.live_now || false,
|
||||||
premiere_timestamp: video.premiere_timestamp,
|
premiere_timestamp: video_.try &.premiere_timestamp || nil,
|
||||||
views: video.views,
|
views: video_.try &.views || nil,
|
||||||
})
|
})
|
||||||
|
|
||||||
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,29 +11,9 @@ 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
|
||||||
HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
|
GGPHT_POOL.client &.get(url, headers) do |resp|
|
||||||
return request_proc.call(resp)
|
return self.proxy_image(env, resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -61,27 +41,10 @@ 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
|
||||||
HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
|
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
|
||||||
return request_proc.call(resp)
|
env.response.headers["Connection"] = "close"
|
||||||
|
return self.proxy_image(env, resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -101,26 +64,9 @@ 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
|
||||||
HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
|
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
|
||||||
return request_proc.call(resp)
|
return self.proxy_image(env, resp)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
|
@ -165,8 +111,7 @@ module Invidious::Routes::Images
|
||||||
if name == "maxres.jpg"
|
if name == "maxres.jpg"
|
||||||
build_thumbnails(id).each do |thumb|
|
build_thumbnails(id).each do |thumb|
|
||||||
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
|
||||||
# This can likely be optimized into a (small) pool sometime in the future.
|
if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200
|
||||||
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
|
||||||
|
@ -181,29 +126,28 @@ 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
|
||||||
# This can likely be optimized into a (small) pool sometime in the future.
|
get_ytimg_pool("i").client &.get(url, headers) do |resp|
|
||||||
HTTP::Client.get("https://i.ytimg.com#{url}") do |resp|
|
return self.proxy_image(env, 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,6 +64,8 @@ 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
|
||||||
|
@ -170,6 +172,8 @@ 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
|
||||||
|
|
|
@ -304,23 +304,6 @@ module Invidious::Routes::Playlists
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["action_create_playlist"]?
|
|
||||||
action = "action_create_playlist"
|
|
||||||
elsif env.params.query["action_delete_playlist"]?
|
|
||||||
action = "action_delete_playlist"
|
|
||||||
elsif env.params.query["action_edit_playlist"]?
|
|
||||||
action = "action_edit_playlist"
|
|
||||||
elsif env.params.query["action_add_video"]?
|
|
||||||
action = "action_add_video"
|
|
||||||
video_id = env.params.query["video_id"]
|
|
||||||
elsif env.params.query["action_remove_video"]?
|
|
||||||
action = "action_remove_video"
|
|
||||||
elsif env.params.query["action_move_video_before"]?
|
|
||||||
action = "action_move_video_before"
|
|
||||||
else
|
|
||||||
return env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
playlist_id = env.params.query["playlist_id"]
|
playlist_id = env.params.query["playlist_id"]
|
||||||
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
|
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
|
||||||
|
@ -335,12 +318,8 @@ module Invidious::Routes::Playlists
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
email = user.email
|
case action = env.params.query["action"]?
|
||||||
|
when "add_video"
|
||||||
case action
|
|
||||||
when "action_edit_playlist"
|
|
||||||
# TODO: Playlist stub
|
|
||||||
when "action_add_video"
|
|
||||||
if playlist.index.size >= CONFIG.playlist_length_limit
|
if playlist.index.size >= CONFIG.playlist_length_limit
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
||||||
|
@ -377,12 +356,14 @@ module Invidious::Routes::Playlists
|
||||||
|
|
||||||
Invidious::Database::PlaylistVideos.insert(playlist_video)
|
Invidious::Database::PlaylistVideos.insert(playlist_video)
|
||||||
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
|
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
|
||||||
when "action_remove_video"
|
when "remove_video"
|
||||||
index = env.params.query["set_video_id"]
|
index = env.params.query["set_video_id"]
|
||||||
Invidious::Database::PlaylistVideos.delete(index)
|
Invidious::Database::PlaylistVideos.delete(index)
|
||||||
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
|
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
|
||||||
when "action_move_video_before"
|
when "move_video_before"
|
||||||
# TODO: Playlist stub
|
# TODO: Playlist stub
|
||||||
|
when nil
|
||||||
|
return error_json(400, "Missing action")
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return error_json(400, "Unsupported action #{action}")
|
||||||
end
|
end
|
||||||
|
|
|
@ -228,6 +228,8 @@ 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
|
||||||
|
@ -269,6 +271,8 @@ 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
|
||||||
|
|
|
@ -32,24 +32,16 @@ module Invidious::Routes::Subscriptions
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
|
|
||||||
action = "action_create_subscription_to_channel"
|
|
||||||
elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
|
|
||||||
action = "action_remove_subscriptions"
|
|
||||||
else
|
|
||||||
return env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
channel_id = env.params.query["c"]?
|
channel_id = env.params.query["c"]?
|
||||||
channel_id ||= ""
|
channel_id ||= ""
|
||||||
|
|
||||||
case action
|
case action = env.params.query["action"]?
|
||||||
when "action_create_subscription_to_channel"
|
when "create_subscription_to_channel"
|
||||||
if !user.subscriptions.includes? channel_id
|
if !user.subscriptions.includes? channel_id
|
||||||
get_channel(channel_id)
|
get_channel(channel_id)
|
||||||
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
||||||
end
|
end
|
||||||
when "action_remove_subscriptions"
|
when "remove_subscriptions"
|
||||||
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return error_json(400, "Unsupported action #{action}")
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
{% skip_file if flag?(:api_only) %}
|
|
||||||
|
|
||||||
module Invidious::Routes::BackendSwitcher
|
|
||||||
def self.switch(env)
|
|
||||||
referer = get_referer(env)
|
|
||||||
backend_id = env.params.query["backend_id"]
|
|
||||||
|
|
||||||
# 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,6 +3,11 @@ 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(",")
|
||||||
|
@ -42,11 +47,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
headers["Range"] = "bytes=#{range_for_head}"
|
headers["Range"] = "bytes=#{range_for_head}"
|
||||||
end
|
end
|
||||||
|
|
||||||
headers["Origin"] = "https://www.youtube.com"
|
client = make_client(URI.parse(host), region, force_resolve: true)
|
||||||
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
|
||||||
|
@ -61,7 +62,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}" : ""}"
|
||||||
|
@ -75,7 +76,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
|
||||||
|
@ -100,7 +101,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
client.get(url, headers) do |resp|
|
client.post(url, headers, protobuf) 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
|
||||||
|
@ -151,7 +152,7 @@ module Invidious::Routes::VideoPlayback
|
||||||
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
|
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
client.get(url, headers) do |resp|
|
client.post(url, headers, protobuf) 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
|
||||||
|
@ -168,10 +169,13 @@ module Invidious::Routes::VideoPlayback
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
if location = resp.headers["Location"]?
|
if location = resp.headers["Location"]?
|
||||||
location = URI.parse(location)
|
url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region)
|
||||||
location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
|
|
||||||
|
|
||||||
env.redirect location
|
if title = query_params["title"]?
|
||||||
|
url = "#{url}&title=#{URI.encode_www_form(title)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
env.redirect url
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -200,7 +204,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
|
||||||
|
|
||||||
|
@ -257,6 +261,11 @@ 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?
|
||||||
|
|
||||||
|
@ -298,7 +307,16 @@ module Invidious::Routes::VideoPlayback
|
||||||
end
|
end
|
||||||
|
|
||||||
if local
|
if local
|
||||||
url = URI.parse(url).request_target.not_nil!
|
external_proxy = Invidious::HttpServer::Utils.get_external_proxy
|
||||||
|
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,8 +128,7 @@ module Invidious::Routes::Watch
|
||||||
end
|
end
|
||||||
|
|
||||||
if params.local
|
if params.local
|
||||||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
||||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
video_streams = video.video_streams
|
video_streams = video.video_streams
|
||||||
|
@ -204,6 +203,12 @@ 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
|
||||||
|
|
||||||
|
@ -255,18 +260,10 @@ module Invidious::Routes::Watch
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["action_mark_watched"]?
|
case action = env.params.query["action"]?
|
||||||
action = "action_mark_watched"
|
when "mark_watched"
|
||||||
elsif env.params.query["action_mark_unwatched"]?
|
|
||||||
action = "action_mark_unwatched"
|
|
||||||
else
|
|
||||||
return env.redirect referer
|
|
||||||
end
|
|
||||||
|
|
||||||
case action
|
|
||||||
when "action_mark_watched"
|
|
||||||
Invidious::Database::Users.mark_watched(user, id)
|
Invidious::Database::Users.mark_watched(user, id)
|
||||||
when "action_mark_unwatched"
|
when "mark_unwatched"
|
||||||
Invidious::Database::Users.mark_unwatched(user, id)
|
Invidious::Database::Users.mark_unwatched(user, id)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return error_json(400, "Unsupported action #{action}")
|
||||||
|
@ -334,14 +331,18 @@ 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
|
elsif itag = download_widget["itag"]?.try &.as_i.to_s
|
||||||
# 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"
|
||||||
|
|
||||||
return Invidious::Routes::VideoPlayback.latest_version(env)
|
if (CONFIG.invidious_companion.present?)
|
||||||
|
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,7 +21,6 @@ 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
|
||||||
|
@ -244,17 +243,16 @@ 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
|
||||||
|
@ -272,11 +270,6 @@ 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
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,7 @@ module Invidious::Search
|
||||||
@type : Type = Type::All,
|
@type : Type = Type::All,
|
||||||
@duration : Duration = Duration::None,
|
@duration : Duration = Duration::None,
|
||||||
@features : Features = Features::None,
|
@features : Features = Features::None,
|
||||||
@sort : Sort = Sort::Relevance
|
@sort : Sort = Sort::Relevance,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@ module Invidious::Search
|
||||||
def initialize(
|
def initialize(
|
||||||
params : HTTP::Params,
|
params : HTTP::Params,
|
||||||
@type : Type = Type::Regular,
|
@type : Type = Type::Regular,
|
||||||
@region : String? = nil
|
@region : String? = nil,
|
||||||
)
|
)
|
||||||
# Get the raw search query string (common to all search types). In
|
# Get the raw search query string (common to all search types). In
|
||||||
# Regular search mode, also look for the `search_query` URL parameter
|
# Regular search mode, also look for the `search_query` URL parameter
|
||||||
|
|
|
@ -45,18 +45,5 @@ 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
|
||||||
|
|
|
@ -290,42 +290,39 @@ struct Invidious::User
|
||||||
end
|
end
|
||||||
|
|
||||||
def from_newpipe(user : User, body : String) : Bool
|
def from_newpipe(user : User, body : String) : Bool
|
||||||
io = IO::Memory.new(body)
|
Compress::Zip::File.open(IO::Memory.new(body), true) do |file|
|
||||||
|
entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" }
|
||||||
|
return false if entry.nil?
|
||||||
|
entry.open do |file_io|
|
||||||
|
# Ensure max size of 4MB
|
||||||
|
io_sized = IO::Sized.new(file_io, 0x400000)
|
||||||
|
|
||||||
Compress::Zip::File.open(io) do |file|
|
begin
|
||||||
file.entries.each do |entry|
|
temp = File.tempfile(".db") do |tempfile|
|
||||||
entry.open do |file_io|
|
begin
|
||||||
# Ensure max size of 4MB
|
File.write(tempfile.path, io_sized.gets_to_end)
|
||||||
io_sized = IO::Sized.new(file_io, 0x400000)
|
rescue
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
next if entry.filename != "newpipe.db"
|
DB.open("sqlite3://" + tempfile.path) do |db|
|
||||||
|
user.watched += db.query_all("SELECT url FROM streams", as: String)
|
||||||
|
.map(&.lchop("https://www.youtube.com/watch?v="))
|
||||||
|
|
||||||
tempfile = File.tempfile(".db")
|
user.watched.uniq!
|
||||||
|
Invidious::Database::Users.update_watch_history(user)
|
||||||
|
|
||||||
begin
|
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
|
||||||
File.write(tempfile.path, io_sized.gets_to_end)
|
.map(&.lchop("https://www.youtube.com/channel/"))
|
||||||
rescue
|
|
||||||
return false
|
user.subscriptions.uniq!
|
||||||
|
user.subscriptions = get_batch_channels(user.subscriptions)
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_subscriptions(user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
ensure
|
||||||
db = DB.open("sqlite3://" + tempfile.path)
|
temp.delete if !temp.nil?
|
||||||
|
|
||||||
user.watched += db.query_all("SELECT url FROM streams", as: String)
|
|
||||||
.map(&.lchop("https://www.youtube.com/watch?v="))
|
|
||||||
|
|
||||||
user.watched.uniq!
|
|
||||||
Invidious::Database::Users.update_watch_history(user)
|
|
||||||
|
|
||||||
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
|
|
||||||
.map(&.lchop("https://www.youtube.com/channel/"))
|
|
||||||
|
|
||||||
user.subscriptions.uniq!
|
|
||||||
user.subscriptions = get_batch_channels(user.subscriptions)
|
|
||||||
|
|
||||||
Invidious::Database::Users.update_subscriptions(user)
|
|
||||||
|
|
||||||
db.close
|
|
||||||
tempfile.delete
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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 = 2
|
SCHEMA_VERSION = 3
|
||||||
|
|
||||||
property id : String
|
property id : String
|
||||||
|
|
||||||
|
@ -106,7 +106,7 @@ struct Video
|
||||||
if formats = info.dig?("streamingData", "adaptiveFormats")
|
if formats = info.dig?("streamingData", "adaptiveFormats")
|
||||||
return formats
|
return formats
|
||||||
.as_a.map(&.as_h)
|
.as_a.map(&.as_h)
|
||||||
.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
.sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 }
|
||||||
else
|
else
|
||||||
return [] of Hash(String, JSON::Any)
|
return [] of Hash(String, JSON::Any)
|
||||||
end
|
end
|
||||||
|
@ -192,6 +192,10 @@ 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,6 +123,7 @@ module Invidious::Videos
|
||||||
"Esperanto",
|
"Esperanto",
|
||||||
"Estonian",
|
"Estonian",
|
||||||
"Filipino",
|
"Filipino",
|
||||||
|
"Filipino (auto-generated)",
|
||||||
"Finnish",
|
"Finnish",
|
||||||
"French",
|
"French",
|
||||||
"French (auto-generated)",
|
"French (auto-generated)",
|
||||||
|
|
|
@ -36,6 +36,13 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
|
||||||
|
|
||||||
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
|
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
|
||||||
|
|
||||||
|
if published_time_text = related["publishedTimeText"]?
|
||||||
|
decoded_time = decode_date(published_time_text["simpleText"].to_s)
|
||||||
|
published = decoded_time.to_rfc3339.to_s
|
||||||
|
else
|
||||||
|
published = nil
|
||||||
|
end
|
||||||
|
|
||||||
# TODO: when refactoring video types, make a struct for related videos
|
# TODO: when refactoring video types, make a struct for related videos
|
||||||
# or reuse an existing type, if that fits.
|
# or reuse an existing type, if that fits.
|
||||||
return {
|
return {
|
||||||
|
@ -47,6 +54,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
|
||||||
"view_count" => JSON::Any.new(view_count || "0"),
|
"view_count" => JSON::Any.new(view_count || "0"),
|
||||||
"short_view_count" => JSON::Any.new(short_view_count || "0"),
|
"short_view_count" => JSON::Any.new(short_view_count || "0"),
|
||||||
"author_verified" => JSON::Any.new(author_verified),
|
"author_verified" => JSON::Any.new(author_verified),
|
||||||
|
"published" => JSON::Any.new(published || ""),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -100,39 +108,32 @@ 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
|
||||||
|
|
||||||
new_player_response = nil
|
if CONFIG.invidious_companion.present?
|
||||||
|
new_player_response = nil
|
||||||
|
|
||||||
# Don't use Android client if po_token is passed because po_token doesn't
|
# Don't use Android test suite client if po_token is passed because po_token doesn't
|
||||||
# work for Android client.
|
# work for Android test suite 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
|
||||||
|
|
||||||
# Last hope
|
{"captions", "playabilityStatus", "playerConfig", "storyboards", "invidiousCompanion"}.each do |f|
|
||||||
# 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"}.each do |f|
|
|
||||||
params[f] = player_response[f] if player_response[f]?
|
params[f] = player_response[f] if player_response[f]?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -224,8 +225,17 @@ 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 || false
|
.try &.as_bool
|
||||||
|
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
|
||||||
|
|
|
@ -20,7 +20,7 @@ module Invidious::Videos
|
||||||
|
|
||||||
def initialize(
|
def initialize(
|
||||||
*, @url, @width, @height, @count, @interval,
|
*, @url, @width, @height, @count, @interval,
|
||||||
@rows, @columns, @images_count
|
@rows, @columns, @images_count,
|
||||||
)
|
)
|
||||||
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
|
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
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
|
||||||
|
@ -28,15 +29,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_URL %>/channel/<%= ucid %>">
|
<meta property="og:url" content="<%= host %>/channel/<%= ucid %>">
|
||||||
<meta property="og:title" content="<%= author %>">
|
<meta property="og:title" content="<%= author %>">
|
||||||
<meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
|
<meta property="og:image" content="<%= host %>/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_URL %>/channel/<%= ucid %>">
|
<meta name="twitter:url" content="<%= host %>/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_URL %>/ggpht<%= channel_profile_pic %>">
|
<meta name="twitter:image" content="<%= host %>/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 -%>
|
||||||
|
|
||||||
|
|
|
@ -103,12 +103,15 @@
|
||||||
if item.is_a?(PlaylistVideo)
|
if item.is_a?(PlaylistVideo)
|
||||||
link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}"
|
link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}"
|
||||||
endpoint_params = "?v=#{item.id}&list=#{item.plid}"
|
endpoint_params = "?v=#{item.id}&list=#{item.plid}"
|
||||||
|
embed_id = item.id
|
||||||
elsif item.is_a?(MixVideo)
|
elsif item.is_a?(MixVideo)
|
||||||
link_url = "/watch?v=#{item.id}&list=#{item.rdid}"
|
link_url = "/watch?v=#{item.id}&list=#{item.rdid}"
|
||||||
endpoint_params = "?v=#{item.id}&list=#{item.rdid}"
|
endpoint_params = "?v=#{item.id}&list=#{item.rdid}"
|
||||||
|
embed_id = item.id
|
||||||
else
|
else
|
||||||
link_url = "/watch?v=#{item.id}"
|
link_url = "/watch?v=#{item.id}"
|
||||||
endpoint_params = "?v=#{item.id}"
|
endpoint_params = "?v=#{item.id}"
|
||||||
|
embed_id = item.id
|
||||||
end
|
end
|
||||||
-%>
|
-%>
|
||||||
|
|
||||||
|
@ -128,7 +131,7 @@
|
||||||
|
|
||||||
<div class="top-left-overlay">
|
<div class="top-left-overlay">
|
||||||
<%- if env.get? "show_watched" -%>
|
<%- if env.get? "show_watched" -%>
|
||||||
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
|
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_watched&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
||||||
data-onclick="mark_watched" data-id="<%= item.id %>">
|
data-onclick="mark_watched" data-id="<%= item.id %>">
|
||||||
|
@ -138,14 +141,14 @@
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
|
|
||||||
<%- if plid_form = env.get?("add_playlist_items") -%>
|
<%- if plid_form = env.get?("add_playlist_items") -%>
|
||||||
<%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
|
<%- form_parameters = "action=add_video&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
|
||||||
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
|
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
||||||
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
|
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
|
||||||
</form>
|
</form>
|
||||||
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
|
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
|
||||||
<%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
|
<%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
|
||||||
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
|
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
||||||
|
|
|
@ -22,6 +22,8 @@
|
||||||
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)
|
||||||
|
@ -34,8 +36,12 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<% if params.quality == "dash" %>
|
<% if params.quality == "dash"
|
||||||
<source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
|
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1"
|
||||||
|
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 %>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
|
@ -44,6 +50,8 @@
|
||||||
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,13 +1,13 @@
|
||||||
<% if user %>
|
<% if user %>
|
||||||
<% if subscriptions.includes? ucid %>
|
<% if subscriptions.includes? ucid %>
|
||||||
<form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
|
<form action="/subscription_ajax?action=remove_subscriptions&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
|
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
|
||||||
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
|
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<% else %>
|
<% else %>
|
||||||
<form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
|
<form action="/subscription_ajax?action=create_subscription_to_channel&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
|
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
|
||||||
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
|
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<div class="flex-right flexible">
|
<div class="flex-right flexible">
|
||||||
<div class="icon-buttons">
|
<div class="icon-buttons">
|
||||||
|
<a title="<%=translate(locale, "videoinfo_youTube_embed_link")%>" rel="noreferrer noopener" href="https://www.youtube.com/embed/<%=embed_id%>">
|
||||||
|
<i class="icon ion-md-open"></i>
|
||||||
|
</a>
|
||||||
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
|
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
|
||||||
<i class="icon ion-logo-youtube"></i>
|
<i class="icon ion-logo-youtube"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="top-left-overlay"><div class="watched">
|
<div class="top-left-overlay"><div class="watched">
|
||||||
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
|
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
<button type="submit" class="pure-button pure-button-secondary low-profile"
|
||||||
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
|
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
<%
|
<%
|
||||||
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["SERVER_ID"]?.try &.value
|
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,6 +107,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if !CONFIG.backends.empty? %>
|
<% if !CONFIG.backends.empty? %>
|
||||||
|
<% 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 | %>
|
||||||
|
@ -128,6 +130,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if CONFIG.banner %>
|
<% if CONFIG.banner %>
|
||||||
<div class="h-box">
|
<div class="h-box">
|
||||||
|
@ -310,7 +313,10 @@
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="footer-footer">
|
<div class="footer-footer">
|
||||||
<div class="box">You are currently using Backend: <%= current_backend %></p>
|
<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") %>
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
<div class="pure-u-2-5"></div>
|
<div class="pure-u-2-5"></div>
|
||||||
<div class="pure-u-1-5" style="text-align:right">
|
<div class="pure-u-1-5" style="text-align:right">
|
||||||
<h3 style="padding-right:0.5em">
|
<h3 style="padding-right:0.5em">
|
||||||
<form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
|
<form data-onsubmit="return_false" action="/subscription_ajax?action=remove_subscriptions&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
|
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-u-1-5" style="text-align:right">
|
<div class="pure-u-1-5" style="text-align:right">
|
||||||
<h3 style="padding-right:0.5em">
|
<h3 style="padding-right:0.5em">
|
||||||
<form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
|
<form data-onsubmit="return_false" action="/token_ajax?action=revoke_token&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
|
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<% 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 %>
|
||||||
|
@ -8,22 +9,24 @@
|
||||||
<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_URL %>/watch?v=<%= video.id %>">
|
<meta property="og:url" content="<%= host %>/watch?v=<%= video.id %>">
|
||||||
<meta property="og:title" content="<%= title %>">
|
<meta property="og:title" content="<%= title %>">
|
||||||
<meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
|
<meta property="og:image" content="<%= host %>/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">
|
||||||
<meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
<!-- This shouldn't be empty, ever. -->
|
||||||
<meta property="og:video:secure_url" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
<meta property="og:video" content="<%= video_url %>">
|
||||||
<meta property="og:video:type" content="text/html">
|
<meta property="og:video:url" content="<%= video_url %>">
|
||||||
<meta property="og:video:width" content="1280">
|
<meta property="og:video:secure_url" content="<%= video_url %>">
|
||||||
<meta property="og:video:height" content="720">
|
<meta property="og:video:type" content="video/mp4">
|
||||||
|
<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_URL %>/watch?v=<%= video.id %>">
|
<meta name="twitter:url" content="<%= host %>/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_URL %>/vi/<%= video.id %>/maxres.jpg">
|
<meta name="twitter:image" content="<%= host %>/vi/<%= video.id %>/maxres.jpg">
|
||||||
<meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>">
|
<meta name="twitter:player" content="<%= host %>/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 %>">
|
||||||
|
@ -172,7 +175,7 @@ we're going to need to do it here in order to allow for translations.
|
||||||
<% if user %>
|
<% if user %>
|
||||||
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
|
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
|
||||||
<% if !playlists.empty? %>
|
<% if !playlists.empty? %>
|
||||||
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank">
|
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" method="post" target="_blank">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
|
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
|
||||||
<select style="width:100%" name="playlist_id" id="playlist_id">
|
<select style="width:100%" name="playlist_id" id="playlist_id">
|
||||||
|
@ -183,7 +186,6 @@ we're going to need to do it here in order to allow for translations.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
<input type="hidden" name="action_add_video" value="1">
|
|
||||||
<input type="hidden" name="video_id" value="<%= video.id %>">
|
<input type="hidden" name="video_id" value="<%= video.id %>">
|
||||||
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
|
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
|
||||||
<b><%= translate(locale, "Add to playlist") %></b>
|
<b><%= translate(locale, "Add to playlist") %></b>
|
||||||
|
|
|
@ -1,3 +1,51 @@
|
||||||
|
# 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"
|
||||||
|
@ -13,67 +61,56 @@ def add_yt_headers(request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
struct YoutubeConnectionPool
|
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
|
||||||
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 == "www.youtube.com"
|
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
|
||||||
client.read_timeout = 10.seconds
|
client.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, &)
|
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
|
||||||
client = make_client(url, region, force_resolve)
|
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
|
||||||
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,6 +21,7 @@ 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)
|
||||||
|
@ -66,6 +67,8 @@ private module Parsers
|
||||||
author_id = author_fallback.id
|
author_id = author_fallback.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
author_thumbnail = item_contents.dig?("channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail", "thumbnails", 0, "url").try &.as_s
|
||||||
|
|
||||||
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
|
author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
|
||||||
|
|
||||||
# For live videos (and possibly recently premiered videos) there is no published information.
|
# For live videos (and possibly recently premiered videos) there is no published information.
|
||||||
|
@ -147,6 +150,7 @@ private module Parsers
|
||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
premiere_timestamp: premiere_timestamp,
|
premiere_timestamp: premiere_timestamp,
|
||||||
author_verified: author_verified,
|
author_verified: author_verified,
|
||||||
|
author_thumbnail: author_thumbnail,
|
||||||
badges: badges,
|
badges: badges,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
@ -467,9 +471,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 videoRenderer, used
|
# A richItemRenderer seems to be a simple wrapper for a various other types,
|
||||||
# by the result page for hashtags and for the podcast tab on channels.
|
# used on the hashtags result page and the channel podcast tab. It is located
|
||||||
# It is located inside a continuationItems container for hashtags.
|
# itself inside a richGridRenderer container.
|
||||||
#
|
#
|
||||||
module RichItemRendererParser
|
module RichItemRendererParser
|
||||||
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||||
|
@ -482,6 +486,8 @@ 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
|
||||||
|
|
||||||
|
@ -496,6 +502,9 @@ 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"]?
|
||||||
|
@ -573,6 +582,137 @@ private module Parsers
|
||||||
length_seconds: duration,
|
length_seconds: duration,
|
||||||
premiere_timestamp: Time.unix(0),
|
premiere_timestamp: Time.unix(0),
|
||||||
author_verified: false,
|
author_verified: false,
|
||||||
|
author_thumbnail: nil,
|
||||||
|
badges: VideoBadges::None,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parser_name
|
||||||
|
return {{@type.name}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
|
||||||
|
# Returns nil when the given object is not a lockupViewModel.
|
||||||
|
#
|
||||||
|
# This structure is present since November 2024 on the "podcasts" and
|
||||||
|
# "playlists" tabs of the channel page. It is usually encapsulated in either
|
||||||
|
# a richItemRenderer or a richGridRenderer.
|
||||||
|
#
|
||||||
|
module LockupViewModelParser
|
||||||
|
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||||
|
if item_contents = item["lockupViewModel"]?
|
||||||
|
return self.parse(item_contents, author_fallback)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.parse(item_contents, author_fallback)
|
||||||
|
playlist_id = item_contents["contentId"].as_s
|
||||||
|
|
||||||
|
thumbnail_view_model = item_contents.dig(
|
||||||
|
"contentImage", "collectionThumbnailViewModel",
|
||||||
|
"primaryThumbnail", "thumbnailViewModel"
|
||||||
|
)
|
||||||
|
|
||||||
|
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
|
||||||
|
|
||||||
|
# This complicated sequences tries to extract the following data structure:
|
||||||
|
# "overlays": [{
|
||||||
|
# "thumbnailOverlayBadgeViewModel": {
|
||||||
|
# "thumbnailBadges": [{
|
||||||
|
# "thumbnailBadgeViewModel": {
|
||||||
|
# "text": "430 episodes",
|
||||||
|
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
|
||||||
|
# }
|
||||||
|
# }]
|
||||||
|
# }
|
||||||
|
# }]
|
||||||
|
#
|
||||||
|
# NOTE: this simplistic `.to_i` conversion might not work on larger
|
||||||
|
# playlists and hasn't been tested.
|
||||||
|
video_count = thumbnail_view_model.dig("overlays").as_a
|
||||||
|
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
|
||||||
|
.flatten
|
||||||
|
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
|
||||||
|
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
|
||||||
|
})
|
||||||
|
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
|
||||||
|
|
||||||
|
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
|
||||||
|
title = metadata.dig("title", "content").as_s
|
||||||
|
|
||||||
|
# TODO: Retrieve "updated" info from metadata parts
|
||||||
|
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
|
||||||
|
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
|
||||||
|
# One of these parts should contain a string like: "Updated 2 days ago"
|
||||||
|
|
||||||
|
# TODO: Maybe add a button to access the first video of the playlist?
|
||||||
|
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
||||||
|
# Available fields: "videoId", "playlistId", "params"
|
||||||
|
|
||||||
|
return SearchPlaylist.new({
|
||||||
|
title: title,
|
||||||
|
id: playlist_id,
|
||||||
|
author: author_fallback.name,
|
||||||
|
ucid: author_fallback.id,
|
||||||
|
video_count: video_count || -1,
|
||||||
|
videos: [] of SearchPlaylistVideo,
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
author_verified: false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parser_name
|
||||||
|
return {{@type.name}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses an InnerTube shortsLockupViewModel into a SearchVideo.
|
||||||
|
# Returns nil when the given object is not a shortsLockupViewModel.
|
||||||
|
#
|
||||||
|
# This structure is present since around October 2024 on the "shorts" tab of
|
||||||
|
# the channel page and likely replaces the reelItemRenderer structure. It is
|
||||||
|
# usually (always?) encapsulated in a richItemRenderer.
|
||||||
|
#
|
||||||
|
module ShortsLockupViewModelParser
|
||||||
|
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
|
||||||
|
if item_contents = item["shortsLockupViewModel"]?
|
||||||
|
return self.parse(item_contents, author_fallback)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.parse(item_contents, author_fallback)
|
||||||
|
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
|
||||||
|
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
|
||||||
|
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
|
||||||
|
|
||||||
|
video_id = item_contents.dig(
|
||||||
|
"onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"
|
||||||
|
).as_s
|
||||||
|
|
||||||
|
title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s
|
||||||
|
|
||||||
|
view_count = short_text_to_number(
|
||||||
|
item_contents.dig("overlayMetadata", "secondaryText", "content").as_s
|
||||||
|
)
|
||||||
|
|
||||||
|
# Approximate to one minute, as "shorts" generally don't exceed that.
|
||||||
|
# NOTE: The actual duration is not provided by Youtube anymore.
|
||||||
|
# TODO: Maybe use -1 as an error value and handle that on the frontend?
|
||||||
|
duration = 60_i32
|
||||||
|
|
||||||
|
SearchVideo.new({
|
||||||
|
title: title,
|
||||||
|
id: video_id,
|
||||||
|
author: author_fallback.name,
|
||||||
|
ucid: author_fallback.id,
|
||||||
|
published: Time.unix(0),
|
||||||
|
views: view_count,
|
||||||
|
description_html: "",
|
||||||
|
length_seconds: duration,
|
||||||
|
premiere_timestamp: Time.unix(0),
|
||||||
|
author_verified: false,
|
||||||
|
author_thumbnail: nil,
|
||||||
badges: VideoBadges::None,
|
badges: VideoBadges::None,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
@ -889,7 +1029,7 @@ end
|
||||||
def extract_items(
|
def extract_items(
|
||||||
initial_data : InitialData,
|
initial_data : InitialData,
|
||||||
author_fallback : String? = nil,
|
author_fallback : String? = nil,
|
||||||
author_id_fallback : String? = nil
|
author_id_fallback : String? = nil,
|
||||||
) : {Array(SearchItem), String?}
|
) : {Array(SearchItem), String?}
|
||||||
items = [] of SearchItem
|
items = [] of SearchItem
|
||||||
continuation = nil
|
continuation = nil
|
||||||
|
|
|
@ -202,7 +202,7 @@ module YoutubeAPI
|
||||||
def initialize(
|
def initialize(
|
||||||
*,
|
*,
|
||||||
@client_type = ClientType::Web,
|
@client_type = ClientType::Web,
|
||||||
@region = "US"
|
@region = "US",
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -361,7 +361,7 @@ module YoutubeAPI
|
||||||
browse_id : String,
|
browse_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,
|
||||||
)
|
)
|
||||||
# JSON Request data, required by the API
|
# JSON Request data, required by the API
|
||||||
data = {
|
data = {
|
||||||
|
@ -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,7 +491,11 @@ module YoutubeAPI
|
||||||
data["params"] = params
|
data["params"] = params
|
||||||
end
|
end
|
||||||
|
|
||||||
return self._post_json("/youtubei/v1/player", data, client_config)
|
if CONFIG.invidious_companion.present?
|
||||||
|
return self._post_invidious_companion("/youtubei/v1/player", data)
|
||||||
|
else
|
||||||
|
return self._post_json("/youtubei/v1/player", data, client_config)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
####################################################################
|
####################################################################
|
||||||
|
@ -548,7 +552,7 @@ module YoutubeAPI
|
||||||
def search(
|
def search(
|
||||||
search_query : String,
|
search_query : String,
|
||||||
params : String,
|
params : String,
|
||||||
client_config : ClientConfig | Nil = nil
|
client_config : ClientConfig | Nil = nil,
|
||||||
)
|
)
|
||||||
# JSON Request data, required by the API
|
# JSON Request data, required by the API
|
||||||
data = {
|
data = {
|
||||||
|
@ -574,7 +578,7 @@ module YoutubeAPI
|
||||||
|
|
||||||
def get_transcript(
|
def get_transcript(
|
||||||
params : String,
|
params : String,
|
||||||
client_config : ClientConfig | Nil = nil
|
client_config : ClientConfig | Nil = nil,
|
||||||
) : Hash(String, JSON::Any)
|
) : Hash(String, JSON::Any)
|
||||||
data = {
|
data = {
|
||||||
"context" => self.make_context(client_config),
|
"context" => self.make_context(client_config),
|
||||||
|
@ -596,7 +600,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
|
||||||
|
@ -628,6 +632,11 @@ 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
|
||||||
|
@ -652,6 +661,51 @@ 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)
|
||||||
#
|
#
|
||||||
|
@ -670,7 +724,7 @@ module YoutubeAPI
|
||||||
# Multiple encodings can be combined, and are listed in the order
|
# Multiple encodings can be combined, and are listed in the order
|
||||||
# in which they were applied. E.g: "deflate, gzip" means that the
|
# in which they were applied. E.g: "deflate, gzip" means that the
|
||||||
# content must be first "gunzipped", then "defated".
|
# content must be first "gunzipped", then "defated".
|
||||||
encodings.split(',').reverse.each do |enc|
|
encodings.split(',').reverse!.each do |enc|
|
||||||
case enc.strip(' ')
|
case enc.strip(' ')
|
||||||
when "gzip"
|
when "gzip"
|
||||||
body_io = Compress::Gzip::Reader.new(body_io, sync_close: true)
|
body_io = Compress::Gzip::Reader.new(body_io, sync_close: true)
|
||||||
|
|
Loading…
Add table
Reference in a new issue