forked from Fijxu/invidious
Compare commits
387 commits
Author | SHA1 | Date | |
---|---|---|---|
deb1187ea9 | |||
4a0e61812e | |||
ad95f0e2c0 | |||
e13800e859 | |||
69e351770d | |||
3e33c9b70f | |||
0ce17d91eb | |||
c9eed028b0 | |||
ff3d008a6f | |||
49ae71a6ac | |||
fac53ce721 | |||
b4e146fb60 | |||
d7aeb1a89f | |||
7b072200f6 | |||
bbec111997 | |||
e3d60a0517 | |||
ce052103e7 | |||
c57a4f4920 | |||
426e7bfbdb | |||
3d85519ec9 | |||
a74d89b6d9 | |||
fd8c40e0da | |||
5f1944925b | |||
015c9ec5d1 | |||
be9a3794e9 | |||
642b2e8bf0 | |||
ce97a41301 | |||
b29f5b39de | |||
895745934b | |||
d47aa3dd6a | |||
ddf6802d76 | |||
626fb2d1a8 | |||
56f309d6bb | |||
|
23ff6135bb | ||
7c9f79e1f1 | |||
7aba1f7ba3 | |||
8be23eb01d | |||
a44f37563b | |||
facd01b52e | |||
|
409d12a81e | ||
db53ee21ee | |||
|
70ff463cc6 | ||
|
e23d0d13be | ||
|
5c8b4eb379 | ||
a4cb5f094c | |||
01ccd55829 | |||
8fe965419a | |||
fda823593e | |||
24e66231df | |||
1001a72297 | |||
e5c0f15398 | |||
ecacbab2a5 | |||
bceb7a61ef | |||
|
f3d982a885 | ||
27fecf3879 | |||
50fa7de901 | |||
|
adcdb8cb92 | ||
|
fe4fa0480a | ||
|
dbbcacc955 | ||
|
58ad848d56 | ||
|
f9b9e85ee4 | ||
|
6ac74f4362 | ||
|
9fbe3944b0 | ||
|
c5e9447f41 | ||
|
3e329410d1 | ||
|
74dfda150e | ||
|
e60f53154e | ||
|
3d77635a5c | ||
|
d0433c8386 | ||
|
4ea4878d1a | ||
|
1f0a89fb5f | ||
|
f95f87e448 | ||
e76867aaba | |||
62cc10d2ca | |||
9f475c1f2a | |||
b3e24c703d | |||
d8ecfd9c04 | |||
|
49afbf2a14 | ||
7fcee35cb1 | |||
99224b9f60 | |||
e0d4272070 | |||
33a1473522 | |||
|
d853b9f6dc | ||
d70681538a | |||
bbc5913b8d | |||
1df1945849 | |||
b08f7ab7ac | |||
7c91d5f194 | |||
fc8d843082 | |||
331e0a1532 | |||
089c63a70b | |||
aff5fbb1aa | |||
|
e2df12b7d6 | ||
|
29219c46a1 | ||
|
a77f083a0a | ||
|
eaf47385c5 | ||
d75d860888 | |||
f68d7bcbc7 | |||
|
1fb8d3f583 | ||
|
26b15d6e35 | ||
|
786e3e0550 | ||
|
104553fdc4 | ||
|
ae670d5b2d | ||
|
b2c14f1a2a | ||
|
b899bc959e | ||
|
74dc6795cd | ||
|
5404b67bef | ||
|
7b59ccf645 | ||
|
cc6c39d0e6 | ||
|
37f3c285d7 | ||
|
106086c766 | ||
|
0980867d42 | ||
|
3abc377d56 | ||
|
4a0a6f7ed5 | ||
|
3056e1767e | ||
|
0846faa6f6 | ||
|
943c42e47b | ||
|
fc7b5120db | ||
|
d4d6a4b172 | ||
|
e0cb54f7e0 | ||
|
844e1bdf43 | ||
|
aacfbb09da | ||
|
f57b4b5e4f | ||
|
b1422b7434 | ||
|
f56e4012fe | ||
|
7d5b2ec7b6 | ||
|
cad64e420c | ||
|
f181ae3cb0 | ||
|
0fd480bae2 | ||
|
afb0aad7d3 | ||
|
6816ded0fa | ||
|
0546a73bfa | ||
|
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 | ||
|
b4a6193642 | ||
25c3153f4c | |||
4a8fffac26 | |||
ebd89acf79 | |||
3bb7924181 | |||
30c0b5059d | |||
391659780d | |||
f248024b65 | |||
|
525dea1e2a | ||
|
f9885cca8e | ||
df94f1c0b8 | |||
|
047ead8080 | ||
83256b2af1 | |||
31219ce196 | |||
a01c8c63d3 | |||
58c4d8c951 | |||
91bcec72c8 | |||
|
275318dae2 | ||
a63300e284 | |||
|
48d2250024 | ||
|
5f8130fd03 | ||
|
b4e930f3bc | ||
|
d7f5cdc2f9 | ||
79859100a8 | |||
|
04b0742293 | ||
|
1838ac4c99 | ||
|
8729f01075 | ||
|
6dd89bd401 | ||
|
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 | |||
|
3850739d7f | ||
|
9d91ac3b88 | ||
|
5d0149844f | ||
|
75b68618ab | ||
|
003c6f81dc | ||
|
4bc77b81bf | ||
|
06e1a508e8 | ||
|
52bc9aa328 | ||
|
480e073fa9 | ||
|
6e39b9b303 | ||
|
46c58bd84c | ||
|
7521902e88 | ||
|
bd48af825c | ||
|
ee89db49ba | ||
|
3af6681869 | ||
|
1124dd645d | ||
|
b526f48120 | ||
|
e8cd631b2d | ||
|
69ff6def5f | ||
|
26dc9dc99c | ||
|
2d6b46c926 | ||
|
cab02d4959 | ||
|
5f590dda80 | ||
|
b2f5b1eb68 | ||
|
7693f61e44 | ||
|
7214fdaff4 | ||
|
c24ed85110 | ||
|
288e1dccda | ||
|
6b7e730100 | ||
|
ccb2a6c58e | ||
|
3b471ae964 | ||
|
eb8fcc9e88 | ||
|
7b7197cde8 | ||
|
3c6019edd0 | ||
|
70754659e5 | ||
|
8d4c16c79c | ||
|
9db6eb058c | ||
|
e7f7f39ce8 | ||
|
8456f8d4cd | ||
|
27dd94f60d | ||
|
4d410d124f | ||
|
6861148290 | ||
|
03f9962a47 | ||
|
d098e5ae9b | ||
|
4c486634e2 | ||
|
3bced4e12b | ||
|
0d22af6564 | ||
|
2a6a32e667 | ||
|
50da6cf3e7 | ||
|
7388e4ca72 | ||
|
be216fff94 | ||
|
019807256f | ||
|
a0d24190b8 | ||
|
2b2d67fcfa | ||
|
76369eb599 | ||
|
6236cea33e | ||
|
e8c2388589 | ||
|
995df2d296 | ||
|
c0d75bc52f | ||
|
e307fcc9a1 | ||
|
bae8bab3ff | ||
|
fa59f41f7b | ||
|
20ca1ebcc0 | ||
|
b0b4f09b3a | ||
|
48af0af9d5 | ||
|
f9460e31bc | ||
|
b7a252b096 | ||
|
6b929da0e1 | ||
|
21122db3a7 | ||
|
c9a843c7fe | ||
|
275501aad3 | ||
|
5cdbc184c7 | ||
|
9996d00cb1 | ||
|
9a617ae087 | ||
|
c257882a1f | ||
|
58bad6180f | ||
|
509bace7d1 | ||
|
07c52cba3d | ||
|
04ba7b0d58 | ||
|
4788a3b4a9 | ||
|
7fe2af735d | ||
|
905582db66 | ||
|
78773d7326 |
147 changed files with 3774 additions and 1400 deletions
|
@ -1,48 +1,56 @@
|
||||||
name: 'Invidious CI'
|
name: "Invidious CI"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# workflow_dispatch:
|
workflow_dispatch:
|
||||||
# inputs: {}
|
# schedule:
|
||||||
schedule:
|
# - cron: '0 7 * * 0'
|
||||||
- cron: '0 7 * * 0'
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
|
paths-ignore:
|
||||||
|
- "*.md"
|
||||||
|
- LICENCE
|
||||||
|
- TRANSLATION
|
||||||
|
- invidious.service
|
||||||
|
- .git*
|
||||||
|
- .editorconfig
|
||||||
|
- screenshots/*
|
||||||
|
- .github/ISSUE_TEMPLATE/*
|
||||||
|
- kubernetes/**
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: runner
|
runs-on: runner
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v2
|
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
||||||
name: Setup Docker BuildX system
|
name: Setup Docker BuildX system
|
||||||
|
|
||||||
- name: Login to Docker Container Registry
|
- name: Login to Docker Container Registry
|
||||||
uses: https://code.forgejo.org/docker/login-action@v3.1.0
|
uses: https://code.forgejo.org/docker/login-action@v3.1.0
|
||||||
with:
|
with:
|
||||||
registry: git.nadeko.net
|
registry: git.nadeko.net
|
||||||
username: ${{ secrets.USERNAME }}
|
username: ${{ secrets.USERNAME }}
|
||||||
password: ${{ secrets.TOKEN }}
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: https://github.com/docker/metadata-action@v5
|
uses: https://github.com/docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: git.nadeko.net/fijxu/invidious
|
images: git.nadeko.net/fijxu/invidious
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||||
|
|
||||||
- uses: https://code.forgejo.org/docker/build-push-action@v5
|
|
||||||
name: Build images
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/Dockerfile
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
"release=1"
|
|
||||||
|
|
||||||
|
- uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||||
|
name: Build images
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
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"
|
||||||
|
|
205
CHANGELOG.md
205
CHANGELOG.md
|
@ -2,9 +2,194 @@
|
||||||
|
|
||||||
## vX.Y.0 (future)
|
## vX.Y.0 (future)
|
||||||
|
|
||||||
|
## v2.20250314.0
|
||||||
|
|
||||||
|
### Wrap-up
|
||||||
|
|
||||||
|
This release brings the long awaited feature of supporting multiple audio tracks in a video, some bug fixes and UX improvements, and many other things primarily oriented to self-hosting instances, and developers using the API.
|
||||||
|
|
||||||
|
The `Community` channel tab has been replaced by `Posts` in light of YouTube changes, but the URL remains the same.
|
||||||
|
|
||||||
|
Tamil is now available as an interface language
|
||||||
|
|
||||||
|
Automatic instance redirects will no longer have the chance to annoyingly redirect to the same instance you're on.
|
||||||
|
|
||||||
|
Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
|
||||||
|
|
||||||
|
Invidious is now able to listen through a UNIX socket
|
||||||
|
|
||||||
|
User notifications are now batched for each channel
|
||||||
|
|
||||||
|
**The minimum Crystal version supported by Invidious now `1.12.0`**
|
||||||
|
|
||||||
|
### New features & important changes
|
||||||
|
|
||||||
|
#### For users
|
||||||
|
|
||||||
|
* Invidious now supports videos with multiple audio tracks allowing you to select which one you want to hear with!
|
||||||
|
* Channel pages now have a proper previous page button
|
||||||
|
* RSS feeds for channels will no longer contain the channel's profile picture
|
||||||
|
* Support for channel `courses` page has been added
|
||||||
|
* `Community` tabs has been replaced with `Posts` to comply with YouTube changes
|
||||||
|
* Tamil is now an available interface language.
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
* Invidious is now able to listen on a UNIX socket
|
||||||
|
* User notifications are now batched by channels, significantly reducing database load.
|
||||||
|
* **`1.12.0` is now the oldest Crystal version that Invidious supports**
|
||||||
|
* The example config will no longer force an http proxy to be configured
|
||||||
|
* Invidious will now warn when any top-level config option must be set to a custom value, instead of just `HMAC_KEY`
|
||||||
|
* Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
|
||||||
|
|
||||||
|
#### For developers
|
||||||
|
* Invidious is now compliant to Crystal 1.15 formatting rules, which are incompatible with earlier versions.
|
||||||
|
* `/api/v1/transcripts/{id}` has been added to the API to allow for fetching the transcripts for a video. The arguments are the same as the captions endpoint.
|
||||||
|
* `author_thumbnail` field has been added to videos in the various paged api endpoints
|
||||||
|
* `published` field has been added to the API response for a video's related videos.
|
||||||
|
* Docker builds now uses the Crystal compiler cache, reducing build times on repeated builds significantly.
|
||||||
|
* Invidious ajax action handlers has undergone a clean up and may face compatibility issues with code that depends on these endpoints.
|
||||||
|
* The versions of Crystal that we test in CI/CD are now: `1.12.1`, `1.13.2`, `1.14.0`, `1.15.0`
|
||||||
|
|
||||||
|
### Bugs fixed
|
||||||
|
|
||||||
|
#### User-side
|
||||||
|
* Local video listen mode is now preserved when clicking on a video in the sidebar playlist widget
|
||||||
|
* Automatic instance redirects will no longer redirect to the same instance the user is on
|
||||||
|
* Fix some thumbnails responses returning 404
|
||||||
|
* Videos: Fix missing host parameter on playback URLs when `local=true`
|
||||||
|
* Fix HLS being used for non-livestream videos
|
||||||
|
* Fix timeupdate event errors when required elements are missing
|
||||||
|
* User: Ensure IO is properly closed when importing NewPipe subscriptions
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
* Fix http proxy configuration being forced by the standard example config
|
||||||
|
|
||||||
|
#### API
|
||||||
|
* `/api/v1/videos/{id}` will no longer return an occasional empty JSON response
|
||||||
|
|
||||||
|
### Full list of pull requests merged since the last release (newest first)
|
||||||
|
* Make Invidious compliant to Crystal 1.15 formatting rules (https://github.com/iv-org/invidious/pull/5014, by @syeopite)
|
||||||
|
* Remove formatter check on container workflows (https://github.com/iv-org/invidious/pull/5153, by @syeopite)
|
||||||
|
* Videos: Fix missing host parameter on playback URLs when `local=true` (https://github.com/iv-org/invidious/pull/4992, by @SamantazFox)
|
||||||
|
* Remove stdlib override for proxy initialization (https://github.com/iv-org/invidious/pull/5065, by @syeopite)
|
||||||
|
* Add support for author thumbnails in search api for videos (https://github.com/iv-org/invidious/pull/5072, thanks @ChunkyProgrammer)
|
||||||
|
* Skip route if resp got closed by before handlers (https://github.com/iv-org/invidious/pull/5073, by @syeopite)
|
||||||
|
* Fix video thumbnails in mixes (https://github.com/iv-org/invidious/pull/5116, thanks @iBicha)
|
||||||
|
* CI: Drop support for versions prior to 1.12 and add 1.15.0 (https://github.com/iv-org/invidious/pull/5148, by @syeopite)
|
||||||
|
* [Continuing #5094] Set language info for dash audio streams and sort (https://github.com/iv-org/invidious/pull/5149, thanks @giuliano-macedo)
|
||||||
|
* Warn when any top-level config is "CHANGE_ME!!" (https://github.com/iv-org/invidious/pull/5150, by @syeopite)
|
||||||
|
* Comment out http_proxy in example config (https://github.com/iv-org/invidious/pull/5151, by @syeopite)
|
||||||
|
* API: Add a 'published' video parameter for related videos (https://github.com/iv-org/invidious/pull/4149, thanks @RadoslavL)
|
||||||
|
* Ensure IO is properly closed when importing NewPipe subscriptions (https://github.com/iv-org/invidious/pull/4346, thanks @ChunkyProgrammer)
|
||||||
|
* Carry over audio-only mode in playlist links (https://github.com/iv-org/invidious/pull/4784, thanks @krystof1119)
|
||||||
|
* Routes: Clean ajax actions handlers (https://github.com/iv-org/invidious/pull/5036, by @SamantazFox)
|
||||||
|
* Frontend: Add a first page and previous page buttons for channel navigation (https://github.com/iv-org/invidious/pull/4123, thanks @RadoslavL)
|
||||||
|
* RSS: Channel + Playlist improvements (https://github.com/iv-org/invidious/pull/4298, thanks @ChunkyProgrammer)
|
||||||
|
* Batch user notifications together (https://github.com/iv-org/invidious/pull/4486, thanks @999eagle)
|
||||||
|
* JS: Update timeupdate event making it more defensive to prevent errors (https://github.com/iv-org/invidious/pull/4782, thanks @PMK)
|
||||||
|
* Add API endpoint for fetching transcripts from YouTube by (https://github.com/iv-org/invidious/pull/4788, by @syeopite)
|
||||||
|
* Translations update from Hosted Weblate by (https://github.com/iv-org/invidious/pull/4989, thanks to our many translators)
|
||||||
|
* Add the ability to listen on UNIX sockets (https://github.com/iv-org/invidious/pull/5112, thanks @Caian)
|
||||||
|
* Pick a different instance upon redirect (https://github.com/iv-org/invidious/pull/5154, thanks @epicsam123)
|
||||||
|
* Add Courses to channel page and channel API (https://github.com/iv-org/invidious/pull/5158, thanks @ChunkyProgrammer)
|
||||||
|
* fix /api/v1/videos/:id returns 200 with no content (https://github.com/iv-org/invidious/pull/5162, thanks @Drikanis)
|
||||||
|
* Use Crystal compiler cache in docker builds (https://github.com/iv-org/invidious/pull/5163, by @syeopite)
|
||||||
|
* Channels: Fix community tab by (https://github.com/iv-org/invidious/pull/5183, thanks @Fijxu)
|
||||||
|
* Fix typo in `src/invidious/routes/images.cr` (https://github.com/iv-org/invidious/pull/5184, by @syeopite)
|
||||||
|
* Fix an issue with the HLS manifest check for livestream videos (https://github.com/iv-org/invidious/pull/5189, thanks @alexmaras)
|
||||||
|
* Warn when `po_token`, `visitor_data` and/or `inv-sig-helper` is not configured (https://github.com/iv-org/invidious/pull/5202, by @syeopite)
|
||||||
|
## v2.20241110.0
|
||||||
|
|
||||||
|
### Wrap-up
|
||||||
|
|
||||||
|
This release is most importantly here to fix to the annoying "Youtube API returned error 400"
|
||||||
|
error that prevented all channel pages from loading.
|
||||||
|
|
||||||
|
If you're updating from the previous release, it provides no improvements on the ability to play
|
||||||
|
videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
|
||||||
|
by a previous attempt at restoring video playback on large instances.
|
||||||
|
|
||||||
|
In the preferences, a new option allows for control of video preload. When enabled, this option
|
||||||
|
tells the browser to load the video as soon as the page is loaded (this used to be the default).
|
||||||
|
When disabled, the video starts loading only when the "play" button is pressed.
|
||||||
|
|
||||||
|
New interface languages available: Bulgarian, Welsh and Lombard
|
||||||
|
|
||||||
|
New dependency required: `tzdata`.
|
||||||
|
|
||||||
|
An HTTP proxy can be configured directly in Invidious, if needed. \
|
||||||
|
**NOTE:** In that case, it is recommended to comment out `force_resolve`.
|
||||||
|
|
||||||
|
|
||||||
|
### New features & important changes
|
||||||
|
|
||||||
|
#### For users
|
||||||
|
|
||||||
|
* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
|
||||||
|
* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
|
||||||
|
* Preferences: Addition of the new "preload" option
|
||||||
|
* New interface languages available: Bulgarian, Welsh and Lombard
|
||||||
|
* Added "Filipino (auto-generated)" to the list of caption languages available
|
||||||
|
* Lots of new translations from Weblate
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
|
||||||
|
* Allow the configuration of an HTTP proxy to talk to Youtube
|
||||||
|
* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
|
||||||
|
* The instance list is downloaded in the background to improve redirection speed
|
||||||
|
* New `colorize_logs` option makes each log level a different color
|
||||||
|
|
||||||
|
#### For developpers
|
||||||
|
|
||||||
|
* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
|
||||||
|
`newest`, `oldest` and `popular`
|
||||||
|
* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
|
||||||
|
* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
|
||||||
|
`is3d` and `hasCaptions`
|
||||||
|
|
||||||
|
### Bugs fixed
|
||||||
|
|
||||||
|
#### User-side
|
||||||
|
|
||||||
|
* Channels: The second page of shorts now loads as expected
|
||||||
|
* Channels: Fixed intermittent empty "playlists" tab
|
||||||
|
* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
|
||||||
|
* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
|
||||||
|
* Switching to another instance is much faster
|
||||||
|
* Fixed an "invalid byte sequence" error when subscribing to a playlist
|
||||||
|
* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
|
||||||
|
|
||||||
|
#### For instance owners
|
||||||
|
|
||||||
|
* Fix `force_resolve` being ignored in some cases
|
||||||
|
|
||||||
|
#### API
|
||||||
|
|
||||||
|
* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
|
||||||
|
|
||||||
|
|
||||||
### Full list of pull requests merged since the last release (newest first)
|
### Full list of pull requests merged since the last release (newest first)
|
||||||
|
|
||||||
|
* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha)
|
||||||
|
* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox)
|
||||||
|
* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox)
|
||||||
|
* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox)
|
||||||
|
* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox)
|
||||||
|
* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue)
|
||||||
|
* Stale bot updates ([#5060], thanks @syeopite)
|
||||||
|
* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox)
|
||||||
|
* Channels: Fix for live videos ([#5027], thanks @iBicha)
|
||||||
|
* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox)
|
||||||
|
* Shards: Update database dependencies ([#5034], by @SamantazFox)
|
||||||
|
* Logger: Add color support for different log levels ([#4931], thanks @Fijxu)
|
||||||
|
* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite)
|
||||||
|
* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite)
|
||||||
|
* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox)
|
||||||
|
* 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 +207,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 +222,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
|
||||||
|
|
224
README.md
224
README.md
|
@ -1,170 +1,62 @@
|
||||||
<div align="center">
|
# nadeko.net Invidious fork
|
||||||
<img src="assets/invidious-colored-vector.svg" width="192" height="192" alt="Invidious logo">
|
|
||||||
<h1>Invidious</h1>
|
|
||||||
|
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
|
This is a fork of Invidious with features that I have done for my own instance. If you want to maintain an instance, feel free to use this fork and it's container images (they are also compatible with Podman, not just docker!)
|
||||||
<img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/iv-org/invidious/actions">
|
|
||||||
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/iv-org/invidious/commits/master">
|
|
||||||
<img alt="GitHub commits" src="https://img.shields.io/github/commit-activity/y/iv-org/invidious?color=red&label=commits">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/iv-org/invidious/issues">
|
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/iv-org/invidious?color=important">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/iv-org/invidious/pulls">
|
|
||||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/iv-org/invidious?color=blueviolet">
|
|
||||||
</a>
|
|
||||||
<a href="https://hosted.weblate.org/engage/invidious/">
|
|
||||||
<img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://github.com/humanetech-community/awesome-humane-tech">
|
https://git.nadeko.net/Fijxu/-/packages/container/invidious/latest
|
||||||
<img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h3>An open source alternative front-end to YouTube</h3>
|
> [!CAUTION]
|
||||||
|
> If you already have an Invidious instance running the upstream code, moving it to this fork will not work for you!
|
||||||
<a href="https://invidious.io/">Website</a>
|
> This is due to the "Removal of materialized views on PostgreSQL" pull request that requires a migration of the database
|
||||||
•
|
> using it.
|
||||||
<a href="https://instances.invidious.io/">Instances list</a>
|
|
||||||
•
|
|
||||||
<a href="https://docs.invidious.io/faq/">FAQ</a>
|
|
||||||
•
|
|
||||||
<a href="https://docs.invidious.io/">Documentation</a>
|
|
||||||
•
|
|
||||||
<a href="#contribute">Contribute</a>
|
|
||||||
•
|
|
||||||
<a href="https://invidious.io/donate/">Donate</a>
|
|
||||||
|
|
||||||
<h5>Chat with us:</h5>
|
|
||||||
<a href="https://matrix.to/#/#invidious:matrix.org">
|
|
||||||
<img alt="Matrix" src="https://img.shields.io/matrix/invidious:matrix.org?label=Matrix&color=darkgreen">
|
|
||||||
</a>
|
|
||||||
<a href="https://web.libera.chat/?channel=#invidious">
|
|
||||||
<img alt="Libera.chat (IRC)" src="https://img.shields.io/badge/IRC%20%28Libera.chat%29-%23invidious-darkgreen">
|
|
||||||
</a>
|
|
||||||
<br>
|
|
||||||
<a rel="me" href="https://social.tchncs.de/@invidious">
|
|
||||||
<img alt="Fediverse: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Fediverse-%40invidious%40social.tchncs.de-darkgreen">
|
|
||||||
</a>
|
|
||||||
<br>
|
|
||||||
<a href="https://invidious.io/contact/">
|
|
||||||
<img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
| Player | Preferences | Subscriptions |
|
|
||||||
|-------------------------------------|-------------------------------------|---------------------------------------|
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
**User features**
|
|
||||||
- Lightweight
|
|
||||||
- No ads
|
|
||||||
- No tracking
|
|
||||||
- No JavaScript required
|
|
||||||
- Light/Dark themes
|
|
||||||
- Customizable homepage
|
|
||||||
- Subscriptions independent from Google
|
|
||||||
- Notifications for all subscribed channels
|
|
||||||
- Audio-only mode (with background play on mobile)
|
|
||||||
- Support for Reddit comments
|
|
||||||
- [Available in many languages](locales/), thanks to [our translators](#contribute)
|
|
||||||
|
|
||||||
**Data import/export**
|
|
||||||
- Import subscriptions from YouTube, NewPipe and Freetube
|
|
||||||
- Import watch history from YouTube and NewPipe
|
|
||||||
- Export subscriptions to NewPipe and Freetube
|
|
||||||
- Import/Export Invidious user data
|
|
||||||
|
|
||||||
**Technical features**
|
|
||||||
- Embedded video support
|
|
||||||
- [Developer API](https://docs.invidious.io/api/)
|
|
||||||
- Does not use official YouTube APIs
|
|
||||||
- No Contributor License Agreement (CLA)
|
|
||||||
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
**Using invidious:**
|
|
||||||
|
|
||||||
- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now!
|
|
||||||
|
|
||||||
**Hosting invidious:**
|
|
||||||
|
|
||||||
- [Follow the installation instructions](https://docs.invidious.io/installation/)
|
|
||||||
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
The full documentation can be accessed online at https://docs.invidious.io/
|
|
||||||
|
|
||||||
The documentation's source code is available in this repository:
|
|
||||||
https://github.com/iv-org/documentation
|
|
||||||
|
|
||||||
### Extensions
|
|
||||||
|
|
||||||
We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get),
|
|
||||||
a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
|
|
||||||
embedded youtube videos on other websites with invidious.
|
|
||||||
|
|
||||||
The documentation contains a list of browser extensions that we recommended to use along with Invidious.
|
|
||||||
|
|
||||||
You can read more here: https://docs.invidious.io/applications/
|
|
||||||
|
|
||||||
|
|
||||||
## Contribute
|
|
||||||
|
|
||||||
### Code
|
|
||||||
|
|
||||||
1. Fork it ( https://github.com/iv-org/invidious/fork ).
|
|
||||||
1. Create your feature branch (`git checkout -b my-new-feature`).
|
|
||||||
1. Stage your files (`git add .`).
|
|
||||||
1. Commit your changes (`git commit -am 'Add some feature'`).
|
|
||||||
1. Push to the branch (`git push origin my-new-feature`).
|
|
||||||
1. Create a new pull request ( https://github.com/iv-org/invidious/compare ).
|
|
||||||
|
|
||||||
### Translations
|
|
||||||
|
|
||||||
We use [Weblate](https://weblate.org) to manage Invidious translations.
|
|
||||||
|
|
||||||
You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/.
|
|
||||||
|
|
||||||
Creating an account is not required, but recommended, especially if you want to contribute regularly.
|
|
||||||
Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ...
|
|
||||||
|
|
||||||
|
|
||||||
## Projects using Invidious
|
|
||||||
|
|
||||||
A list of projects and extensions for or utilizing Invidious can be found in the documentation: https://docs.invidious.io/applications/
|
|
||||||
|
|
||||||
## Liability
|
|
||||||
|
|
||||||
We take no responsibility for the use of our tool, or external instances
|
|
||||||
provided by third parties. We strongly recommend you abide by the valid
|
|
||||||
official regulations in your country. Furthermore, we refuse liability
|
|
||||||
for any inappropriate use of Invidious, such as illegal downloading.
|
|
||||||
This tool is provided to you in the spirit of free, open software.
|
|
||||||
|
|
||||||
You may view the LICENSE in which this software is provided to you [here](./LICENSE).
|
|
||||||
|
|
||||||
> 16. Limitation of Liability.
|
|
||||||
>
|
>
|
||||||
> IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
> If you don't have an instance already, you can use this fork safely, but you will not be able to switch to upstream Invidious.
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
## Features and changes of this fork:
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
- ~~[Use a Redis compatible DB for video cache instead of just PostgreSQL](https://git.nadeko.net/Fijxu/invidious/commit/bbc5913b8dacaed4d466bcc466a0782d5e3f5edc): Invidious by default caches the video information for some hours in PostgreSQL. Since the data is accessed a lot, it is better off using an in memory database instead, it's faster and it will not wear out your SSD (due to constant writes to the database).~~
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
~~It can be set using this on `config.yml`:~~
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
```yaml
|
||||||
SUCH DAMAGES.
|
redis_url: tcp://127.0.0.1:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
- [Ability to use different video caching backends](https://git.nadeko.net/Fijxu/invidious/commit/e76867aaba022d64ebab73648a37a0c63b788e0f): If you want, you can the PostgreSQL video cache the Redis one or the built-in in memory one that uses the LRU algorithm. Redis and LRU are recommended for public instances, but since Invidious has memory leaks, the LRU cache is lost if Invidious crashes or it's restarted, so because of this, redis is the default option.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
video_cache:
|
||||||
|
enabled: true
|
||||||
|
backend: 1 # 0 is PSQL, 1 Redis, 2 Built-in LRU
|
||||||
|
lru_max_size: 18000 # ~500MB (ignored if backend is 0 or 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you choose to use Redis, make sure to set the `redis_url` config property:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis_url: tcp://127.0.0.1:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
- [Removal of materialized views on PostgreSQL](github.com/iv-org/invidious/pull/2469): If you don't have this on your Invidious public instance, your SSD will suffer and it will catch on fire https://github.com/iv-org/invidious/pull/2469#issuecomment-2012623454
|
||||||
|
|
||||||
|
- Limit the DASH resolution sent to the clients: It can be set using `max_dash_resolution` on the config. Example: `max_dash_resolution: 1080`
|
||||||
|
|
||||||
|
- [Limit requests made to Youtube API when pulling subscriptions (feeds)](https://git.nadeko.net/Fijxu/invidious/commit/df94f1c0b82d95846574487231ea251530838ef0): Due to the recent changes of Youtube ("This helps protect out community", "Sign in to confirm you are not a bot"), subscriptions now have limited information, this is because Invidious by default, makes a video request to youtube to be able to get more information about the video, like `length_seconds`, `live_now`, `premiere_timestamp`, and `views`. If you have a lot of users with a ton of subscriptions, Invidious will basically spam youtube API all the time, resulting in a block from youtube.
|
||||||
|
|
||||||
|
It can be set using this on `config.yml`:
|
||||||
|
```yaml
|
||||||
|
use_innertube_for_feeds: false
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
- Autoreload configuration: If you are hosting Invidious on Linux without docker, this may be useful for you if you want to change the banner without restarting Invidious.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
reload_config_automatically: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development features
|
||||||
|
|
||||||
|
- Option to disable CSP: Useful for local development, set `csp: false` on the config and done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
There is more things that I added to this fork, but those are the most important ones. I also regularly merge unmerged pull requests from https://github.com/iv-org/invidious and random fixes as well. Is not the most stable codebase, but you can't really make something stable when youtube is trying to destroy every third party client out there.
|
|
@ -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;
|
||||||
|
|
1
assets/js/.gitignore
vendored
Normal file
1
assets/js/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
minified
|
|
@ -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');
|
||||||
|
|
||||||
|
|
93
assets/js/pagination.js
Normal file
93
assets/js/pagination.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CURRENT_CONTINUATION = (new URL(document.location)).searchParams.get("continuation");
|
||||||
|
const CONT_CACHE_KEY = `continuation_cache_${encodeURIComponent(window.location.pathname)}`;
|
||||||
|
|
||||||
|
function get_data(){
|
||||||
|
return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_data(){
|
||||||
|
const prev_data = get_data();
|
||||||
|
prev_data.push(CURRENT_CONTINUATION);
|
||||||
|
|
||||||
|
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function button_press(){
|
||||||
|
let prev_data = get_data();
|
||||||
|
if (!prev_data.length) return null;
|
||||||
|
|
||||||
|
// Sanity check. Nowhere should the current continuation token exist in the cache
|
||||||
|
// but it can happen when using the browser's back feature. As such we'd need to travel
|
||||||
|
// back to the point where the current continuation token first appears in order to
|
||||||
|
// account for the rewind.
|
||||||
|
const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
|
||||||
|
if (conflict_at != -1) {
|
||||||
|
prev_data.length = conflict_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev_ctoken = prev_data.pop();
|
||||||
|
|
||||||
|
// On the first page, the stored continuation token is null.
|
||||||
|
if (prev_ctoken === null) {
|
||||||
|
sessionStorage.removeItem(CONT_CACHE_KEY);
|
||||||
|
let url = set_continuation();
|
||||||
|
window.location.href = url;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
|
||||||
|
let url = set_continuation(prev_ctoken);
|
||||||
|
|
||||||
|
window.location.href = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to set the current page's continuation token
|
||||||
|
// Removes the continuation parameter when a continuation token is not given
|
||||||
|
function set_continuation(prev_ctoken = null){
|
||||||
|
let url = window.location.href.split('?')[0];
|
||||||
|
let params = window.location.href.split('?')[1];
|
||||||
|
let url_params = new URLSearchParams(params);
|
||||||
|
|
||||||
|
if (prev_ctoken) {
|
||||||
|
url_params.set("continuation", prev_ctoken);
|
||||||
|
} else {
|
||||||
|
url_params.delete('continuation');
|
||||||
|
};
|
||||||
|
|
||||||
|
if(Array.from(url_params).length > 0){
|
||||||
|
return `${url}?${url_params.toString()}`;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('DOMContentLoaded', function(){
|
||||||
|
const pagination_data = JSON.parse(document.getElementById('pagination-data').textContent);
|
||||||
|
const next_page_containers = document.getElementsByClassName("page-next-container");
|
||||||
|
|
||||||
|
for (let container of next_page_containers){
|
||||||
|
const next_page_button = container.getElementsByClassName("pure-button")
|
||||||
|
|
||||||
|
// exists?
|
||||||
|
if (next_page_button.length > 0){
|
||||||
|
next_page_button[0].addEventListener("click", save_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add previous page buttons when not on the first page
|
||||||
|
if (CURRENT_CONTINUATION) {
|
||||||
|
const prev_page_containers = document.getElementsByClassName("page-prev-container")
|
||||||
|
|
||||||
|
for (let container of prev_page_containers) {
|
||||||
|
if (pagination_data.is_rtl) {
|
||||||
|
container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page} <i class="icon ion-ios-arrow-forward"></i></button>`
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i> ${pagination_data.prev_page}</button>`
|
||||||
|
}
|
||||||
|
container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -56,6 +56,7 @@ videojs.Vhs.MAX_GOAL_BUFFER_LENGTH = 80;
|
||||||
var player = videojs('player', options);
|
var player = videojs('player', options);
|
||||||
|
|
||||||
player.on('error', function () {
|
player.on('error', function () {
|
||||||
|
console.debug(`[VideoJS Debug] Playback cannot continue, error: ${player.error().code}`)
|
||||||
if (video_data.params.quality === 'dash') return;
|
if (video_data.params.quality === 'dash') return;
|
||||||
|
|
||||||
var localNotDisabled = (
|
var localNotDisabled = (
|
||||||
|
@ -137,26 +138,32 @@ player.on('timeupdate', function () {
|
||||||
// YouTube links
|
// YouTube links
|
||||||
|
|
||||||
let elem_yt_watch = document.getElementById('link-yt-watch');
|
let elem_yt_watch = document.getElementById('link-yt-watch');
|
||||||
|
if (elem_yt_watch) {
|
||||||
|
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
|
||||||
|
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
|
||||||
|
}
|
||||||
|
|
||||||
let elem_yt_embed = document.getElementById('link-yt-embed');
|
let elem_yt_embed = document.getElementById('link-yt-embed');
|
||||||
|
if (elem_yt_embed) {
|
||||||
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
|
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
|
||||||
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
|
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
|
||||||
|
}
|
||||||
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
|
|
||||||
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
|
|
||||||
|
|
||||||
// Invidious links
|
// Invidious links
|
||||||
|
|
||||||
let domain = window.location.origin;
|
let domain = window.location.origin;
|
||||||
|
|
||||||
let elem_iv_embed = document.getElementById('link-iv-embed');
|
let elem_iv_embed = document.getElementById('link-iv-embed');
|
||||||
|
if (elem_iv_embed) {
|
||||||
|
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
|
||||||
|
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
|
||||||
|
}
|
||||||
|
|
||||||
let elem_iv_other = document.getElementById('link-iv-other');
|
let elem_iv_other = document.getElementById('link-iv-other');
|
||||||
|
if (elem_iv_other) {
|
||||||
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
|
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
|
||||||
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
|
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
|
||||||
|
}
|
||||||
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
|
|
||||||
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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!!"
|
||||||
|
|
||||||
#########################################
|
#########################################
|
||||||
#
|
#
|
||||||
|
@ -130,6 +177,20 @@ https_only: false
|
||||||
##
|
##
|
||||||
#hsts: true
|
#hsts: true
|
||||||
|
|
||||||
|
##
|
||||||
|
## Path and permissions of a UNIX socket to listen on for incoming connections.
|
||||||
|
##
|
||||||
|
## Note: Enabling socket will make invidious stop listening on the address
|
||||||
|
## specified by 'host_binding' and 'port'.
|
||||||
|
##
|
||||||
|
## Accepted values: Any path to a new file (that doesn't exist yet) and its
|
||||||
|
## permissions following the UNIX octal convention.
|
||||||
|
## Default: <none>
|
||||||
|
##
|
||||||
|
#socket_binding:
|
||||||
|
# path: /tmp/invidious.sock
|
||||||
|
# permissions: 777
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Network (outbound)
|
# Network (outbound)
|
||||||
|
@ -173,6 +234,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 +296,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
|
||||||
|
|
||||||
|
@ -998,8 +1072,7 @@ default_user_preferences:
|
||||||
##
|
##
|
||||||
#extend_desc: false
|
#extend_desc: false
|
||||||
|
|
||||||
# redis_url: 127.0.0.1:6379
|
# redis_url: redis://127.0.0.1:6379/0?initial_pool_size=1&max_pool_size=10&checkout_timeout=10&retry_attempts=2&retry_delay=0.5&max_idle_pool_size=50
|
||||||
# redis_socket: /var/run/valkey/valkey.sock
|
|
||||||
# donation_url: "https://example.com/donate"
|
# donation_url: "https://example.com/donate"
|
||||||
# contact_url: "https://example.com/contact"
|
# contact_url: "https://example.com/contact"
|
||||||
# home_domain: "https://example.com/
|
# home_domain: "https://example.com/
|
||||||
|
|
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.16.0-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache sqlite-static yaml-static
|
RUN apk add --no-cache sqlite-static yaml-static
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ ARG release
|
||||||
WORKDIR /invidious
|
WORKDIR /invidious
|
||||||
COPY ./shard.yml ./shard.yml
|
COPY ./shard.yml ./shard.yml
|
||||||
COPY ./shard.lock ./shard.lock
|
COPY ./shard.lock ./shard.lock
|
||||||
|
|
||||||
RUN shards install --production
|
RUN shards install --production
|
||||||
|
|
||||||
COPY ./src/ ./src/
|
COPY ./src/ ./src/
|
||||||
|
@ -19,21 +20,14 @@ COPY ./scripts/ ./scripts/
|
||||||
COPY ./assets/ ./assets/
|
COPY ./assets/ ./assets/
|
||||||
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
|
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
|
||||||
|
|
||||||
RUN crystal spec --warnings all \
|
RUN --mount=type=cache,target=/root/.cache/crystal \
|
||||||
--link-flags "-lxml2 -llzma"
|
|
||||||
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 \
|
|
||||||
crystal build ./src/invidious.cr \
|
|
||||||
--static --warnings all \
|
|
||||||
--link-flags "-lxml2 -llzma"; \
|
|
||||||
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
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
|
||||||
RUN crystal spec --warnings all \
|
RUN crystal spec --warnings all \
|
||||||
--link-flags "-lxml2 -llzma"
|
--link-flags "-lxml2 -llzma"
|
||||||
|
|
||||||
RUN if [[ "${release}" == 1 ]] ; then \
|
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
|
||||||
crystal build ./src/invidious.cr \
|
crystal build ./src/invidious.cr \
|
||||||
--release \
|
--release \
|
||||||
--static --warnings all \
|
--static --warnings all \
|
||||||
|
@ -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
|
||||||
|
|
|
@ -559,10 +559,12 @@
|
||||||
"toggle_theme": "تبديل الموضوع",
|
"toggle_theme": "تبديل الموضوع",
|
||||||
"Add to playlist": "أضف إلى قائمة التشغيل",
|
"Add to playlist": "أضف إلى قائمة التشغيل",
|
||||||
"Add to playlist: ": "أضف إلى قائمة التشغيل: ",
|
"Add to playlist: ": "أضف إلى قائمة التشغيل: ",
|
||||||
"Answer": "الرد",
|
"Answer": "اجابة",
|
||||||
"Search for videos": "ابحث عن مقاطع الفيديو",
|
"Search for videos": "ابحث عن مقاطع الفيديو",
|
||||||
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
|
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
|
||||||
"carousel_slide": "الشريحة {{current}} من {{total}}",
|
"carousel_slide": "الشريحة {{current}} من {{total}}",
|
||||||
"carousel_skip": "تخطي الكاروسيل",
|
"carousel_skip": "تخطي الكاروسيل",
|
||||||
"carousel_go_to": "انتقل إلى الشريحة `x`"
|
"carousel_go_to": "انتقل إلى الشريحة `x`",
|
||||||
|
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
|
||||||
|
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -137,7 +137,7 @@
|
||||||
"Family friendly? ": "Vhodné pro rodiny? ",
|
"Family friendly? ": "Vhodné pro rodiny? ",
|
||||||
"Engagement: ": "Zapojení: ",
|
"Engagement: ": "Zapojení: ",
|
||||||
"English": "Angličtina",
|
"English": "Angličtina",
|
||||||
"English (auto-generated)": "Angličtina (automaticky generováno)",
|
"English (auto-generated)": "Angličtina (vytvořeno automaticky)",
|
||||||
"Afrikaans": "Afrikánština",
|
"Afrikaans": "Afrikánština",
|
||||||
"Albanian": "Albánština",
|
"Albanian": "Albánština",
|
||||||
"Amharic": "Amharština",
|
"Amharic": "Amharština",
|
||||||
|
@ -294,8 +294,8 @@
|
||||||
"Chinese (China)": "Čínština (Čína)",
|
"Chinese (China)": "Čínština (Čína)",
|
||||||
"Chinese (Hong Kong)": "Čínština (Hong Kong)",
|
"Chinese (Hong Kong)": "Čínština (Hong Kong)",
|
||||||
"Chinese (Taiwan)": "Čínština (Taiwan)",
|
"Chinese (Taiwan)": "Čínština (Taiwan)",
|
||||||
"Portuguese (auto-generated)": "Portugalština (automaticky generováno)",
|
"Portuguese (auto-generated)": "Portugalština (vytvořeno automaticky)",
|
||||||
"Spanish (auto-generated)": "Španělština (automaticky generováno)",
|
"Spanish (auto-generated)": "Španělština (vytvořeno automaticky)",
|
||||||
"Spanish (Mexico)": "Španělština (Mexiko)",
|
"Spanish (Mexico)": "Španělština (Mexiko)",
|
||||||
"Spanish (Spain)": "Španělština (Španělsko)",
|
"Spanish (Spain)": "Španělština (Španělsko)",
|
||||||
"generic_count_years_0": "{{count}} rokem",
|
"generic_count_years_0": "{{count}} rokem",
|
||||||
|
@ -352,13 +352,13 @@
|
||||||
"comments_points_count_0": "{{count}} bod",
|
"comments_points_count_0": "{{count}} bod",
|
||||||
"comments_points_count_1": "{{count}} body",
|
"comments_points_count_1": "{{count}} body",
|
||||||
"comments_points_count_2": "{{count}} bodů",
|
"comments_points_count_2": "{{count}} bodů",
|
||||||
"German (auto-generated)": "Němčina (automaticky generováno)",
|
"German (auto-generated)": "Němčina (vytvořeno automaticky)",
|
||||||
"Indonesian (auto-generated)": "Indonéština (automaticky generováno)",
|
"Indonesian (auto-generated)": "Indonéština (vytvořeno automaticky)",
|
||||||
"Interlingue": "Interlingue",
|
"Interlingue": "Interlingue",
|
||||||
"Italian (auto-generated)": "Italština (automaticky generováno)",
|
"Italian (auto-generated)": "Italština (vytvořeno automaticky)",
|
||||||
"Japanese (auto-generated)": "Japonština (automaticky generováno)",
|
"Japanese (auto-generated)": "Japonština (vytvořeno automaticky)",
|
||||||
"Korean (auto-generated)": "Korejština (automaticky generováno)",
|
"Korean (auto-generated)": "Korejština (vytvořeno automaticky)",
|
||||||
"Russian (auto-generated)": "Ruština (automaticky generováno)",
|
"Russian (auto-generated)": "Ruština (vytvořeno automaticky)",
|
||||||
"generic_count_months_0": "{{count}} měsícem",
|
"generic_count_months_0": "{{count}} měsícem",
|
||||||
"generic_count_months_1": "{{count}} měsíci",
|
"generic_count_months_1": "{{count}} měsíci",
|
||||||
"generic_count_months_2": "{{count}} měsíci",
|
"generic_count_months_2": "{{count}} měsíci",
|
||||||
|
@ -371,7 +371,7 @@
|
||||||
"footer_documentation": "Dokumentace",
|
"footer_documentation": "Dokumentace",
|
||||||
"next_steps_error_message_refresh": "Obnovit stránku",
|
"next_steps_error_message_refresh": "Obnovit stránku",
|
||||||
"Chinese": "Čínština",
|
"Chinese": "Čínština",
|
||||||
"Dutch (auto-generated)": "Nizozemština (automaticky generováno)",
|
"Dutch (auto-generated)": "Nizozemština (vytvořeno automaticky)",
|
||||||
"Erroneous token": "Chybný token",
|
"Erroneous token": "Chybný token",
|
||||||
"tokens_count_0": "{{count}} token",
|
"tokens_count_0": "{{count}} token",
|
||||||
"tokens_count_1": "{{count}} tokeny",
|
"tokens_count_1": "{{count}} tokeny",
|
||||||
|
@ -380,9 +380,9 @@
|
||||||
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
|
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
|
||||||
"English (United States)": "Angličtina (Spojené státy)",
|
"English (United States)": "Angličtina (Spojené státy)",
|
||||||
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
|
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
|
||||||
"French (auto-generated)": "Francouzština (automaticky generováno)",
|
"French (auto-generated)": "Francouzština (vytvořeno automaticky)",
|
||||||
"Turkish (auto-generated)": "Turečtina (automaticky generováno)",
|
"Turkish (auto-generated)": "Turečtina (vytvořeno automaticky)",
|
||||||
"Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)",
|
"Vietnamese (auto-generated)": "Vietnamština (vytvořeno automaticky)",
|
||||||
"Current version: ": "Aktuální verze: ",
|
"Current version: ": "Aktuální verze: ",
|
||||||
"next_steps_error_message": "Měli byste zkusit: ",
|
"next_steps_error_message": "Měli byste zkusit: ",
|
||||||
"footer_donate_page": "Přispět",
|
"footer_donate_page": "Přispět",
|
||||||
|
@ -513,5 +513,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
|
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
|
||||||
"carousel_slide": "Snímek {{current}} z {{total}}",
|
"carousel_slide": "Snímek {{current}} z {{total}}",
|
||||||
"carousel_skip": "Přeskočit galerii",
|
"carousel_skip": "Přeskočit galerii",
|
||||||
"carousel_go_to": "Přejít na snímek `x`"
|
"carousel_go_to": "Přejít na snímek `x`",
|
||||||
|
"preferences_preload_label": "Předem načíst data videa: ",
|
||||||
|
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"last": "neueste",
|
"last": "neueste",
|
||||||
"Next page": "Nächste Seite",
|
"Next page": "Nächste Seite",
|
||||||
"Previous page": "Vorherige Seite",
|
"Previous page": "Vorherige Seite",
|
||||||
|
"First page": "Erste Seite",
|
||||||
"Clear watch history?": "Verlauf löschen?",
|
"Clear watch history?": "Verlauf löschen?",
|
||||||
"New password": "Neues Passwort",
|
"New password": "Neues Passwort",
|
||||||
"New passwords must match": "Neue Passwörter müssen übereinstimmen",
|
"New passwords must match": "Neue Passwörter müssen übereinstimmen",
|
||||||
|
@ -490,12 +491,13 @@
|
||||||
"generic_channels_count_plural": "{{count}} Kanäle",
|
"generic_channels_count_plural": "{{count}} Kanäle",
|
||||||
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
|
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
|
||||||
"Answer": "Antwort",
|
"Answer": "Antwort",
|
||||||
"The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.",
|
"The Popular feed has been disabled by the administrator.": "Der Feed für beliebte Inhalte wurde vom Administrator deaktiviert.",
|
||||||
"Add to playlist": "Einer Wiedergabeliste hinzufügen",
|
"Add to playlist": "Einer Wiedergabeliste hinzufügen",
|
||||||
"Search for videos": "Nach Videos suchen",
|
"Search for videos": "Nach Videos suchen",
|
||||||
"toggle_theme": "Thema wechseln",
|
"toggle_theme": "Thema wechseln",
|
||||||
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
|
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
|
||||||
"carousel_go_to": "Zu Folie `x` gehen",
|
"carousel_go_to": "Zu Element `x` springen",
|
||||||
"carousel_slide": "Folie {{current}} von {{total}}",
|
"carousel_slide": "Seite {{current}} von {{total}}",
|
||||||
"carousel_skip": "Karussell überspringen"
|
"carousel_skip": "Galerie überspringen",
|
||||||
|
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
|
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
|
||||||
"Import": "Εισαγωγή",
|
"Import": "Εισαγωγή",
|
||||||
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
|
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
|
||||||
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
|
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube απο CVS/OPML",
|
||||||
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
|
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
|
||||||
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
|
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
|
||||||
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
|
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
|
||||||
|
@ -455,7 +455,7 @@
|
||||||
"channel_tab_streams_label": "Ζωντανή μετάδοση",
|
"channel_tab_streams_label": "Ζωντανή μετάδοση",
|
||||||
"playlist_button_add_items": "Προσθήκη βίντεο",
|
"playlist_button_add_items": "Προσθήκη βίντεο",
|
||||||
"Artist: ": "Καλλιτέχνης: ",
|
"Artist: ": "Καλλιτέχνης: ",
|
||||||
"search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
|
"search_message_use_another_instance": "Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
|
||||||
"generic_button_save": "Αποθήκευση",
|
"generic_button_save": "Αποθήκευση",
|
||||||
"generic_button_cancel": "Ακύρωση",
|
"generic_button_cancel": "Ακύρωση",
|
||||||
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
|
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
|
||||||
|
@ -490,9 +490,13 @@
|
||||||
"Search for videos": "Αναζήτηση βίντεο",
|
"Search for videos": "Αναζήτηση βίντεο",
|
||||||
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
|
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
|
||||||
"Answer": "Απάντηση",
|
"Answer": "Απάντηση",
|
||||||
"Add to playlist": "Λίιστα αναπαραγωγής",
|
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής",
|
||||||
"Add to playlist: ": "Λίστα αναπαραγωγής: ",
|
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ",
|
||||||
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
|
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
|
||||||
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
|
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
|
||||||
"toggle_theme": "Αλλαγή θέματος"
|
"toggle_theme": "Αλλαγή θέματος",
|
||||||
|
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
|
||||||
|
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
|
||||||
|
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
|
||||||
|
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
"last": "last",
|
"last": "last",
|
||||||
"Next page": "Next page",
|
"Next page": "Next page",
|
||||||
"Previous page": "Previous page",
|
"Previous page": "Previous page",
|
||||||
|
"First page": "First page",
|
||||||
"Clear watch history?": "Clear watch history?",
|
"Clear watch history?": "Clear watch history?",
|
||||||
"New password": "New password",
|
"New password": "New password",
|
||||||
"New passwords must match": "New passwords must match",
|
"New passwords must match": "New passwords must match",
|
||||||
|
@ -287,6 +288,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)",
|
||||||
|
@ -511,13 +513,21 @@
|
||||||
"channel_tab_streams_label": "Livestreams",
|
"channel_tab_streams_label": "Livestreams",
|
||||||
"channel_tab_podcasts_label": "Podcasts",
|
"channel_tab_podcasts_label": "Podcasts",
|
||||||
"channel_tab_releases_label": "Releases",
|
"channel_tab_releases_label": "Releases",
|
||||||
|
"channel_tab_courses_label": "Courses",
|
||||||
"channel_tab_playlists_label": "Playlists",
|
"channel_tab_playlists_label": "Playlists",
|
||||||
"channel_tab_community_label": "Community",
|
"channel_tab_community_label": "Community",
|
||||||
|
"channel_tab_posts_label": "Posts",
|
||||||
"channel_tab_channels_label": "Channels",
|
"channel_tab_channels_label": "Channels",
|
||||||
"toggle_theme": "Toggle Theme",
|
"toggle_theme": "Toggle Theme",
|
||||||
"carousel_slide": "Slide {{current}} of {{total}}",
|
"carousel_slide": "Slide {{current}} of {{total}}",
|
||||||
"carousel_skip": "Skip the Carousel",
|
"carousel_skip": "Skip the Carousel",
|
||||||
"carousel_go_to": "Go to slide `x`",
|
"carousel_go_to": "Go to slide `x`",
|
||||||
"footer_contact_url": "Contact the Administrator"
|
"footer_contact_url": "Contact the Administrator",
|
||||||
|
"new_username": "New username",
|
||||||
|
"change_username": "Change username",
|
||||||
|
"username_required_field": "Username is a required field",
|
||||||
|
"username_empty": "Username cannot be empty",
|
||||||
|
"username_is_the_same": "This is your username, use another one",
|
||||||
|
"username_taken": "Username is already taken, use another one",
|
||||||
|
"backend_unavailable": "The backend you selected is unavailable. You have been redirected to the next one"
|
||||||
}
|
}
|
||||||
|
|
|
@ -516,5 +516,14 @@
|
||||||
"carousel_slide": "Diapositiva {{current}} de {{total}}",
|
"carousel_slide": "Diapositiva {{current}} de {{total}}",
|
||||||
"carousel_skip": "Saltar el carrusel",
|
"carousel_skip": "Saltar el carrusel",
|
||||||
"carousel_go_to": "Ir a la diapositiva `x`",
|
"carousel_go_to": "Ir a la diapositiva `x`",
|
||||||
"footer_contact_url": "Contactar al Administrador"
|
"footer_contact_url": "Contactar al Administrador",
|
||||||
|
"preferences_preload_label": "Precargar datos del vídeo: ",
|
||||||
|
"Filipino (auto-generated)": "Filipino (generado automáticamente)",
|
||||||
|
"new_username": "Nuevo nombre de usuario",
|
||||||
|
"change_username": "Cambiar nombre de usuario",
|
||||||
|
"username_required_field": "El nombre de usuario es un campo obligatorio",
|
||||||
|
"username_empty": "El nombre de usuario no puede estar vacío",
|
||||||
|
"username_is_the_same": "Este es tu nombre de usuario, usa otro",
|
||||||
|
"username_taken": "El nombre de usuario ya está en uso, usa otro",
|
||||||
|
"backend_unavailable": "El backend seleccionado no está disponible. Has sido redireccionado al siguiente"
|
||||||
}
|
}
|
||||||
|
|
|
@ -496,5 +496,6 @@
|
||||||
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
|
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
|
||||||
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
|
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
|
||||||
"channel_tab_releases_label": "آثار",
|
"channel_tab_releases_label": "آثار",
|
||||||
"toggle_theme": "تغییر وضعیت تم"
|
"toggle_theme": "تغییر وضعیت تم",
|
||||||
|
"preferences_preload_label": "پیش بار کردن دادههای ویدیو: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -460,7 +460,7 @@
|
||||||
"search_filters_apply_button": "Ota valitut suodattimet käyttöön",
|
"search_filters_apply_button": "Ota valitut suodattimet käyttöön",
|
||||||
"search_filters_date_label": "Latausaika",
|
"search_filters_date_label": "Latausaika",
|
||||||
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
|
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
|
||||||
"search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
|
"search_message_use_another_instance": "Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
|
||||||
"search_filters_date_option_none": "Milloin tahansa",
|
"search_filters_date_option_none": "Milloin tahansa",
|
||||||
"search_filters_type_option_all": "Mikä tahansa tyyppi",
|
"search_filters_type_option_all": "Mikä tahansa tyyppi",
|
||||||
"Popular enabled: ": "Suosittu käytössä: ",
|
"Popular enabled: ": "Suosittu käytössä: ",
|
||||||
|
@ -496,5 +496,6 @@
|
||||||
"generic_channels_count_plural": "{{count}} kanavaa",
|
"generic_channels_count_plural": "{{count}} kanavaa",
|
||||||
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
|
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
|
||||||
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
|
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
|
||||||
"toggle_theme": "Vaihda teemaa"
|
"toggle_theme": "Vaihda teemaa",
|
||||||
|
"preferences_preload_label": "Esilataa video data. "
|
||||||
}
|
}
|
||||||
|
|
|
@ -505,7 +505,7 @@
|
||||||
"channel_tab_releases_label": "Parutions",
|
"channel_tab_releases_label": "Parutions",
|
||||||
"channel_tab_podcasts_label": "Émissions audio",
|
"channel_tab_podcasts_label": "Émissions audio",
|
||||||
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
|
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
|
||||||
"Add to playlist: ": "Ajouter à la playlist : ",
|
"Add to playlist: ": "Ajouter à la playlist : ",
|
||||||
"Add to playlist": "Ajouter à la playlist",
|
"Add to playlist": "Ajouter à la playlist",
|
||||||
"Answer": "Répondre",
|
"Answer": "Répondre",
|
||||||
"Search for videos": "Rechercher des vidéos",
|
"Search for videos": "Rechercher des vidéos",
|
||||||
|
@ -513,5 +513,7 @@
|
||||||
"carousel_skip": "Passez le carrousel",
|
"carousel_skip": "Passez le carrousel",
|
||||||
"carousel_slide": "Diapositive {{current}} sur {{total}}",
|
"carousel_slide": "Diapositive {{current}} sur {{total}}",
|
||||||
"carousel_go_to": "Aller à la diapositive `x`",
|
"carousel_go_to": "Aller à la diapositive `x`",
|
||||||
"toggle_theme": "Changer le Thème"
|
"toggle_theme": "Changer le Thème",
|
||||||
|
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
|
||||||
|
"preferences_preload_label": "Précharger les données de la vidéo : "
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"toggle_theme": "Uklj./Isklj. temu",
|
"toggle_theme": "Uklj./Isklj. temu",
|
||||||
"carousel_slide": "Kadar {{current}} od {{total}}",
|
"carousel_slide": "Kadar {{current}} od {{total}}",
|
||||||
"carousel_go_to": "Idi na kadar `x`",
|
"carousel_go_to": "Idi na kadar `x`",
|
||||||
"carousel_skip": "Preskoči vrtuljak"
|
"carousel_skip": "Preskoči vrtuljak",
|
||||||
|
"Filipino (auto-generated)": "Filipinski (automatski generirano)",
|
||||||
|
"preferences_preload_label": "Unaprijed učitaj podatke videa: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -496,5 +496,7 @@
|
||||||
"footer_documentation": "Leiðbeiningar",
|
"footer_documentation": "Leiðbeiningar",
|
||||||
"channel_tab_channels_label": "Rásir",
|
"channel_tab_channels_label": "Rásir",
|
||||||
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
|
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
|
||||||
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
|
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)",
|
||||||
|
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
|
||||||
|
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -469,8 +469,8 @@
|
||||||
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
|
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
|
||||||
"Spanish (Mexico)": "Spagnolo (Messico)",
|
"Spanish (Mexico)": "Spagnolo (Messico)",
|
||||||
"Spanish (Spain)": "Spagnolo (Spagna)",
|
"Spanish (Spain)": "Spagnolo (Spagna)",
|
||||||
"Turkish (auto-generated)": "Turco (auto-generato)",
|
"Turkish (auto-generated)": "Turco (generati automaticamente)",
|
||||||
"Vietnamese (auto-generated)": "Vietnamita (auto-generato)",
|
"Vietnamese (auto-generated)": "Vietnamita (generati automaticamente)",
|
||||||
"search_filters_date_label": "Data caricamento",
|
"search_filters_date_label": "Data caricamento",
|
||||||
"search_filters_date_option_none": "Qualunque data",
|
"search_filters_date_option_none": "Qualunque data",
|
||||||
"search_filters_type_option_all": "Qualunque tipo",
|
"search_filters_type_option_all": "Qualunque tipo",
|
||||||
|
@ -513,5 +513,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
|
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
|
||||||
"carousel_slide": "Fotogramma {{current}} di {{total}}",
|
"carousel_slide": "Fotogramma {{current}} di {{total}}",
|
||||||
"carousel_skip": "Salta la galleria",
|
"carousel_skip": "Salta la galleria",
|
||||||
"carousel_go_to": "Vai al fotogramma `x`"
|
"carousel_go_to": "Vai al fotogramma `x`",
|
||||||
|
"preferences_preload_label": "Precarica dati video: ",
|
||||||
|
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -479,5 +479,7 @@
|
||||||
"carousel_go_to": "スライド`x`を表示",
|
"carousel_go_to": "スライド`x`を表示",
|
||||||
"carousel_slide": "スライド{{current}} / 全{{total}}個中",
|
"carousel_slide": "スライド{{current}} / 全{{total}}個中",
|
||||||
"carousel_skip": "画像のスライド表示をスキップ",
|
"carousel_skip": "画像のスライド表示をスキップ",
|
||||||
"toggle_theme": "テーマの切り替え"
|
"toggle_theme": "テーマの切り替え",
|
||||||
|
"preferences_preload_label": "動画データを事前に読み込む: ",
|
||||||
|
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,7 @@
|
||||||
"Next page": "다음 페이지",
|
"Next page": "다음 페이지",
|
||||||
"last": "마지막",
|
"last": "마지막",
|
||||||
"Shared `x` ago": "`x` 전",
|
"Shared `x` ago": "`x` 전",
|
||||||
"popular": "인기",
|
"popular": "인기순",
|
||||||
"oldest": "과거순",
|
"oldest": "과거순",
|
||||||
"newest": "최신순",
|
"newest": "최신순",
|
||||||
"View playlist on YouTube": "유튜브에서 재생목록 보기",
|
"View playlist on YouTube": "유튜브에서 재생목록 보기",
|
||||||
|
@ -479,5 +479,6 @@
|
||||||
"carousel_go_to": "`x` 슬라이드로 이동",
|
"carousel_go_to": "`x` 슬라이드로 이동",
|
||||||
"Search for videos": "비디오 검색",
|
"Search for videos": "비디오 검색",
|
||||||
"toggle_theme": "테마 전환",
|
"toggle_theme": "테마 전환",
|
||||||
"carousel_slide": "{{total}}의 슬라이드 {{current}}"
|
"carousel_slide": "{{total}}의 슬라이드 {{current}}",
|
||||||
|
"preferences_preload_label": "비디오 데이터 사전 로드: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -496,5 +496,6 @@
|
||||||
"Add to playlist": "Legg til i spilleliste",
|
"Add to playlist": "Legg til i spilleliste",
|
||||||
"Add to playlist: ": "Legg til i spilleliste: ",
|
"Add to playlist: ": "Legg til i spilleliste: ",
|
||||||
"The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
|
"The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
|
||||||
"toggle_theme": "Endre utseende"
|
"toggle_theme": "Endre utseende",
|
||||||
|
"preferences_preload_label": "Last videodata på forhånd: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -496,5 +496,7 @@
|
||||||
"Answer": "Antwoorden",
|
"Answer": "Antwoorden",
|
||||||
"Search for videos": "Naar video's zoeken",
|
"Search for videos": "Naar video's zoeken",
|
||||||
"carousel_skip": "Carousel overslaan",
|
"carousel_skip": "Carousel overslaan",
|
||||||
"toggle_theme": "Thema omschakelen"
|
"toggle_theme": "Thema omschakelen",
|
||||||
|
"preferences_preload_label": "Videogegevens vooraf laden: ",
|
||||||
|
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"Add to playlist: ": "Dodaj do playlisty: ",
|
"Add to playlist: ": "Dodaj do playlisty: ",
|
||||||
"carousel_slide": "Slajd {{current}} z {{total}}",
|
"carousel_slide": "Slajd {{current}} z {{total}}",
|
||||||
"carousel_skip": "Pomiń karuzelę",
|
"carousel_skip": "Pomiń karuzelę",
|
||||||
"carousel_go_to": "Przejdź do slajdu `x`"
|
"carousel_go_to": "Przejdź do slajdu `x`",
|
||||||
|
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
|
||||||
|
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"Answer": "Resposta",
|
"Answer": "Resposta",
|
||||||
"carousel_slide": "Slide {{current}} de {{total}}",
|
"carousel_slide": "Slide {{current}} de {{total}}",
|
||||||
"carousel_skip": "Ignorar carrossel",
|
"carousel_skip": "Ignorar carrossel",
|
||||||
"carousel_go_to": "Ir ao slide `x`"
|
"carousel_go_to": "Ir ao slide `x`",
|
||||||
|
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
|
||||||
|
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
||||||
"carousel_skip": "Ignorar carrossel",
|
"carousel_skip": "Ignorar carrossel",
|
||||||
"carousel_go_to": "Ir para o diapositivo`x`",
|
"carousel_go_to": "Ir para o diapositivo`x`",
|
||||||
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
|
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
|
||||||
|
"preferences_preload_label": "Pré-carregamento dos dados: ",
|
||||||
|
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"last": "последние",
|
"last": "последние",
|
||||||
"Next page": "Следующая страница",
|
"Next page": "Следующая страница",
|
||||||
"Previous page": "Предыдущая страница",
|
"Previous page": "Предыдущая страница",
|
||||||
|
"First page": "Первая страница",
|
||||||
"Clear watch history?": "Очистить историю просмотров?",
|
"Clear watch history?": "Очистить историю просмотров?",
|
||||||
"New password": "Новый пароль",
|
"New password": "Новый пароль",
|
||||||
"New passwords must match": "Новые пароли не совпадают",
|
"New passwords must match": "Новые пароли не совпадают",
|
||||||
|
@ -48,8 +49,8 @@
|
||||||
"preferences_category_player": "Настройки проигрывателя",
|
"preferences_category_player": "Настройки проигрывателя",
|
||||||
"preferences_video_loop_label": "Всегда повторять: ",
|
"preferences_video_loop_label": "Всегда повторять: ",
|
||||||
"preferences_autoplay_label": "Автовоспроизведение: ",
|
"preferences_autoplay_label": "Автовоспроизведение: ",
|
||||||
"preferences_continue_label": "Переходить к следующему видео? ",
|
"preferences_continue_label": "Воспроизводить следующее видео: ",
|
||||||
"preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
|
"preferences_continue_autoplay_label": "Автовоспроизведение следующего видео: ",
|
||||||
"preferences_listen_label": "Режим «только аудио» по умолчанию: ",
|
"preferences_listen_label": "Режим «только аудио» по умолчанию: ",
|
||||||
"preferences_local_label": "Проигрывать видео через прокси? ",
|
"preferences_local_label": "Проигрывать видео через прокси? ",
|
||||||
"preferences_speed_label": "Скорость видео по умолчанию: ",
|
"preferences_speed_label": "Скорость видео по умолчанию: ",
|
||||||
|
@ -513,5 +514,6 @@
|
||||||
"toggle_theme": "Переключатель тем",
|
"toggle_theme": "Переключатель тем",
|
||||||
"carousel_slide": "Пролистано {{current}} из {{total}}",
|
"carousel_slide": "Пролистано {{current}} из {{total}}",
|
||||||
"carousel_skip": "Пропустить всё",
|
"carousel_skip": "Пропустить всё",
|
||||||
"carousel_go_to": "Перейти к странице `x`"
|
"carousel_go_to": "Перейти к странице `x`",
|
||||||
|
"preferences_preload_label": "Предзагрузка видеоданных: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"Import and Export Data": "Uvoz in izvoz podatkov",
|
"Import and Export Data": "Uvoz in izvoz podatkov",
|
||||||
"Import": "Uvozi",
|
"Import": "Uvozi",
|
||||||
"Import Invidious data": "Uvozi Invidious JSON podatke",
|
"Import Invidious data": "Uvozi Invidious JSON podatke",
|
||||||
"Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine",
|
"Import YouTube subscriptions": "Uvozi YouTube CSV ali OPML naročnine",
|
||||||
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
|
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
|
||||||
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
|
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
|
||||||
"Export": "Izvozi",
|
"Export": "Izvozi",
|
||||||
|
@ -105,7 +105,7 @@
|
||||||
"Show more": "Pokaži več",
|
"Show more": "Pokaži več",
|
||||||
"Switch Invidious Instance": "Preklopi Invidious instanco",
|
"Switch Invidious Instance": "Preklopi Invidious instanco",
|
||||||
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
|
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
|
||||||
"search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
|
"search_message_use_another_instance": "Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
|
||||||
"Wilson score: ": "Wilsonov rezultat: ",
|
"Wilson score: ": "Wilsonov rezultat: ",
|
||||||
"Engagement: ": "Sodelovanje: ",
|
"Engagement: ": "Sodelovanje: ",
|
||||||
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
|
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
|
||||||
|
@ -462,7 +462,7 @@
|
||||||
"search_filters_features_option_four_k": "4K",
|
"search_filters_features_option_four_k": "4K",
|
||||||
"search_filters_features_option_hdr": "HDR",
|
"search_filters_features_option_hdr": "HDR",
|
||||||
"next_steps_error_message_refresh": "Osveži",
|
"next_steps_error_message_refresh": "Osveži",
|
||||||
"search_filters_date_option_hour": "Zadnja ura",
|
"search_filters_date_option_hour": "V zadnji uri",
|
||||||
"search_filters_features_option_purchased": "Kupljeno",
|
"search_filters_features_option_purchased": "Kupljeno",
|
||||||
"search_filters_sort_label": "Razvrsti po",
|
"search_filters_sort_label": "Razvrsti po",
|
||||||
"search_filters_sort_option_views": "številu ogledov",
|
"search_filters_sort_option_views": "številu ogledov",
|
||||||
|
@ -521,5 +521,16 @@
|
||||||
"generic_channels_count_1": "{{count}} kanala",
|
"generic_channels_count_1": "{{count}} kanala",
|
||||||
"generic_channels_count_2": "{{count}} kanali",
|
"generic_channels_count_2": "{{count}} kanali",
|
||||||
"generic_channels_count_3": "{{count}} kanalov",
|
"generic_channels_count_3": "{{count}} kanalov",
|
||||||
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
|
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)",
|
||||||
|
"Add to playlist": "Dodaj na seznam predvajanja",
|
||||||
|
"Add to playlist: ": "Dodaj na seznam predvajanja: ",
|
||||||
|
"Search for videos": "Iskanje videoposnetkov",
|
||||||
|
"The Popular feed has been disabled by the administrator.": "Administrator je onemogočil priljubljeni vir.",
|
||||||
|
"Answer": "Odgovor",
|
||||||
|
"Filipino (auto-generated)": "filipinščina (samodejno ustvarjeno)",
|
||||||
|
"toggle_theme": "Preklopi temo",
|
||||||
|
"carousel_slide": "Diapozitiv {{current}} od {{total}}",
|
||||||
|
"carousel_skip": "Preskoči galerijo",
|
||||||
|
"carousel_go_to": "Pojdi na diapozitiv `x`",
|
||||||
|
"preferences_preload_label": "Predhodno naloži video podatke: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -492,5 +492,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
|
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
|
||||||
"carousel_skip": "Anashkaloje Rrotullamen",
|
"carousel_skip": "Anashkaloje Rrotullamen",
|
||||||
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
|
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
|
||||||
"carousel_go_to": "Kalo te diapozitivi `x`"
|
"carousel_go_to": "Kalo te diapozitivi `x`",
|
||||||
|
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
|
||||||
|
"preferences_preload_label": "Parangarko të dhëna videoje: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"Answer": "Odgovor",
|
"Answer": "Odgovor",
|
||||||
"Search for videos": "Pretražite video snimke",
|
"Search for videos": "Pretražite video snimke",
|
||||||
"carousel_skip": "Preskoči karusel",
|
"carousel_skip": "Preskoči karusel",
|
||||||
"toggle_theme": "Подеси тему"
|
"toggle_theme": "Подеси тему",
|
||||||
|
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
|
||||||
|
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"Add to playlist: ": "Додајте на плејлисту: ",
|
"Add to playlist: ": "Додајте на плејлисту: ",
|
||||||
"carousel_skip": "Прескочи карусел",
|
"carousel_skip": "Прескочи карусел",
|
||||||
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
|
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
|
||||||
"carousel_slide": "Слајд {{current}} од {{total}}"
|
"carousel_slide": "Слајд {{current}} од {{total}}",
|
||||||
|
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
|
||||||
|
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -496,5 +496,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
|
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
|
||||||
"carousel_slide": "Bildspel {{current}} av {{total}}",
|
"carousel_slide": "Bildspel {{current}} av {{total}}",
|
||||||
"carousel_skip": "Hoppa över karusellen",
|
"carousel_skip": "Hoppa över karusellen",
|
||||||
"carousel_go_to": "Gå till bildspel `x`"
|
"carousel_go_to": "Gå till bildspel `x`",
|
||||||
|
"preferences_preload_label": "Förladda video data: ",
|
||||||
|
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
|
||||||
}
|
}
|
||||||
|
|
502
locales/ta.json
Normal file
502
locales/ta.json
Normal file
|
@ -0,0 +1,502 @@
|
||||||
|
{
|
||||||
|
"Add to playlist": "பிளேலிச்ட்டில் சேர்க்கவும்",
|
||||||
|
"generic_channels_count": "{{count}} சேனல்",
|
||||||
|
"generic_channels_count_plural": "{{count}} சேனல்கள்",
|
||||||
|
"generic_views_count": "{{count}} பார்வை",
|
||||||
|
"generic_views_count_plural": "{{count}} காட்சிகள்",
|
||||||
|
"generic_videos_count": "{{count}} வீடியோ",
|
||||||
|
"generic_videos_count_plural": "{{count}} வீடியோக்கள்",
|
||||||
|
"generic_playlists_count": "{{count}} பிளேலிச்ட்",
|
||||||
|
"generic_playlists_count_plural": "{{count}} பிளேலிச்ட்கள்",
|
||||||
|
"generic_subscribers_count": "{{count}} சந்தாதாரர்",
|
||||||
|
"generic_subscribers_count_plural": "{{count}} சந்தாதாரர்கள்",
|
||||||
|
"generic_button_delete": "நீக்கு",
|
||||||
|
"generic_button_rss": "ஆர்.எச்.எச்",
|
||||||
|
"LIVE": "வாழ",
|
||||||
|
"Shared `x` ago": "`X` முன்பு பகிரப்பட்டது",
|
||||||
|
"Unsubscribe": "குழுவிலகவும்",
|
||||||
|
"View playlist on YouTube": "யூடியூப்பில் பிளேலிச்ட்டைக் காண்க",
|
||||||
|
"newest": "புதியது",
|
||||||
|
"oldest": "பழமையானது",
|
||||||
|
"popular": "மக்கள்",
|
||||||
|
"last": "கடைசி",
|
||||||
|
"Next page": "அடுத்த பக்கம்",
|
||||||
|
"Previous page": "முந்தைய பக்கம்",
|
||||||
|
"Clear watch history?": "தெளிவான கண்காணிப்பு வரலாறு?",
|
||||||
|
"New password": "புதிய கடவுச்சொல்",
|
||||||
|
"New passwords must match": "புதிய கடவுச்சொற்கள் பொருந்த வேண்டும்",
|
||||||
|
"Authorize token?": "கிள்ளாக்கை அங்கீகரிக்கவா?",
|
||||||
|
"Yes": "ஆம்",
|
||||||
|
"Import YouTube playlist (.csv)": "யூடியூப் பிளேலிச்ட்டை இறக்குமதி செய்க (.csv)",
|
||||||
|
"Import YouTube watch history (.json)": "YouTube வாட்ச் வரலாற்றை இறக்குமதி செய்க (.json)",
|
||||||
|
"Import Invidious data": "வன்கவர்வு சாதொபொகு தரவை இறக்குமதி செய்க",
|
||||||
|
"Import YouTube subscriptions": "YouTube காபிம அல்லது OPML சந்தாக்களை இறக்குமதி செய்க",
|
||||||
|
"Import FreeTube subscriptions (.db)": "ஃப்ரீட்யூப் சந்தாக்களை இறக்குமதி செய்க (.db)",
|
||||||
|
"Import NewPipe data (.zip)": "நியூபைப் தரவை இறக்குமதி செய்க (.zip)",
|
||||||
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள் (நியூபைப் & ஃப்ரீட்யூப்பிற்கு)",
|
||||||
|
"Export subscriptions as OPML": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள்",
|
||||||
|
"Export data as JSON": "சாதொபொகு ஆக வன்கவர்வு தரவை ஏற்றுமதி செய்யுங்கள்",
|
||||||
|
"Delete account?": "கணக்கை நீக்கவா?",
|
||||||
|
"History": "வரலாறு",
|
||||||
|
"JavaScript license information": "சாவாச்கிரிப்ட் உரிம செய்தி",
|
||||||
|
"source": "மூலம்",
|
||||||
|
"An alternative front-end to YouTube": "YouTube க்கு ஒரு மாற்று முன் இறுதியில்",
|
||||||
|
"Log in": "புகுபதிகை",
|
||||||
|
"Log in/register": "உள்நுழைக/பதிவு செய்யுங்கள்",
|
||||||
|
"User ID": "பயனர் ஐடி",
|
||||||
|
"Password": "கடவுச்சொல்",
|
||||||
|
"Time (h:mm:ss):": "நேரம் (h: மிமீ: எச்எச்):",
|
||||||
|
"Sign In": "விடுபதிகை",
|
||||||
|
"Register": "பதிவு செய்யுங்கள்",
|
||||||
|
"E-mail": "மின்னஞ்சல்",
|
||||||
|
"Preferences": "விருப்பத்தேர்வுகள்",
|
||||||
|
"preferences_preload_label": "வீடியோ தரவை முன்பே ஏற்றவும்: ",
|
||||||
|
"preferences_autoplay_label": "தன்னியக்க: ",
|
||||||
|
"preferences_continue_label": "இயல்பாக அடுத்து விளையாடுங்கள்: ",
|
||||||
|
"preferences_local_label": "பதிலாள் வீடியோக்கள்: ",
|
||||||
|
"preferences_watch_history_label": "கண்காணிப்பு வரலாற்றை இயக்கு: ",
|
||||||
|
"preferences_speed_label": "இயல்புநிலை வேகம்: ",
|
||||||
|
"preferences_quality_label": "விருப்பமான வீடியோ தரம்: ",
|
||||||
|
"preferences_quality_dash_label": "விருப்பமான கோடு வீடியோ தரம்: ",
|
||||||
|
"preferences_quality_dash_option_auto": "தானி",
|
||||||
|
"preferences_quality_dash_option_best": "சிறந்த",
|
||||||
|
"preferences_quality_dash_option_worst": "மோசமான",
|
||||||
|
"preferences_quality_dash_option_4320p": "4320 ப",
|
||||||
|
"preferences_quality_dash_option_1080p": "1080 ப",
|
||||||
|
"preferences_quality_dash_option_720p": "720 ஆ",
|
||||||
|
"preferences_quality_dash_option_480p": "480 ப",
|
||||||
|
"preferences_quality_dash_option_360p": "360 ப",
|
||||||
|
"preferences_quality_dash_option_144p": "144 ப",
|
||||||
|
"preferences_volume_label": "பிளேயர் தொகுதி: ",
|
||||||
|
"preferences_comments_label": "இயல்புநிலை கருத்துகள்: ",
|
||||||
|
"Fallback captions: ": "குறைவடையும் தலைப்புகள்: ",
|
||||||
|
"preferences_captions_label": "இயல்புநிலை தலைப்புகள்: ",
|
||||||
|
"preferences_related_videos_label": "தொடர்புடைய வீடியோக்களைக் காட்டு: ",
|
||||||
|
"preferences_annotations_label": "முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டு: ",
|
||||||
|
"preferences_vr_mode_label": "ஊடாடும் 360 டிகிரி வீடியோக்கள் (வெப்சிஎல் தேவை): ",
|
||||||
|
"preferences_category_visual": "காட்சி விருப்பத்தேர்வுகள்",
|
||||||
|
"light": "ஒளி",
|
||||||
|
"preferences_thin_mode_label": "மெல்லிய பயன்முறை: ",
|
||||||
|
"preferences_category_misc": "இதர விருப்பத்தேர்வுகள்",
|
||||||
|
"preferences_category_subscription": "சந்தா விருப்பத்தேர்வுகள்",
|
||||||
|
"preferences_annotations_subscribed_label": "சந்தா சேனல்களுக்கு முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டவா? ",
|
||||||
|
"Redirect homepage to feed: ": "உணவளிக்க முகப்புப்பக்கத்தை திருப்பி விடுங்கள்: ",
|
||||||
|
"preferences_sort_label": "வீடியோக்களை வரிசைப்படுத்துங்கள்: ",
|
||||||
|
"published": "வெளியிடப்பட்டது",
|
||||||
|
"published - reverse": "வெளியிடப்பட்டது - தலைகீழ்",
|
||||||
|
"alphabetically": "அகரவரிசை",
|
||||||
|
"preferences_unseen_only_label": "கவனக்குறைவாக மட்டுமே காட்டுங்கள்: ",
|
||||||
|
"preferences_notifications_only_label": "அறிவிப்புகளைக் காட்டுங்கள் (ஏதேனும் இருந்தால்): ",
|
||||||
|
"Enable web notifications": "வலை அறிவிப்புகளை இயக்கவும்",
|
||||||
|
"`x` is live": "`x` நேரலையில்",
|
||||||
|
"preferences_category_data": "தரவு விருப்பத்தேர்வுகள்",
|
||||||
|
"Manage subscriptions": "சந்தாக்களை நிர்வகிக்கவும்",
|
||||||
|
"Watch history": "வரலாற்றைப் பாருங்கள்",
|
||||||
|
"Delete account": "கணக்கை நீக்கு",
|
||||||
|
"preferences_category_admin": "நிர்வாகி விருப்பத்தேர்வுகள்",
|
||||||
|
"preferences_default_home_label": "இயல்புநிலை முகப்புப்பக்கம்: ",
|
||||||
|
"preferences_feed_menu_label": "ஊட்ட மெனு: ",
|
||||||
|
"preferences_show_nick_label": "மேலே புனைப்பெயரைக் காட்டு: ",
|
||||||
|
"Top enabled: ": "மேலே இயக்கப்பட்டது: ",
|
||||||
|
"CAPTCHA enabled: ": "கேப்ட்சா இயக்கப்பட்டது: ",
|
||||||
|
"Login enabled: ": "உள்நுழைவு இயக்கப்பட்டது: ",
|
||||||
|
"Registration enabled: ": "பதிவு இயக்கப்பட்டது: ",
|
||||||
|
"Report statistics: ": "அறிக்கை புள்ளிவிவரங்கள்: ",
|
||||||
|
"Save preferences": "விருப்பங்களை சேமிக்கவும்",
|
||||||
|
"Subscription manager": "சந்தா மேலாளர்",
|
||||||
|
"Token manager": "கிள்ளாக்கு மேலாளர்",
|
||||||
|
"Token": "கிள்ளாக்கு",
|
||||||
|
"search": "தேடல்",
|
||||||
|
"Released under the AGPLv3 on Github.": "கிட்அப்பில் AgPlv3 இன் கீழ் வெளியிடப்பட்டது.",
|
||||||
|
"View JavaScript license information.": "சாவாச்கிரிப்ட் உரிமத் தகவலைக் காண்க.",
|
||||||
|
"View privacy policy.": "தனியுரிமைக் கொள்கையைக் காண்க.",
|
||||||
|
"Trending": "டிரெண்டிங்",
|
||||||
|
"Public": "பொது",
|
||||||
|
"Unlisted": "பட்டியலிடப்படாதது",
|
||||||
|
"Private": "தனிப்பட்ட",
|
||||||
|
"View all playlists": "அனைத்து பிளேலிச்ட்களையும் காண்க",
|
||||||
|
"Updated `x` ago": "`X` முன்பு புதுப்பிக்கப்பட்டது",
|
||||||
|
"Delete playlist `x`?": "பிளேலிச்ட்டை நீக்கவா?",
|
||||||
|
"Playlist privacy": "பிளேலிச்ட் தனியுரிமை",
|
||||||
|
"Watch on YouTube": "YouTube இல் பாருங்கள்",
|
||||||
|
"Hide annotations": "சிறுகுறிப்புகளை மறைக்கவும்",
|
||||||
|
"Show replies": "பதில்களைக் காட்டு",
|
||||||
|
"Incorrect password": "தவறான கடவுச்சொல்",
|
||||||
|
"Wrong answer": "தவறான பதில்",
|
||||||
|
"Erroneous CAPTCHA": "தவறான கேப்ட்சா",
|
||||||
|
"CAPTCHA is a required field": "கேப்ட்சா ஒரு தேவையான புலம்",
|
||||||
|
"User ID is a required field": "பயனர் ஐடி தேவையான புலம்",
|
||||||
|
"Password is a required field": "கடவுச்சொல் தேவையான புலம்",
|
||||||
|
"Password cannot be empty": "கடவுச்சொல் காலியாக இருக்க முடியாது",
|
||||||
|
"Please log in": "தயவுசெய்து உள்நுழைக",
|
||||||
|
"This channel does not exist.": "இந்த சேனல் இல்லை.",
|
||||||
|
"Could not get channel info.": "சேனல் தகவலைப் பெற முடியவில்லை.",
|
||||||
|
"Could not fetch comments": "கருத்துகளைப் பெற முடியவில்லை",
|
||||||
|
"comments_points_count": "{{count}} புள்ளி",
|
||||||
|
"comments_points_count_plural": "{{count}} புள்ளிகள்",
|
||||||
|
"Could not create mix.": "கலவையை உருவாக்க முடியவில்லை.",
|
||||||
|
"Empty playlist": "வெற்று பிளேலிச்ட்",
|
||||||
|
"Not a playlist.": "ஒரு பிளேலிச்ட் அல்ல.",
|
||||||
|
"Playlist does not exist.": "பிளேலிச்ட் இல்லை.",
|
||||||
|
"Could not pull trending pages.": "பிரபலமான பக்கங்களை இழுக்க முடியவில்லை.",
|
||||||
|
"Erroneous challenge": "தவறான அறைகூவல்",
|
||||||
|
"Erroneous token": "தவறான கிள்ளாக்கு",
|
||||||
|
"No such user": "அத்தகைய பயனர் இல்லை",
|
||||||
|
"Token is expired, please try again": "கிள்ளாக்கு காலாவதியானது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்",
|
||||||
|
"English": "ஆங்கிலம்",
|
||||||
|
"English (United States)": "ஆங்கிலம் (ஐக்கிய அமெரிக்க)",
|
||||||
|
"English (United Kingdom)": "ஆங்கிலம் (ஐக்கிய முடியரசு)",
|
||||||
|
"English (auto-generated)": "ஆங்கிலம் (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Afrikaans": "ஆப்பிரிக்கா",
|
||||||
|
"Albanian": "அல்பேனிய",
|
||||||
|
"Amharic": "அம்ஆரிக்",
|
||||||
|
"Arabic": "அரபு",
|
||||||
|
"Armenian": "ஆர்மீனியன்",
|
||||||
|
"Azerbaijani": "அசர்பைசானி",
|
||||||
|
"Bangla": "பாங்லா",
|
||||||
|
"Basque": "பாச்க்",
|
||||||
|
"Belarusian": "பெலாருசியன்",
|
||||||
|
"Bosnian": "போச்னிய",
|
||||||
|
"Bulgarian": "பல்கேரியன்",
|
||||||
|
"Burmese": "பர்மீச்",
|
||||||
|
"Cantonese (Hong Kong)": "கான்டோனீச் (ஆங்காங்)",
|
||||||
|
"Catalan": "கற்றலான்",
|
||||||
|
"Cebuano": "செபுவானோ",
|
||||||
|
"Chinese": "சீன",
|
||||||
|
"Chinese (China)": "சீன (சீனா)",
|
||||||
|
"Chinese (Hong Kong)": "சீன (ஆங்காங்)",
|
||||||
|
"Chinese (Simplified)": "சீன (எளிமைப்படுத்தப்பட்ட)",
|
||||||
|
"Chinese (Taiwan)": "சீன (தைவான்)",
|
||||||
|
"Chinese (Traditional)": "சீன (பாரம்பரிய)",
|
||||||
|
"Dutch": "டச்சு",
|
||||||
|
"Finnish": "பின்னிச்",
|
||||||
|
"French": "பிரஞ்சு",
|
||||||
|
"German (auto-generated)": "செர்மன் (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Greek": "கிரேக்கம்",
|
||||||
|
"Gujarati": "குசராத்தி",
|
||||||
|
"Haitian Creole": "ஐட்டிய கிரியோல்",
|
||||||
|
"Hungarian": "அங்கேரியன்",
|
||||||
|
"Icelandic": "ஐச்லாந்திய",
|
||||||
|
"Igbo": "இக்போ",
|
||||||
|
"Korean (auto-generated)": "கொரிய (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Macedonian": "மாசிடோனியன்",
|
||||||
|
"Malagasy": "மலகாசி",
|
||||||
|
"Maltese": "மால்டிச்",
|
||||||
|
"Maori": "மௌரி",
|
||||||
|
"Malayalam": "மலையாளம்",
|
||||||
|
"Marathi": "மராத்தி",
|
||||||
|
"Mongolian": "மங்கோலியன்",
|
||||||
|
"Nepali": "நேபாளி",
|
||||||
|
"Norwegian Bokmål": "நார்வேசியன் பொக்மால்",
|
||||||
|
"Nyanja": "நயன்சா",
|
||||||
|
"Russian": "ரச்ய",
|
||||||
|
"Russian (auto-generated)": "ரச்ய (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Samoan": "சமோவான்",
|
||||||
|
"Scottish Gaelic": "ச்கோட்டிச் கயாலிக்",
|
||||||
|
"Serbian": "செர்பிய",
|
||||||
|
"Shona": "சோனா",
|
||||||
|
"Sindhi": "சிந்தி",
|
||||||
|
"Somali": "சோமாலி",
|
||||||
|
"Southern Sotho": "தெற்கத்திய சோதோ",
|
||||||
|
"Spanish": "ச்பானிச்",
|
||||||
|
"Spanish (auto-generated)": "ச்பானிச் (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Sundanese": "சுந்தானியர்கள்",
|
||||||
|
"Swahili": "ச்வாஇலி",
|
||||||
|
"Swedish": "ச்வீடிச்",
|
||||||
|
"Tajik": "தசிக்",
|
||||||
|
"Tamil": "தமிழ்",
|
||||||
|
"Thai": "தாய்",
|
||||||
|
"Turkish": "துருக்கிய",
|
||||||
|
"Vietnamese": "வியட்நாமிய",
|
||||||
|
"Welsh": "வேல்ச்",
|
||||||
|
"Xhosa": "ஓசா",
|
||||||
|
"Yiddish": "யெட்டிச்",
|
||||||
|
"Yoruba": "யோருபா",
|
||||||
|
"Top": "மேலே",
|
||||||
|
"About": "பற்றி",
|
||||||
|
"View as playlist": "பிளேலிச்ட்டாக காண்க",
|
||||||
|
"Gaming": "கேமிங்",
|
||||||
|
"News": "செய்தி",
|
||||||
|
"Movies": "திரைப்படங்கள்",
|
||||||
|
"Download as: ": "என பதிவிறக்கவும்: ",
|
||||||
|
"Download is disabled": "பதிவிறக்கம் முடக்கப்பட்டுள்ளது",
|
||||||
|
"(edited)": "(திருத்தப்பட்டது)",
|
||||||
|
"YouTube comment permalink": "YouTube கருத்து பெர்மாலின்க்",
|
||||||
|
"`x` marked it with a ❤": "`x` அதை a உடன் குறித்தது",
|
||||||
|
"Video mode": "வீடியோ பயன்முறை",
|
||||||
|
"Playlists": "பிளேலிச்ட்கள்",
|
||||||
|
"search_filters_date_option_today": "இன்று",
|
||||||
|
"search_filters_date_option_week": "இந்த வாரம்",
|
||||||
|
"search_filters_date_option_month": "இந்த மாதம்",
|
||||||
|
"search_filters_type_option_channel": "வாய்க்கால்",
|
||||||
|
"search_filters_type_option_playlist": "பிளேலிச்ட்",
|
||||||
|
"search_filters_duration_label": "காலம்",
|
||||||
|
"search_filters_duration_option_none": "எந்த காலமும்",
|
||||||
|
"search_filters_duration_option_medium": "நடுத்தர (4 - 20 நிமிடங்கள்)",
|
||||||
|
"search_filters_duration_option_long": "நீண்ட (> 20 நிமிடங்கள்)",
|
||||||
|
"search_filters_features_label": "நற்பொருத்தங்கள்",
|
||||||
|
"search_filters_features_option_four_k": "எச்.சி.",
|
||||||
|
"search_filters_features_option_live": "நேரடி",
|
||||||
|
"search_filters_features_option_hd": "எச்டி",
|
||||||
|
"search_filters_features_option_subtitles": "வசன வரிகள்/சிசி",
|
||||||
|
"search_filters_features_option_c_commons": "கிரியேட்டிவ் காமன்ச்",
|
||||||
|
"search_filters_features_option_three_sixty": "360 °",
|
||||||
|
"search_filters_features_option_three_d": "ZD",
|
||||||
|
"search_filters_features_option_hdr": "எச்.டி.ஆர்",
|
||||||
|
"search_filters_features_option_location": "இடம்",
|
||||||
|
"search_filters_sort_option_relevance": "பொருத்தமானது",
|
||||||
|
"search_filters_sort_option_rating": "செயல்வரம்பு",
|
||||||
|
"Current version: ": "தற்போதைய பதிப்பு: ",
|
||||||
|
"next_steps_error_message": "அதன் பிறகு நீங்கள் முயற்சி செய்ய வேண்டும்: ",
|
||||||
|
"next_steps_error_message_refresh": "புதுப்பிப்பு",
|
||||||
|
"next_steps_error_message_go_to_youtube": "YouTube க்குச் செல்லுங்கள்",
|
||||||
|
"footer_donate_page": "நன்கொடை",
|
||||||
|
"footer_modfied_source_code": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு",
|
||||||
|
"adminprefs_modified_source_code_url_label": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு களஞ்சியத்திற்கு முகவரி",
|
||||||
|
"videoinfo_started_streaming_x_ago": "`X` முன்பு ச்ட்ரீமிங் செய்யத் தொடங்கியது",
|
||||||
|
"videoinfo_watch_on_youTube": "YouTube இல் பாருங்கள்",
|
||||||
|
"download_subtitles": "வசன வரிகள் - `x` (.vtt)",
|
||||||
|
"user_created_playlists": "`x` உருவாக்கியது பிளேலிச்ட்கள்",
|
||||||
|
"user_saved_playlists": "`x` சேமித்த பிளேலிச்ட்கள்",
|
||||||
|
"crash_page_before_reporting": "ஒரு பிழையைப் புகாரளிப்பதற்கு முன், உங்களிடம் இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்:",
|
||||||
|
"crash_page_switch_instance": "<a href = \"` x` \"> மற்றொரு நிகழ்வைப் பயன்படுத்த முயற்சித்தேன் </a>",
|
||||||
|
"crash_page_search_issue": "அறிவிலிமையத்தில் உள்ள <a href=\"`x`\"> தற்போதைய சிக்கல்களைத் தேடியது</a>",
|
||||||
|
"channel_tab_shorts_label": "குறுக்குகள்",
|
||||||
|
"channel_tab_streams_label": "லைவ்ச்ட்ரீம்கள்",
|
||||||
|
"carousel_go_to": "`X` ச்லைடு செல்லவும்",
|
||||||
|
"Popular": "புகழ்பெற்ற",
|
||||||
|
"Subscribe": "குழுசேர்",
|
||||||
|
"View channel on YouTube": "YouTube இல் சேனலைக் காண்க",
|
||||||
|
"Authorize token for `x`?": "`X` க்கு கிள்ளாக்கை அங்கீகரிக்கவா?",
|
||||||
|
"No": "இல்லை",
|
||||||
|
"Add to playlist: ": "பிளேலிச்ட்டில் சேர்க்கவும்: ",
|
||||||
|
"Answer": "பதில்",
|
||||||
|
"Search for videos": "வீடியோக்களைத் தேடுங்கள்",
|
||||||
|
"The Popular feed has been disabled by the administrator.": "பிரபலமான ஊட்டத்தை நிர்வாகியால் முடக்கப்பட்டுள்ளது.",
|
||||||
|
"generic_subscriptions_count": "{{count}} சந்தா",
|
||||||
|
"generic_subscriptions_count_plural": "{{count}} சந்தாக்கள்",
|
||||||
|
"generic_button_edit": "தொகு",
|
||||||
|
"generic_button_save": "சேமி",
|
||||||
|
"generic_button_cancel": "ரத்துசெய்",
|
||||||
|
"Import and Export Data": "தரவை இறக்குமதி செய்து ஏற்றுமதி செய்யுங்கள்",
|
||||||
|
"Import": "இறக்குமதி",
|
||||||
|
"Import NewPipe subscriptions (.json)": "நியூபிப்பிப் சந்தாக்களை இறக்குமதி செய்யுங்கள் (.json)",
|
||||||
|
"Export": "ஏற்றுமதி",
|
||||||
|
"Text CAPTCHA": "உரை கேப்ட்சா",
|
||||||
|
"Image CAPTCHA": "பட கேப்ட்சா",
|
||||||
|
"preferences_category_player": "பிளேயர் விருப்பத்தேர்வுகள்",
|
||||||
|
"preferences_video_loop_label": "எப்போதும் லூப்: ",
|
||||||
|
"preferences_continue_autoplay_label": "தன்னியக்க அடுத்த வீடியோ: ",
|
||||||
|
"preferences_listen_label": "இயல்பாக கேளுங்கள்: ",
|
||||||
|
"preferences_quality_option_dash": "கோடு (தகவமைப்பு தரம்)",
|
||||||
|
"preferences_quality_option_hd720": "HD720",
|
||||||
|
"preferences_quality_option_medium": "சராசரி",
|
||||||
|
"preferences_quality_option_small": "சிறிய",
|
||||||
|
"preferences_quality_dash_option_2160p": "2160 ப",
|
||||||
|
"preferences_quality_dash_option_1440p": "1440 ப",
|
||||||
|
"preferences_quality_dash_option_240p": "240 ப",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"reddit": "ரெடிட்",
|
||||||
|
"invidious": "வெகுவாக",
|
||||||
|
"preferences_extend_desc_label": "வீடியோ விளக்கத்தை தானாக நீட்டிக்கவும்: ",
|
||||||
|
"preferences_region_label": "உள்ளடக்க நாடு: ",
|
||||||
|
"preferences_player_style_label": "பிளேயர் ச்டைல்: ",
|
||||||
|
"Dark mode: ": "இருண்ட முறை: ",
|
||||||
|
"preferences_dark_mode_label": "தீம்: ",
|
||||||
|
"dark": "இருண்ட",
|
||||||
|
"preferences_automatic_instance_redirect_label": "தானியங்கி நிகழ்வு திசைதிருப்பல் (redirect.invidious.io க்கு குறைவடையும்): ",
|
||||||
|
"preferences_max_results_label": "ஊட்டத்தில் காட்டப்பட்டுள்ள வீடியோக்களின் எண்ணிக்கை: ",
|
||||||
|
"alphabetically - reverse": "அகரவரிசை - தலைகீழ்",
|
||||||
|
"channel name": "சேனல் பெயர்",
|
||||||
|
"channel name - reverse": "சேனல் பெயர் - தலைகீழ்",
|
||||||
|
"Only show latest video from channel: ": "சேனலில் இருந்து அண்மைக் கால வீடியோவைக் காட்டுங்கள்: ",
|
||||||
|
"Only show latest unwatched video from channel: ": "சேனலில் இருந்து அண்மைக் கால கவனிக்கப்படாத வீடியோவைக் காட்டுங்கள்: ",
|
||||||
|
"`x` uploaded a video": "`x` ஒரு வீடியோவைப் பதிவேற்றியது",
|
||||||
|
"Clear watch history": "தெளிவான கண்காணிப்பு வரலாறு",
|
||||||
|
"Log out": "விடுபதிகை",
|
||||||
|
"Source available here.": "சான்று இங்கே கிடைக்கிறது.",
|
||||||
|
"Delete playlist": "பிளேலிச்ட்டை நீக்கு",
|
||||||
|
"Create playlist": "பிளேலிச்ட்டை உருவாக்கவும்",
|
||||||
|
"Title": "தலைப்பு",
|
||||||
|
"Import/export data": "தரவு இறக்குமதி/ஏற்றுமதி",
|
||||||
|
"Change password": "கடவுச்சொல்லை மாற்றவும்",
|
||||||
|
"Manage tokens": "டோக்கன்களை நிர்வகிக்கவும்",
|
||||||
|
"Popular enabled: ": "பிரபலமான இயக்கப்பட்டது: ",
|
||||||
|
"tokens_count": "{{count}} கிள்ளாக்கு",
|
||||||
|
"tokens_count_plural": "{{count}} டோக்கன்கள்",
|
||||||
|
"Import/export": "இறக்குமதி/ஏற்றுமதி",
|
||||||
|
"unsubscribe": "குழுவிலகவும்",
|
||||||
|
"revoke": "ரத்து செய்யுங்கள்",
|
||||||
|
"Subscriptions": "சந்தாக்கள்",
|
||||||
|
"subscriptions_unseen_notifs_count": "{{count}} காணப்படாத அறிவிப்பு",
|
||||||
|
"subscriptions_unseen_notifs_count_plural": "{{count}} காணப்படாத அறிவிப்புகள்",
|
||||||
|
"Editing playlist `x`": "பிளேலிச்ட்டைத் திருத்துதல் `x`",
|
||||||
|
"playlist_button_add_items": "வீடியோக்களைச் சேர்க்கவும்",
|
||||||
|
"Show more": "மேலும் காட்டு",
|
||||||
|
"Show less": "குறைவாகக் காட்டு",
|
||||||
|
"Switch Invidious Instance": "அக்யோர்ட் உதாரணத்தை மாற்றவும்",
|
||||||
|
"search_message_no_results": "முடிவுகள் எதுவும் கிடைக்கவில்லை.",
|
||||||
|
"search_message_change_filters_or_query": "உங்கள் தேடல் வினவலை அகலப்படுத்த முயற்சிக்கவும்/அல்லது வடிப்பான்களை மாற்றவும்.",
|
||||||
|
"search_message_use_another_instance": "நீங்கள் <a href = \"` x` \"> மற்றொரு நிகழ்வில் தேடலாம் </a>.",
|
||||||
|
"Show annotations": "சிறுகுறிப்புகளைக் காட்டு",
|
||||||
|
"Genre: ": "வகை: ",
|
||||||
|
"License: ": "உரிமம்: ",
|
||||||
|
"Standard YouTube license": "நிலையான YouTube உரிமம்",
|
||||||
|
"Family friendly? ": "குடும்ப நட்பு? ",
|
||||||
|
"Wilson score: ": "வில்சன் மதிப்பெண்: ",
|
||||||
|
"Engagement: ": "நிச்சயதார்த்தம்: ",
|
||||||
|
"Whitelisted regions: ": "அனுமதிப்பட்டிய பகுதிகள்: ",
|
||||||
|
"Blacklisted regions: ": "தடுப்புப்பட்டியாக்கப்பட்ட பகுதிகள்: ",
|
||||||
|
"Music in this video": "இந்த வீடியோவில் இசை",
|
||||||
|
"Artist: ": "கலைஞர்: ",
|
||||||
|
"Song: ": "பாடல்: ",
|
||||||
|
"Album: ": "ஆல்பம்: ",
|
||||||
|
"Shared `x`": "பகிரப்பட்டது `x`",
|
||||||
|
"Premieres in `x`": "`X` இல் பிரீமியர்ச்",
|
||||||
|
"Premieres `x`": "பிரீமியர்ச் `x`",
|
||||||
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "ஆய்! நீங்கள் சாவாச்கிரிப்ட் முடக்கப்பட்டிருப்பது போல் தெரிகிறது. கருத்துகளைக் காண இங்கே சொடுக்கு செய்க, அவர்கள் ஏற்றுவதற்கு சிறிது நேரம் ஆகலாம் என்பதை நினைவில் கொள்ளுங்கள்.",
|
||||||
|
"View YouTube comments": "YouTube கருத்துகளைக் காண்க",
|
||||||
|
"View more comments on Reddit": "ரெடிட் குறித்த கூடுதல் கருத்துகளைக் காண்க",
|
||||||
|
"View `x` comments": {
|
||||||
|
"([^.,0-9]|^)1([^.,0-9]|$)": "`X` கருத்தைக் காண்க",
|
||||||
|
"": "`X` கருத்துகளைக் காண்க"
|
||||||
|
},
|
||||||
|
"View Reddit comments": "ரெடிட் கருத்துகளைக் காண்க",
|
||||||
|
"Hide replies": "பதில்களை மறைக்கவும்",
|
||||||
|
"Wrong username or password": "தவறான பயனர்பெயர் அல்லது கடவுச்சொல்",
|
||||||
|
"Password cannot be longer than 55 characters": "கடவுச்சொல் 55 எழுத்துகளை விட நீளமாக இருக்க முடியாது",
|
||||||
|
"Invidious Private Feed for `x`": "`X` க்கான மோசமான தனியார் ஊட்டம்",
|
||||||
|
"channel:`x`": "சேனல்: `x`",
|
||||||
|
"Deleted or invalid channel": "நீக்கப்பட்ட அல்லது தவறான சேனல்",
|
||||||
|
"comments_view_x_replies": "{{count}} பதிலைக் காண்க",
|
||||||
|
"comments_view_x_replies_plural": "{{count}} பதில்களைக் காண்க",
|
||||||
|
"`x` ago": "`x` முன்பு",
|
||||||
|
"Load more": "மேலும் ஏற்றவும்",
|
||||||
|
"Hidden field \"challenge\" is a required field": "மறைக்கப்பட்ட புலம் \"அறைகூவல்\" என்பது தேவையான புலம்",
|
||||||
|
"Hidden field \"token\" is a required field": "மறைக்கப்பட்ட புலம் \"கிள்ளாக்கு\" என்பது தேவையான புலம்",
|
||||||
|
"Corsican": "கார்சிகன்",
|
||||||
|
"Croatian": "குரோசியன்",
|
||||||
|
"Czech": "செக்",
|
||||||
|
"Danish": "டேனிச்",
|
||||||
|
"Dutch (auto-generated)": "டச்சு (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Esperanto": "எச்பெராண்டோ",
|
||||||
|
"Estonian": "எச்டோனிய",
|
||||||
|
"Filipino": "ஃபிலிபினோ",
|
||||||
|
"Filipino (auto-generated)": "பிலிப்பைன்ச் (தானாக உருவாக்கிய)",
|
||||||
|
"French (auto-generated)": "பிரஞ்சு (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Galician": "காலிசியன்",
|
||||||
|
"Georgian": "சார்சியன்",
|
||||||
|
"German": "செர்மன்",
|
||||||
|
"Hausa": "ஔசா",
|
||||||
|
"Lao": "லாவோ",
|
||||||
|
"Latin": "லத்தீன்",
|
||||||
|
"Latvian": "லாட்வியன்",
|
||||||
|
"Hawaiian": "அவாயியன்",
|
||||||
|
"Hebrew": "எபிரேய",
|
||||||
|
"Lithuanian": "லிதுவேனியன்",
|
||||||
|
"Hindi": "இந்தி",
|
||||||
|
"Hmong": "அமோங்",
|
||||||
|
"Indonesian": "இந்தோனேசிய",
|
||||||
|
"Indonesian (auto-generated)": "இந்தோனேசிய (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Interlingue": "இன்டர்லின்குய்",
|
||||||
|
"Irish": "ஐரிச்",
|
||||||
|
"Italian": "இத்தாலிய",
|
||||||
|
"Italian (auto-generated)": "இத்தாலியன் (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Japanese": "சப்பானியர்கள்",
|
||||||
|
"Japanese (auto-generated)": "சப்பானிய (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Javanese": "சாவானீச்",
|
||||||
|
"Kannada": "கன்னடா",
|
||||||
|
"Kazakh": "கசாக்",
|
||||||
|
"Khmer": "கெமர்",
|
||||||
|
"Korean": "கொரிய",
|
||||||
|
"Kurdish": "குர்திச்",
|
||||||
|
"Kyrgyz": "கிர்கிச்",
|
||||||
|
"Luxembourgish": "லக்சம்போர்கிச்",
|
||||||
|
"Malay": "மலாய்",
|
||||||
|
"Pashto": "பச்தோ",
|
||||||
|
"Persian": "பெர்சியன்",
|
||||||
|
"Polish": "போலீச்",
|
||||||
|
"Portuguese": "போர்த்துகீசியம்",
|
||||||
|
"Portuguese (auto-generated)": "போர்த்துகீசியம் (தானாக உருவாக்கிய)",
|
||||||
|
"generic_count_minutes": "{{count}} மணித்துளி",
|
||||||
|
"generic_count_minutes_plural": "{{count}} நிமிடங்கள்",
|
||||||
|
"generic_count_seconds": "{{count}} இரண்டாவது",
|
||||||
|
"generic_count_seconds_plural": "{{count}} வினாடிகள்",
|
||||||
|
"Fallback comments: ": "குறைவடையும் கருத்துரைகள்: ",
|
||||||
|
"Portuguese (Brazil)": "போர்த்துகீசியம் (பிரேசில்)",
|
||||||
|
"Punjabi": "பஞ்சாபி",
|
||||||
|
"Romanian": "ருமேனிய",
|
||||||
|
"Sinhala": "சிங்களம்",
|
||||||
|
"Slovak": "ச்லோவாக்",
|
||||||
|
"Slovenian": "ச்லோவேனியன்",
|
||||||
|
"Spanish (Latin America)": "ச்பானிச் (லத்தீன் அமெரிக்கா)",
|
||||||
|
"Spanish (Mexico)": "ச்பானிச் (மெக்சிகோ)",
|
||||||
|
"Spanish (Spain)": "ச்பானிச் (ச்பெயின்)",
|
||||||
|
"Telugu": "தெலுங்கு",
|
||||||
|
"Turkish (auto-generated)": "துருக்கிய (தானாக உருவாக்கிய)",
|
||||||
|
"Ukrainian": "உக்ரேனிய",
|
||||||
|
"Urdu": "உருது",
|
||||||
|
"Uzbek": "உச்பெக்",
|
||||||
|
"Vietnamese (auto-generated)": "வியட்நாமிய (தானாக உருவாக்கப்பட்ட)",
|
||||||
|
"Western Frisian": "மேற்கு ஃபிரிசியன்",
|
||||||
|
"Zulu": "சுலு",
|
||||||
|
"generic_count_years": "{{count}}} ஆண்டு",
|
||||||
|
"generic_count_years_plural": "{{count}} ஆண்டுகள்",
|
||||||
|
"generic_count_months": "{{count}} மாதம்",
|
||||||
|
"generic_count_months_plural": "{{count}} மாதங்கள்",
|
||||||
|
"generic_count_weeks": "{{count}}} வாரம்",
|
||||||
|
"generic_count_weeks_plural": "{{count}} வாரங்கள்",
|
||||||
|
"generic_count_days": "{{count}}} நாள்",
|
||||||
|
"generic_count_days_plural": "{{count}} நாட்கள்",
|
||||||
|
"generic_count_hours": "{{count}} மணிநேரம்",
|
||||||
|
"generic_count_hours_plural": "{{count}} மணிநேரம்",
|
||||||
|
"Search": "தேடல்",
|
||||||
|
"Rating: ": "மதிப்பீடு: ",
|
||||||
|
"preferences_locale_label": "மொழி: ",
|
||||||
|
"Default": "இயல்புநிலை",
|
||||||
|
"Music": "இசை",
|
||||||
|
"Download": "பதிவிறக்கம்",
|
||||||
|
"%A %B %-d, %Y": "%A %b %-d, %y",
|
||||||
|
"permalink": "பெர்மாலின்க்",
|
||||||
|
"Channel Sponsor": "சேனல் ஒப்புரவாளர்",
|
||||||
|
"Audio mode": "ஆடியோ பயன்முறை",
|
||||||
|
"search_filters_duration_option_short": "குறுகிய (<4 நிமிடங்கள்)",
|
||||||
|
"search_filters_title": "வடிப்பான்கள்",
|
||||||
|
"search_filters_date_label": "தேதி பதிவேற்றும் தேதி",
|
||||||
|
"search_filters_date_option_none": "எந்த தேதி",
|
||||||
|
"search_filters_date_option_hour": "கடைசி மணி",
|
||||||
|
"search_filters_date_option_year": "இந்த ஆண்டு",
|
||||||
|
"search_filters_type_label": "வகை",
|
||||||
|
"search_filters_type_option_all": "எந்த வகை",
|
||||||
|
"search_filters_type_option_video": "ஒளிதோற்றம்",
|
||||||
|
"search_filters_type_option_movie": "படம்",
|
||||||
|
"search_filters_type_option_show": "காட்டு",
|
||||||
|
"search_filters_features_option_vr180": "VR180",
|
||||||
|
"search_filters_features_option_purchased": "வாங்கப்பட்டது",
|
||||||
|
"search_filters_sort_label": "வரிசைப்படுத்தவும்",
|
||||||
|
"search_filters_sort_option_date": "பதிவேற்ற தேதி",
|
||||||
|
"search_filters_sort_option_views": "எண்ணிக்கை காண்க",
|
||||||
|
"search_filters_apply_button": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்களைப் பயன்படுத்துங்கள்",
|
||||||
|
"footer_documentation": "ஆவணப்படுத்துதல்",
|
||||||
|
"footer_source_code": "மூலக் குறியீடு",
|
||||||
|
"footer_original_source_code": "அசல் மூலக் குறியீடு",
|
||||||
|
"none": "எதுவுமில்லை",
|
||||||
|
"videoinfo_youTube_embed_link": "உட்பொதிக்கப்பட்டது",
|
||||||
|
"videoinfo_invidious_embed_link": "உட்பொதிப்பு இணைப்பு",
|
||||||
|
"Video unavailable": "வீடியோ கிடைக்கவில்லை",
|
||||||
|
"preferences_save_player_pos_label": "பிளேபேக் நிலையை சேமிக்கவும்: ",
|
||||||
|
"crash_page_you_found_a_bug": "நீங்கள் ஒரு பிழையை கண்டுபிடித்ததாகத் தெரிகிறது!",
|
||||||
|
"crash_page_refresh": "<a href = \"` x` \"> பக்கத்தை புதுப்பிக்க முயற்சித்தேன் </a>",
|
||||||
|
"crash_page_read_the_faq": "<a href = \"` x` \"> அடிக்கடி கேட்கப்படும் கேள்விகள் (கேள்விகள்) </a> ஐப் படியுங்கள்",
|
||||||
|
"crash_page_report_issue": "மேலே எதுவும் உதவவில்லை என்றால், தயவுசெய்து <a href = \"` x` \"> அறிவிலிமையம் </a> (முன்னுரிமை ஆங்கிலத்தில்) ஒரு புதிய சிக்கலைத் திறந்து உங்கள் செய்தியில் பின்வரும் உரையைச் சேர்க்கவும் (அந்த உரையை மொழிபெயர்க்க வேண்டாம்):",
|
||||||
|
"error_video_not_in_playlist": "கோரப்பட்ட வீடியோ இந்த பிளேலிச்ட்டில் இல்லை. <a href = \"` x` \"> பிளேலிச்ட் முகப்பு பக்கத்திற்கு இங்கே சொடுக்கு செய்க. </a>",
|
||||||
|
"channel_tab_videos_label": "வீடியோக்கள்",
|
||||||
|
"channel_tab_podcasts_label": "பாட்காச்ட்கள்",
|
||||||
|
"channel_tab_releases_label": "வெளியீடுகள்",
|
||||||
|
"channel_tab_playlists_label": "பிளேலிச்ட்கள்",
|
||||||
|
"channel_tab_community_label": "சமூகம்",
|
||||||
|
"channel_tab_channels_label": "சேனல்கள்",
|
||||||
|
"toggle_theme": "கருப்பொருளை மாற்றவும்",
|
||||||
|
"carousel_slide": "{{total}} இன் ச்லைடு {{current}}",
|
||||||
|
"carousel_skip": "கொணர்வி தவிர்க்கவும்"
|
||||||
|
}
|
1
locales/tok.json
Normal file
1
locales/tok.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
|
@ -496,5 +496,6 @@
|
||||||
"carousel_slide": "Sunum {{current}} / {{total}}",
|
"carousel_slide": "Sunum {{current}} / {{total}}",
|
||||||
"carousel_skip": "Kayar menüyü atla",
|
"carousel_skip": "Kayar menüyü atla",
|
||||||
"carousel_go_to": "`x` sunumuna git",
|
"carousel_go_to": "`x` sunumuna git",
|
||||||
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
|
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.",
|
||||||
|
"preferences_preload_label": "Video verilerini önceden yükle: "
|
||||||
}
|
}
|
||||||
|
|
|
@ -513,5 +513,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
|
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
|
||||||
"carousel_slide": "Слайд {{current}} з {{total}}",
|
"carousel_slide": "Слайд {{current}} з {{total}}",
|
||||||
"carousel_skip": "Пропустити карусель",
|
"carousel_skip": "Пропустити карусель",
|
||||||
"carousel_go_to": "Перейти до слайда `x`"
|
"carousel_go_to": "Перейти до слайда `x`",
|
||||||
|
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
|
||||||
|
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -479,5 +479,7 @@
|
||||||
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
|
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
|
||||||
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
|
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
|
||||||
"carousel_skip": "跳过图集",
|
"carousel_skip": "跳过图集",
|
||||||
"carousel_go_to": "转到图 `x`"
|
"carousel_go_to": "转到图 `x`",
|
||||||
|
"preferences_preload_label": "预加载视频数据: ",
|
||||||
|
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -479,5 +479,7 @@
|
||||||
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
|
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
|
||||||
"carousel_skip": "略過輪播",
|
"carousel_skip": "略過輪播",
|
||||||
"carousel_go_to": "跳到投影片 `x`",
|
"carousel_go_to": "跳到投影片 `x`",
|
||||||
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
|
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。",
|
||||||
|
"preferences_preload_label": "預先載入影片資訊 ",
|
||||||
|
"Filipino (auto-generated)": "菲律賓語(自動產生)"
|
||||||
}
|
}
|
||||||
|
|
2
mocks
2
mocks
|
@ -1 +1 @@
|
||||||
Subproject commit 11ec372f72747c09d48ffef04843f72be67d5b54
|
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872
|
97
scripts/minify-js.cr
Executable file
97
scripts/minify-js.cr
Executable file
|
@ -0,0 +1,97 @@
|
||||||
|
require "http"
|
||||||
|
require "colorize"
|
||||||
|
|
||||||
|
ESBUILD_VERSION = "0.25.0"
|
||||||
|
|
||||||
|
esbuild_os = ""
|
||||||
|
esbuild_arch = ""
|
||||||
|
|
||||||
|
# https://esbuild.github.io/getting-started/#other-ways-to-install
|
||||||
|
{% if flag?(:linux) %}
|
||||||
|
esbuild_os = "linux"
|
||||||
|
{% elsif flag?(:windows) %}
|
||||||
|
esbuild_os = "win32"
|
||||||
|
{% elsif flag?(:darwin) %}
|
||||||
|
esbuild_os = "darwin"
|
||||||
|
{% elsif flag?(:freebsd) %}
|
||||||
|
esbuild_os = "freebsd"
|
||||||
|
{% elsif flag?(:openbsd) %}
|
||||||
|
esbuild_os = "openbsd"
|
||||||
|
{% elsif flag?(:netbsd) %}
|
||||||
|
esbuild_os = "netbsd"
|
||||||
|
{% elsif flag?(:solaris) %}
|
||||||
|
esbuild_os = "sunos"
|
||||||
|
{% else %}
|
||||||
|
esbuild_os = "linux"
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
{% if flag?(:x86_64) %}
|
||||||
|
esbuild_arch = "x64"
|
||||||
|
{% elsif flag?(:arm64) %}
|
||||||
|
esbuild_arch = "arm64"
|
||||||
|
{% else %}
|
||||||
|
esbuild_arch = "x64"
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
tmp_dir_path = "#{Dir.tempdir}/invidious-esbuild-binary"
|
||||||
|
esbuild_tar_location = "#{tmp_dir_path}/esbuild-#{esbuild_os}-#{esbuild_os}-#{ESBUILD_VERSION}.tgz"
|
||||||
|
esbuild_binary_location = "#{tmp_dir_path}/package/bin/esbuild"
|
||||||
|
Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path
|
||||||
|
|
||||||
|
esbuild_url = "https://registry.npmjs.org/@esbuild/#{esbuild_os}-#{esbuild_arch}/-/#{esbuild_os}-#{esbuild_arch}-#{ESBUILD_VERSION}.tgz"
|
||||||
|
|
||||||
|
# Download esbuild binary
|
||||||
|
HTTP::Client.get(esbuild_url) do |response|
|
||||||
|
puts "Downloading esbuild from #{esbuild_url}"
|
||||||
|
data = response.body_io.gets_to_end
|
||||||
|
File.write(esbuild_tar_location, data)
|
||||||
|
|
||||||
|
`tar -vzxf '#{esbuild_tar_location}' -C '#{tmp_dir_path}'`
|
||||||
|
raise "Extraction for #{esbuild_tar_location} failed" if !$?.success?
|
||||||
|
puts "esbuild downloaded successfully"
|
||||||
|
end
|
||||||
|
|
||||||
|
files_to_minify = [
|
||||||
|
"_helpers.js",
|
||||||
|
"comments.js",
|
||||||
|
"embed.js",
|
||||||
|
"handlers.js",
|
||||||
|
"notifications.js",
|
||||||
|
"pagination.js",
|
||||||
|
"playlist_widget.js",
|
||||||
|
"post.js",
|
||||||
|
"sse.js",
|
||||||
|
"subscribe_widget.js",
|
||||||
|
"themes.js",
|
||||||
|
"watch.js",
|
||||||
|
"player.js",
|
||||||
|
"watched_indicator.js",
|
||||||
|
"watched_widget.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
files_to_minify.each do |file|
|
||||||
|
file_path = "assets/js/#{file}"
|
||||||
|
outdir = "assets/js/minified"
|
||||||
|
process_output = IO::Memory.new
|
||||||
|
|
||||||
|
process = Process.run("#{esbuild_binary_location}", error: process_output, args: [
|
||||||
|
file_path,
|
||||||
|
"--color=false",
|
||||||
|
"--sourcemap",
|
||||||
|
"--minify",
|
||||||
|
"--outdir=#{outdir}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.success?
|
||||||
|
puts "Minified #{file}".colorize(:green)
|
||||||
|
elsif !process.success?
|
||||||
|
puts "Failed to minify #{file}, esbuild exited with exit code #{process.exit_code}: #{process_output.to_s.split("\n").first}".colorize(:red)
|
||||||
|
raise Exception.new("All files have to be minified sucessfully in order to compile!")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Minify done!"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
`rm -rf #{tmp_dir_path}`
|
38
shard.lock
38
shard.lock
|
@ -2,39 +2,39 @@ version: 2.0
|
||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
git: https://github.com/crystal-ameba/ameba.git
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 1.6.1
|
version: 1.6.4
|
||||||
|
|
||||||
athena-negotiation:
|
athena-negotiation:
|
||||||
git: https://github.com/athena-framework/negotiation.git
|
git: https://github.com/athena-framework/negotiation.git
|
||||||
version: 0.1.1
|
version: 0.1.5
|
||||||
|
|
||||||
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.4
|
||||||
|
|
||||||
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.4.1
|
||||||
|
|
||||||
|
http_proxy:
|
||||||
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
|
version: 0.10.3
|
||||||
|
|
||||||
|
inotify:
|
||||||
|
git: https://github.com/petoem/inotify.cr.git
|
||||||
|
version: 1.0.3
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
git: https://github.com/kemalcr/kemal.git
|
git: https://github.com/kemalcr/kemal.git
|
||||||
version: 1.1.2
|
version: 1.6.0
|
||||||
|
|
||||||
kilt:
|
|
||||||
git: https://github.com/jeromegn/kilt.git
|
|
||||||
version: 0.6.1
|
|
||||||
|
|
||||||
pg:
|
pg:
|
||||||
git: https://github.com/will/crystal-pg.git
|
git: https://github.com/will/crystal-pg.git
|
||||||
version: 0.24.0
|
version: 0.28.0
|
||||||
|
|
||||||
pool:
|
|
||||||
git: https://github.com/ysbaddaden/pool.git
|
|
||||||
version: 0.2.4
|
|
||||||
|
|
||||||
protodec:
|
protodec:
|
||||||
git: https://github.com/iv-org/protodec.git
|
git: https://github.com/iv-org/protodec.git
|
||||||
|
@ -45,14 +45,14 @@ shards:
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
git: https://github.com/stefanwille/crystal-redis.git
|
git: https://github.com/jgaskins/redis.git
|
||||||
version: 2.9.1
|
version: 0.12.0
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
34
shard.yml
34
shard.yml
|
@ -1,27 +1,23 @@
|
||||||
name: invidious
|
name: invidious
|
||||||
version: 0.20.1
|
version: 2.20250314.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.6.0
|
||||||
kilt:
|
|
||||||
github: jeromegn/kilt
|
|
||||||
version: ~> 0.6.1
|
|
||||||
protodec:
|
protodec:
|
||||||
github: iv-org/protodec
|
github: iv-org/protodec
|
||||||
version: ~> 0.1.5
|
version: ~> 0.1.5
|
||||||
|
@ -29,7 +25,13 @@ dependencies:
|
||||||
github: athena-framework/negotiation
|
github: athena-framework/negotiation
|
||||||
version: ~> 0.1.1
|
version: ~> 0.1.1
|
||||||
redis:
|
redis:
|
||||||
github: stefanwille/crystal-redis
|
github: jgaskins/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 +41,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
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
# Overrides for Kemal's `content_for` macro in order to keep using
|
|
||||||
# kilt as it was before Kemal v1.1.1 (Kemal PR #618).
|
|
||||||
|
|
||||||
require "kemal"
|
|
||||||
require "kilt"
|
|
||||||
|
|
||||||
macro content_for(key, file = __FILE__)
|
|
||||||
%proc = ->() {
|
|
||||||
__kilt_io__ = IO::Memory.new
|
|
||||||
{{ yield }}
|
|
||||||
__kilt_io__.to_s
|
|
||||||
}
|
|
||||||
|
|
||||||
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc
|
|
||||||
nil
|
|
||||||
end
|
|
|
@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt
|
||||||
filesize = data.bytesize
|
filesize = data.bytesize
|
||||||
attachment(env, filename, disposition)
|
attachment(env, filename, disposition)
|
||||||
|
|
||||||
Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
|
Kemal.config.static_headers.try(&.call(env, file_path, filestat))
|
||||||
|
|
||||||
file = IO::Memory.new(data)
|
file = IO::Memory.new(data)
|
||||||
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
||||||
|
|
|
@ -17,12 +17,11 @@
|
||||||
require "digest/md5"
|
require "digest/md5"
|
||||||
require "file_utils"
|
require "file_utils"
|
||||||
|
|
||||||
# Require kemal, kilt, then our own overrides
|
# Require kemal, then our own overrides
|
||||||
require "kemal"
|
require "kemal"
|
||||||
require "kilt"
|
|
||||||
require "./ext/kemal_content_for.cr"
|
|
||||||
require "./ext/kemal_static_file_handler.cr"
|
require "./ext/kemal_static_file_handler.cr"
|
||||||
|
|
||||||
|
require "http_proxy"
|
||||||
require "athena-negotiation"
|
require "athena-negotiation"
|
||||||
require "openssl/hmac"
|
require "openssl/hmac"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
|
@ -32,6 +31,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/*"
|
||||||
|
@ -49,7 +49,8 @@ require "./invidious/channels/*"
|
||||||
require "./invidious/user/*"
|
require "./invidious/user/*"
|
||||||
require "./invidious/search/*"
|
require "./invidious/search/*"
|
||||||
require "./invidious/routes/**"
|
require "./invidious/routes/**"
|
||||||
require "./invidious/jobs/**"
|
require "./invidious/jobs/base_job"
|
||||||
|
require "./invidious/jobs/*"
|
||||||
|
|
||||||
# Declare the base namespace for invidious
|
# Declare the base namespace for invidious
|
||||||
module Invidious
|
module Invidious
|
||||||
|
@ -58,20 +59,30 @@ 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
|
||||||
REDIS_DB = Redis::PooledClient.new(unixsocket: CONFIG.redis_socket || nil, url: CONFIG.redis_url || nil)
|
|
||||||
|
|
||||||
if REDIS_DB.ping
|
ARCHIVE_URL = URI.parse("https://archive.org")
|
||||||
puts "Connected to redis"
|
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
|
||||||
end
|
REDDIT_URL = URI.parse("https://www.reddit.com")
|
||||||
ARCHIVE_URL = URI.parse("https://archive.org")
|
YT_URL = URI.parse("https://www.youtube.com")
|
||||||
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
|
PUBSUB_HOST_URL = CONFIG.pubsub_domain
|
||||||
REDDIT_URL = URI.parse("https://www.reddit.com")
|
HOST_URL = make_host_url(Kemal.config)
|
||||||
YT_URL = URI.parse("https://www.youtube.com")
|
|
||||||
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 +109,16 @@ 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)
|
||||||
|
|
||||||
|
COMPANION_POOL = [] of CompanionConnectionPool
|
||||||
|
|
||||||
|
CONFIG.invidious_companion.each do |companion|
|
||||||
|
COMPANION_POOL << CompanionConnectionPool.new(companion, capacity: CONFIG.pool_size)
|
||||||
|
end
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
Kemal.config.extra_options do |parser|
|
Kemal.config.extra_options do |parser|
|
||||||
parser.banner = "Usage: invidious [arguments]"
|
parser.banner = "Usage: invidious [arguments]"
|
||||||
|
@ -139,6 +160,15 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_log
|
||||||
# Check table integrity
|
# Check table integrity
|
||||||
Invidious::Database.check_integrity(CONFIG)
|
Invidious::Database.check_integrity(CONFIG)
|
||||||
|
|
||||||
|
# Minifies Invidious Javascript
|
||||||
|
{% if flag?(:minify_debug) || (flag?(:release) || flag?(:production)) && !flag?(:skip_minified_js) %}
|
||||||
|
{% puts "\nMinifying Invidious JavaScript\n" %}
|
||||||
|
{% puts run("../scripts/minify-js.cr").stringify %}
|
||||||
|
JS_PATH="js/minified"
|
||||||
|
{% else %}
|
||||||
|
JS_PATH="js"
|
||||||
|
{% end %}
|
||||||
|
|
||||||
{% if !flag?(:skip_videojs_download) %}
|
{% if !flag?(:skip_videojs_download) %}
|
||||||
# Resolve player dependencies. This is done at compile time.
|
# Resolve player dependencies. This is done at compile time.
|
||||||
#
|
#
|
||||||
|
@ -181,15 +211,24 @@ if CONFIG.popular_enabled
|
||||||
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
||||||
end
|
end
|
||||||
|
|
||||||
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
|
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32)
|
||||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
|
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
|
||||||
|
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
|
||||||
|
|
||||||
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
|
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
|
||||||
|
|
||||||
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
|
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
|
||||||
|
|
||||||
if CONFIG.external_videoplayback_proxy
|
if !CONFIG.tokens_server.empty?
|
||||||
Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
|
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
|
||||||
|
|
||||||
|
if CONFIG.invidious_companion.present?
|
||||||
|
Invidious::Jobs.register Invidious::Jobs::CheckBackend.new
|
||||||
|
else
|
||||||
|
LOGGER.info("jobs: Disabling CheckBackend job. invidious-companion and their respective external video playback proxies (if set on invidious-companion) will not be checked")
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Jobs.start_all
|
Invidious::Jobs.start_all
|
||||||
|
@ -214,8 +253,8 @@ error 500 do |env, ex|
|
||||||
error_template(500, ex)
|
error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
static_headers do |response|
|
static_headers do |env|
|
||||||
response.headers.add("Cache-Control", "max-age=2629800")
|
env.response.headers.add("Cache-Control", "max-age=2629800")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Init Kemal
|
# Init Kemal
|
||||||
|
@ -232,8 +271,6 @@ add_context_storage_type(Preferences)
|
||||||
add_context_storage_type(Invidious::User)
|
add_context_storage_type(Invidious::User)
|
||||||
|
|
||||||
Kemal.config.logger = LOGGER
|
Kemal.config.logger = LOGGER
|
||||||
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
|
|
||||||
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
|
|
||||||
Kemal.config.app_name = "Invidious"
|
Kemal.config.app_name = "Invidious"
|
||||||
|
|
||||||
# Use in kemal's production mode.
|
# Use in kemal's production mode.
|
||||||
|
@ -242,4 +279,16 @@ Kemal.config.app_name = "Invidious"
|
||||||
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
|
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
Kemal.run
|
Kemal.run do |config|
|
||||||
|
if socket_binding = CONFIG.socket_binding
|
||||||
|
File.delete?(socket_binding.path)
|
||||||
|
# Create a socket and set its desired permissions
|
||||||
|
server = UNIXServer.new(socket_binding.path)
|
||||||
|
perms = socket_binding.permissions.to_i(base: 8)
|
||||||
|
File.chmod(socket_binding.path, perms)
|
||||||
|
config.server.not_nil!.bind server
|
||||||
|
else
|
||||||
|
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
|
||||||
|
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
@ -249,11 +249,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
|
|
||||||
if was_insert
|
if was_insert
|
||||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
|
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
|
||||||
if CONFIG.enable_user_notifications
|
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
|
||||||
Invidious::Database::Users.add_notification(video)
|
|
||||||
else
|
|
||||||
Invidious::Database::Users.feed_needs_update(video)
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
|
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
|
||||||
end
|
end
|
||||||
|
@ -285,11 +281,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
||||||
if Time.utc - video.published > 1.minute
|
if Time.utc - video.published > 1.minute
|
||||||
was_insert = Invidious::Database::ChannelVideos.insert(video)
|
was_insert = Invidious::Database::ChannelVideos.insert(video)
|
||||||
if was_insert
|
if was_insert
|
||||||
if CONFIG.enable_user_notifications
|
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
|
||||||
Invidious::Database::Users.add_notification(video)
|
|
||||||
else
|
|
||||||
Invidious::Database::Users.feed_needs_update(video)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -44,3 +44,12 @@ def fetch_channel_releases(ucid, author, continuation)
|
||||||
end
|
end
|
||||||
return extract_items(initial_data, author, ucid)
|
return extract_items(initial_data, author, ucid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_channel_courses(ucid, author, continuation)
|
||||||
|
if continuation
|
||||||
|
initial_data = YoutubeAPI.browse(continuation)
|
||||||
|
else
|
||||||
|
initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
|
||||||
|
end
|
||||||
|
return extract_items(initial_data, author, ucid)
|
||||||
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -8,6 +8,13 @@ struct DBConfig
|
||||||
property dbname : String
|
property dbname : String
|
||||||
end
|
end
|
||||||
|
|
||||||
|
struct SocketBindingConfig
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
property path : String
|
||||||
|
property permissions : String
|
||||||
|
end
|
||||||
|
|
||||||
struct ConfigPreferences
|
struct ConfigPreferences
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
|
@ -45,6 +52,7 @@ struct ConfigPreferences
|
||||||
property vr_mode : Bool = true
|
property vr_mode : Bool = true
|
||||||
property show_nick : Bool = true
|
property show_nick : Bool = true
|
||||||
property save_player_pos : Bool = false
|
property save_player_pos : Bool = false
|
||||||
|
property enable_dearrow : Bool = false
|
||||||
|
|
||||||
def to_tuple
|
def to_tuple
|
||||||
{% begin %}
|
{% begin %}
|
||||||
|
@ -55,9 +63,34 @@ 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("")
|
||||||
|
|
||||||
|
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||||
|
property i2p_public_url : URI = URI.parse("")
|
||||||
|
|
||||||
|
property note : String = ""
|
||||||
|
property domain : Array(String) = [] of String
|
||||||
|
end
|
||||||
|
|
||||||
# Number of threads to use for crawling videos from channels (for updating subscriptions)
|
# Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||||
property channel_threads : Int32 = 1
|
property channel_threads : Int32 = 1
|
||||||
# Time interval between two executions of the job that crawls channel videos (subscriptions update).
|
# Time interval between two executions of the job that crawls channel videos (subscriptions update).
|
||||||
|
@ -75,8 +108,8 @@ class Config
|
||||||
# Database configuration using 12-Factor "Database URL" syntax
|
# Database configuration using 12-Factor "Database URL" syntax
|
||||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||||
property database_url : URI = URI.parse("")
|
property database_url : URI = URI.parse("")
|
||||||
property redis_url : String?
|
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||||
property redis_socket : String?
|
property redis_url : URI = URI.parse("")
|
||||||
# Use polling to keep decryption function up to date
|
# Use polling to keep decryption function up to date
|
||||||
property decrypt_polling : Bool = false
|
property decrypt_polling : Bool = false
|
||||||
# Used for crawling channels: threads should check all videos uploaded by a channel
|
# Used for crawling channels: threads should check all videos uploaded by a channel
|
||||||
|
@ -87,18 +120,18 @@ class Config
|
||||||
|
|
||||||
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||||
property https_only : Bool?
|
property https_only : Bool?
|
||||||
|
# Enable or disable CSP
|
||||||
|
property csp : Bool? = true
|
||||||
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||||
property hmac_key : String = ""
|
property hmac_key : String = ""
|
||||||
# Domain to be used for links to resources on the site where an absolute URL is required
|
# Domain to be used for links to resources on the site where an absolute URL is required
|
||||||
property domain : String?
|
property domain : String?
|
||||||
# Alternative domains. You can add other domains, like TOR and I2P addresses
|
# Materialious redirects
|
||||||
property alternative_domains : Array(String) = [] of String
|
property materialious_domain : String?
|
||||||
property donation_url : String?
|
|
||||||
property contact_url : String?
|
|
||||||
property home_domain : String?
|
|
||||||
|
|
||||||
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||||
property use_pubsub_feeds : Bool | Int32 = false
|
property use_pubsub_feeds : Bool | Int32 = false
|
||||||
|
property use_innertube_for_feeds : Bool = true
|
||||||
property popular_enabled : Bool = true
|
property popular_enabled : Bool = true
|
||||||
property captcha_enabled : Bool = true
|
property captcha_enabled : Bool = true
|
||||||
property login_enabled : Bool = true
|
property login_enabled : Bool = true
|
||||||
|
@ -149,8 +182,12 @@ class Config
|
||||||
property port : Int32 = 3000
|
property port : Int32 = 3000
|
||||||
# Host to bind (overridden by command line argument)
|
# Host to bind (overridden by command line argument)
|
||||||
property host_binding : String = "0.0.0.0"
|
property host_binding : String = "0.0.0.0"
|
||||||
|
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
|
||||||
|
property socket_binding : SocketBindingConfig? = nil
|
||||||
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||||
property pool_size : Int32 = 100
|
property pool_size : Int32 = 100
|
||||||
|
# HTTP Proxy configuration
|
||||||
|
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 +197,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 +219,34 @@ class Config
|
||||||
# of the backend
|
# of the backend
|
||||||
property backends_delimiter : String = "|"
|
property backends_delimiter : String = "|"
|
||||||
|
|
||||||
property external_videoplayback_proxy : String?
|
property pubsub_domain : String = ""
|
||||||
|
|
||||||
# Materialious redirects
|
property server_id_cookie_name : String = "COMPANION_ID"
|
||||||
property materialious_domain : String?
|
|
||||||
|
property tokens_server : String = ""
|
||||||
|
|
||||||
|
property video_cache : VideoCacheConfig
|
||||||
|
|
||||||
|
class VideoCacheConfig
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
property enabled : Bool = true
|
||||||
|
property backend : Int32 = 1
|
||||||
|
# Max quantity of keys that can be held on the LRU cache
|
||||||
|
property lru_max_size : Int32 = 18432 # ~512MB
|
||||||
|
end
|
||||||
|
|
||||||
|
property check_backends_interval : Int32 = 30
|
||||||
|
|
||||||
|
property force_local : Bool = true
|
||||||
|
|
||||||
|
property disable_livestreams : Bool = true
|
||||||
|
|
||||||
|
property max_popuplar_results : Int32 = 40
|
||||||
|
|
||||||
|
{% if flag?(:linux) %}
|
||||||
|
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 +263,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 +332,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 +371,40 @@ class Config
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Warn when any config attribute is set to "CHANGE_ME!!"
|
||||||
|
if config.{{ivar.id}} == "CHANGE_ME!!"
|
||||||
|
puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
|
if config.invidious_companion.present?
|
||||||
|
# invidious_companion and signature_server can't work together
|
||||||
|
if config.signature_server
|
||||||
|
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key.empty?
|
||||||
|
puts "Config: Please configure a key if you are using invidious companion."
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key == "CHANGE_ME!!"
|
||||||
|
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
|
||||||
|
exit(1)
|
||||||
|
elsif config.invidious_companion_key.size != 16
|
||||||
|
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
elsif config.signature_server
|
||||||
|
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
|
||||||
|
else
|
||||||
|
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/")
|
||||||
|
end
|
||||||
|
|
||||||
# HMAC_key is mandatory
|
# HMAC_key is mandatory
|
||||||
# See: https://github.com/iv-org/invidious/issues/3854
|
# See: https://github.com/iv-org/invidious/issues/3854
|
||||||
if config.hmac_key.empty?
|
if config.hmac_key.empty?
|
||||||
puts "Config: 'hmac_key' is required/can't be empty"
|
puts "Config: 'hmac_key' is required/can't be empty"
|
||||||
exit(1)
|
exit(1)
|
||||||
elsif config.hmac_key == "CHANGE_ME!!"
|
|
||||||
puts "Config: The value of 'hmac_key' needs to be changed!!"
|
|
||||||
exit(1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build database_url from db.* if it's not set directly
|
# Build database_url from db.* if it's not set directly
|
||||||
|
@ -272,6 +424,33 @@ class Config
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if config.video_cache.enabled
|
||||||
|
if !config.video_cache.backend.in?(0, 1, 2)
|
||||||
|
puts "Config: 'video_cache_storage', can only be:"
|
||||||
|
puts "0 (PostgreSQL)"
|
||||||
|
puts "1 (Redis compatible DB) (Default)"
|
||||||
|
puts "2 (In memory LRU)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the socket configuration is valid
|
||||||
|
if sb = config.socket_binding
|
||||||
|
if sb.path.ends_with?("/") || File.directory?(sb.path)
|
||||||
|
puts "Config: The socket path " + sb.path + " must not be a directory!"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
d = File.dirname(sb.path)
|
||||||
|
if !File.directory?(d)
|
||||||
|
puts "Config: Socket directory " + sb.path + " does not exist or is not a directory!"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
p = sb.permissions.to_i?(base: 8)
|
||||||
|
if !p || p < 0 || p > 0o777
|
||||||
|
puts "Config: Socket permissions must be an octal between 0 and 777!"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -149,7 +149,7 @@ module Invidious::Database::ChannelVideos
|
||||||
SELECT DISTINCT ON (ucid) *
|
SELECT DISTINCT ON (ucid) *
|
||||||
FROM channel_videos
|
FROM channel_videos
|
||||||
WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
|
WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
|
||||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
|
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT #{CONFIG.max_popuplar_results})
|
||||||
ORDER BY ucid, published DESC
|
ORDER BY ucid, published DESC
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
|
|
|
@ -119,15 +119,15 @@ module Invidious::Database::Users
|
||||||
# Update (notifs)
|
# Update (notifs)
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
def add_notification(video : ChannelVideo)
|
def add_multiple_notifications(channel_id : String, video_ids : Array(String))
|
||||||
request = <<-SQL
|
request = <<-SQL
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET notifications = array_append(notifications, $1),
|
SET notifications = array_cat(notifications, $1),
|
||||||
feed_needs_update = true
|
feed_needs_update = true
|
||||||
WHERE $2 = ANY(subscriptions)
|
WHERE $2 = ANY(subscriptions)
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
PG_DB.exec(request, video.id, video.ucid)
|
PG_DB.exec(request, video_ids, channel_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_notification(user : User, vid : String)
|
def remove_notification(user : User, vid : String)
|
||||||
|
@ -154,14 +154,14 @@ module Invidious::Database::Users
|
||||||
# Update (misc)
|
# Update (misc)
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
def feed_needs_update(video : ChannelVideo)
|
def feed_needs_update(channel_id : String)
|
||||||
request = <<-SQL
|
request = <<-SQL
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET feed_needs_update = true
|
SET feed_needs_update = true
|
||||||
WHERE $1 = ANY(subscriptions)
|
WHERE $1 = ANY(subscriptions)
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
PG_DB.exec(request, video.ucid)
|
PG_DB.exec(request, channel_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_preferences(user : User)
|
def update_preferences(user : User)
|
||||||
|
@ -184,6 +184,36 @@ module Invidious::Database::Users
|
||||||
PG_DB.exec(request, pass, user.email)
|
PG_DB.exec(request, pass, user.email)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_username(user : User, username : String)
|
||||||
|
request = <<-SQL
|
||||||
|
UPDATE users
|
||||||
|
SET email = $1
|
||||||
|
WHERE email = $2
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PG_DB.exec(request, username, user.email)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_user_session_id(user : User, username : String)
|
||||||
|
request = <<-SQL
|
||||||
|
UPDATE session_ids
|
||||||
|
SET email = $1
|
||||||
|
WHERE email = $2
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PG_DB.exec(request, username, user.email)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_user_playlists_author(user : User, username : String)
|
||||||
|
request = <<-SQL
|
||||||
|
UPDATE playlists
|
||||||
|
SET author = $1
|
||||||
|
WHERE author = $2
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PG_DB.exec(request, username, user.email)
|
||||||
|
end
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# Select
|
# Select
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
|
@ -1,27 +1,181 @@
|
||||||
require "./base.cr"
|
require "./base.cr"
|
||||||
|
require "redis"
|
||||||
|
|
||||||
|
VideoCache = Invidious::Database::Videos::Cache.new
|
||||||
|
|
||||||
module Invidious::Database::Videos
|
module Invidious::Database::Videos
|
||||||
|
class Cache
|
||||||
|
def initialize
|
||||||
|
case CONFIG.video_cache.backend
|
||||||
|
when 0
|
||||||
|
@cache = CacheMethods::PostgresSQL.new
|
||||||
|
when 1
|
||||||
|
@cache = CacheMethods::Redis_.new
|
||||||
|
when 2
|
||||||
|
@cache = CacheMethods::LRU.new
|
||||||
|
else
|
||||||
|
LOGGER.debug "Video Cache: Using default cache method to store video cache (PostgreSQL)"
|
||||||
|
@cache = CacheMethods::PostgresSQL.new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set(video : Video, expire_time)
|
||||||
|
@cache.set(video, expire_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def del(id : String)
|
||||||
|
@cache.del(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(id : String)
|
||||||
|
return @cache.get(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module CacheMethods
|
||||||
|
# TODO: Save the cache on a file with a Job
|
||||||
|
class LRU
|
||||||
|
@max_size : Int32
|
||||||
|
@lru = {} of String => String
|
||||||
|
@access = [] of String
|
||||||
|
|
||||||
|
def initialize(@max_size = CONFIG.video_cache.lru_max_size)
|
||||||
|
LOGGER.info "Video Cache: Using in memory LRU to store video cache"
|
||||||
|
LOGGER.info "Video Cache, LRU: LRU cache max size set to #{@max_size}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Handle expire_time with a Job
|
||||||
|
def set(video : Video, expire_time)
|
||||||
|
self[video.id] = video.info.to_json
|
||||||
|
self[video.id + ":time"] = "#{video.updated}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def del(id : String)
|
||||||
|
self.delete(id)
|
||||||
|
self.delete(id + ":time")
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(id : String)
|
||||||
|
info = self[id]
|
||||||
|
time = self[id + ":time"]
|
||||||
|
if info && time
|
||||||
|
return Video.new({
|
||||||
|
id: id,
|
||||||
|
info: JSON.parse(info).as_h,
|
||||||
|
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def [](key)
|
||||||
|
if @lru[key]?
|
||||||
|
@access.delete(key)
|
||||||
|
@access.push(key)
|
||||||
|
@lru[key]
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def []=(key, value)
|
||||||
|
if @lru.size >= @max_size
|
||||||
|
lru_key = @access.shift
|
||||||
|
@lru.delete(lru_key)
|
||||||
|
end
|
||||||
|
@lru[key] = value
|
||||||
|
@access.push(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def delete(key)
|
||||||
|
if @lru[key]?
|
||||||
|
@lru.delete(key)
|
||||||
|
@access.delete(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Redis_
|
||||||
|
@redis : Redis::Client
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@redis = Redis::Client.new(CONFIG.redis_url)
|
||||||
|
LOGGER.info "Video Cache: Using Redis compatible DB to store video cache"
|
||||||
|
LOGGER.info "Connecting to Redis compatible DB"
|
||||||
|
if @redis.ping
|
||||||
|
LOGGER.info "Connected to Redis compatible DB at '#{CONFIG.redis_url}'" if CONFIG.redis_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set(video : Video, expire_time)
|
||||||
|
@redis.set(video.id, video.info.to_json, ex: expire_time)
|
||||||
|
@redis.set(video.id + ":time", video.updated.to_s, ex: expire_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def del(id : String)
|
||||||
|
@redis.del(id)
|
||||||
|
@redis.del(id + ":time")
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(id : String)
|
||||||
|
info = @redis.get(id)
|
||||||
|
time = @redis.get(id + ":time")
|
||||||
|
if info && time
|
||||||
|
return Video.new({
|
||||||
|
id: id,
|
||||||
|
info: JSON.parse(info).as_h,
|
||||||
|
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class PostgresSQL
|
||||||
|
def initialize
|
||||||
|
LOGGER.info "Video Cache: Using PostgreSQL to store video cache"
|
||||||
|
end
|
||||||
|
|
||||||
|
def set(video : Video, expire_time)
|
||||||
|
request = <<-SQL
|
||||||
|
INSERT INTO videos
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PG_DB.exec(request, video.id, video.info.to_json, video.updated)
|
||||||
|
end
|
||||||
|
|
||||||
|
def del(id)
|
||||||
|
request = <<-SQL
|
||||||
|
DELETE FROM videos *
|
||||||
|
WHERE id = $1
|
||||||
|
SQL
|
||||||
|
|
||||||
|
PG_DB.exec(request, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(id : String) : Video?
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT * FROM videos
|
||||||
|
WHERE id = $1
|
||||||
|
SQL
|
||||||
|
|
||||||
|
return PG_DB.query_one?(request, id, as: Video)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
def insert(video : Video)
|
def insert(video : Video)
|
||||||
request = <<-SQL
|
VideoCache.set(video: video, expire_time: 14400) if CONFIG.video_cache.enabled
|
||||||
INSERT INTO videos
|
|
||||||
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
|
VideoCache.del(id)
|
||||||
DELETE FROM videos *
|
|
||||||
WHERE id = $1
|
|
||||||
SQL
|
|
||||||
|
|
||||||
REDIS_DB.del(id)
|
|
||||||
REDIS_DB.del(id + ":time")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_expired
|
def delete_expired
|
||||||
|
@ -44,19 +198,6 @@ module Invidious::Database::Videos
|
||||||
end
|
end
|
||||||
|
|
||||||
def select(id : String) : Video?
|
def select(id : String) : Video?
|
||||||
request = <<-SQL
|
return VideoCache.get(id)
|
||||||
SELECT * FROM videos
|
|
||||||
WHERE id = $1
|
|
||||||
SQL
|
|
||||||
|
|
||||||
if ((info = REDIS_DB.get(id)) && (time = REDIS_DB.get(id + ":time")))
|
|
||||||
return Video.new({
|
|
||||||
id: id,
|
|
||||||
info: JSON.parse(info).as_h,
|
|
||||||
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
|
||||||
})
|
|
||||||
else
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,8 +7,9 @@ module Invidious::Frontend::ChannelPage
|
||||||
Streams
|
Streams
|
||||||
Podcasts
|
Podcasts
|
||||||
Releases
|
Releases
|
||||||
|
Courses
|
||||||
Playlists
|
Playlists
|
||||||
Community
|
Posts
|
||||||
Channels
|
Channels
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,24 @@ require "uri"
|
||||||
module Invidious::Frontend::Pagination
|
module Invidious::Frontend::Pagination
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
|
private def first_page(str : String::Builder, locale : String?, url : String)
|
||||||
|
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
|
||||||
|
|
||||||
|
if locale_is_rtl?(locale)
|
||||||
|
# Inverted arrow ("first" points to the right)
|
||||||
|
str << translate(locale, "First page")
|
||||||
|
str << " "
|
||||||
|
str << %(<i class="icon ion-ios-arrow-forward"></i>)
|
||||||
|
else
|
||||||
|
# Regular arrow ("first" points to the left)
|
||||||
|
str << %(<i class="icon ion-ios-arrow-back"></i>)
|
||||||
|
str << " "
|
||||||
|
str << translate(locale, "First page")
|
||||||
|
end
|
||||||
|
|
||||||
|
str << "</a>"
|
||||||
|
end
|
||||||
|
|
||||||
private def previous_page(str : String::Builder, locale : String?, url : String)
|
private def previous_page(str : String::Builder, locale : String?, url : String)
|
||||||
# Link
|
# Link
|
||||||
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
|
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
|
||||||
|
@ -72,18 +90,24 @@ module Invidious::Frontend::Pagination
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
|
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params)
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
str << %(<div class="h-box">\n)
|
str << %(<div class="h-box">\n)
|
||||||
str << %(<div class="page-nav-container flexible">\n)
|
str << %(<div class="page-nav-container flexible">\n)
|
||||||
|
|
||||||
str << %(<div class="page-prev-container flex-left"></div>\n)
|
str << %(<div class="page-prev-container flex-left">)
|
||||||
|
|
||||||
|
if !first_page
|
||||||
|
self.first_page(str, locale, base_url.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
str << %(</div>\n)
|
||||||
|
|
||||||
str << %(<div class="page-next-container flex-right">)
|
str << %(<div class="page-next-container flex-right">)
|
||||||
|
|
||||||
if !ctoken.nil?
|
if !ctoken.nil?
|
||||||
params_next = URI::Params{"continuation" => ctoken}
|
params["continuation"] = ctoken
|
||||||
url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
|
url_next = HttpServer::Utils.add_params_to_url(base_url, params)
|
||||||
|
|
||||||
self.next_page(str, locale, url_next.to_s)
|
self.next_page(str, locale, url_next.to_s)
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
85
src/invidious/helpers/backend_info.cr
Normal file
85
src/invidious/helpers/backend_info.cr
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
module BackendInfo
|
||||||
|
extend self
|
||||||
|
@@exvpp_url : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
|
||||||
|
@@status : Array(Int32) = Array.new(CONFIG.invidious_companion.size, 0)
|
||||||
|
@@csp : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
|
||||||
|
@@mutex : Mutex = Mutex.new
|
||||||
|
|
||||||
|
def check_backends
|
||||||
|
check_companion()
|
||||||
|
end
|
||||||
|
|
||||||
|
private def check_companion
|
||||||
|
CONFIG.invidious_companion.each_with_index do |companion, index|
|
||||||
|
spawn do
|
||||||
|
begin
|
||||||
|
response = HTTP::Client.get "#{companion.private_url}/healthz"
|
||||||
|
if response.status_code == 200
|
||||||
|
check_videoplayback_proxy(companion, index)
|
||||||
|
generate_csp([companion.public_url.to_s, companion.i2p_public_url.to_s], @@exvpp_url[index], index)
|
||||||
|
else
|
||||||
|
@@status[index] = 0
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
@@status[index] = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def check_videoplayback_proxy(companion : Config::CompanionConfig, index : Int32)
|
||||||
|
begin
|
||||||
|
info = HTTP::Client.get "#{companion.private_url}/info"
|
||||||
|
exvpp_url = JSON.parse(info.body)["external_videoplayback_proxy"]?.try &.to_s
|
||||||
|
rescue JSON::ParseException
|
||||||
|
@@status[index] = 2
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
exvpp_url = "" if exvpp_url.nil?
|
||||||
|
@@exvpp_url[index] = exvpp_url
|
||||||
|
if exvpp_url.empty?
|
||||||
|
@@status[index] = 2
|
||||||
|
return
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
exvpp_health = HTTP::Client.get "#{exvpp_url}/health"
|
||||||
|
if exvpp_health.status_code == 200
|
||||||
|
@@status[index] = 2
|
||||||
|
return exvpp_url
|
||||||
|
else
|
||||||
|
@@status[index] = 1
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
@@status[index] = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def generate_csp(companion_url : Array(String), exvpp_url : String? = nil, index : Int32? = nil)
|
||||||
|
@@mutex.synchronize do
|
||||||
|
@@csp[index] = ""
|
||||||
|
companion_url.each do |url|
|
||||||
|
@@csp[index] += " #{url}"
|
||||||
|
end
|
||||||
|
@@csp[index] += " #{exvpp_url}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_status
|
||||||
|
return @@status
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_exvpp
|
||||||
|
return @@exvpp_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_csp(index : Int32)
|
||||||
|
# A little mutex to prevent sending a partial CSP header
|
||||||
|
# Not sure if this is necessary. But if the @@csp[index] is being assigned
|
||||||
|
# at the same time when it's being accessed, a data race will appear
|
||||||
|
@@mutex.synchronize do
|
||||||
|
return @@csp[index], @@csp[index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -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,8 +180,11 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
||||||
next_steps_text = translate(locale, "next_steps_error_message")
|
next_steps_text = translate(locale, "next_steps_error_message")
|
||||||
refresh = translate(locale, "next_steps_error_message_refresh")
|
refresh = translate(locale, "next_steps_error_message_refresh")
|
||||||
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
|
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
|
||||||
|
go_to_youtube_embed = translate(locale, "videoinfo_youTube_embed_link")
|
||||||
switch_instance = translate(locale, "Switch Invidious Instance")
|
switch_instance = translate(locale, "Switch Invidious Instance")
|
||||||
|
|
||||||
|
show_embed_link = "(<a rel=\"noreferrer noopener\" href=\"https://youtube.com/embed/#{env.params.query["v"]}\">#{go_to_youtube_embed}</a>)" if env.params.query["v"]?
|
||||||
|
|
||||||
return <<-END_HTML
|
return <<-END_HTML
|
||||||
<p style="margin-bottom: 4px;">#{next_steps_text}</p>
|
<p style="margin-bottom: 4px;">#{next_steps_text}</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -193,6 +196,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>
|
||||||
|
#{show_embed_link}
|
||||||
</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
|
||||||
|
@ -39,6 +54,7 @@ LOCALES_LIST = {
|
||||||
"sr" => "Srpski (latinica)", # Serbian (Latin)
|
"sr" => "Srpski (latinica)", # Serbian (Latin)
|
||||||
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
|
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
|
||||||
"sv-SE" => "Svenska", # Swedish
|
"sv-SE" => "Svenska", # Swedish
|
||||||
|
"ta" => "தமிழ்", # Tamil
|
||||||
"tr" => "Türkçe", # Turkish
|
"tr" => "Türkçe", # Turkish
|
||||||
"uk" => "Українська", # Ukrainian
|
"uk" => "Українська", # Ukrainian
|
||||||
"vi" => "Tiếng Việt", # Vietnamese
|
"vi" => "Tiếng Việt", # Vietnamese
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -56,12 +56,11 @@ macro templated(_filename, template = "template", navbar_search = true, buffer_f
|
||||||
{{ layout = "src/invidious/views/" + template + ".ecr" }}
|
{{ layout = "src/invidious/views/" + template + ".ecr" }}
|
||||||
|
|
||||||
__content_filename__ = {{filename}}
|
__content_filename__ = {{filename}}
|
||||||
content = Kilt.render({{filename}})
|
render {{filename}}, {{layout}}
|
||||||
Kilt.render({{layout}})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
macro rendered(filename)
|
macro rendered(filename)
|
||||||
Kilt.render("src/invidious/views/#{{{filename}}}.ecr")
|
render("src/invidious/views/#{{{filename}}}.ecr")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Similar to Kemals halt method but works in a
|
# Similar to Kemals halt method but works in a
|
||||||
|
|
|
@ -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,45 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
|
||||||
end
|
end
|
||||||
return text
|
return text
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def decrypt_ecb_without_salt(data, key)
|
||||||
|
cipher = OpenSSL::Cipher.new("aes-128-ecb")
|
||||||
|
cipher.decrypt
|
||||||
|
cipher.key = key
|
||||||
|
cipher.padding = false
|
||||||
|
|
||||||
|
io = IO::Memory.new
|
||||||
|
io.write(cipher.update(data))
|
||||||
|
io.write(cipher.final)
|
||||||
|
io.rewind
|
||||||
|
|
||||||
|
data_ = io.to_s
|
||||||
|
padding = data_[-1].ord
|
||||||
|
|
||||||
|
return data_[0...(data_.bytesize - padding)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_playback_decrypt(data)
|
||||||
|
data = Base64.decode(data)
|
||||||
|
decrypted_query = decrypt_ecb_without_salt(data, CONFIG.invidious_companion_key)
|
||||||
|
return decrypted_query
|
||||||
|
end
|
||||||
|
|
||||||
|
def encrypt_ecb_without_salt(data, key)
|
||||||
|
cipher = OpenSSL::Cipher.new("aes-128-ecb")
|
||||||
|
cipher.encrypt
|
||||||
|
cipher.key = key
|
||||||
|
|
||||||
|
io = IO::Memory.new
|
||||||
|
io.write(cipher.update(data))
|
||||||
|
io.write(cipher.final)
|
||||||
|
io.rewind
|
||||||
|
|
||||||
|
return io
|
||||||
|
end
|
||||||
|
|
||||||
|
def invidious_companion_encrypt(data)
|
||||||
|
timestamp = Time.utc.to_unix
|
||||||
|
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
|
||||||
|
return Base64.urlsafe_encode(encrypted_data)
|
||||||
|
end
|
||||||
|
|
13
src/invidious/jobs/backend_checker.cr
Normal file
13
src/invidious/jobs/backend_checker.cr
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class Invidious::Jobs::CheckBackend < Invidious::Jobs::BaseJob
|
||||||
|
def initialize
|
||||||
|
end
|
||||||
|
|
||||||
|
def begin
|
||||||
|
loop do
|
||||||
|
BackendInfo.check_backends
|
||||||
|
LOGGER.info("Backend Checker: Done, sleeping for #{CONFIG.check_backends_interval} seconds")
|
||||||
|
sleep CONFIG.check_backends_interval.seconds
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,13 +0,0 @@
|
||||||
class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
|
|
||||||
def initialize
|
|
||||||
end
|
|
||||||
|
|
||||||
def begin
|
|
||||||
loop do
|
|
||||||
Invidious::Routes::API::Manifest.check_external_proxy
|
|
||||||
LOGGER.info("CheckExternalProxy: Done, sleeping for 1 minute")
|
|
||||||
sleep 1.minutes
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,8 +1,32 @@
|
||||||
|
struct VideoNotification
|
||||||
|
getter video_id : String
|
||||||
|
getter channel_id : String
|
||||||
|
getter published : Time
|
||||||
|
|
||||||
|
def_hash @channel_id, @video_id
|
||||||
|
|
||||||
|
def ==(other)
|
||||||
|
video_id == other.video_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_video(video : ChannelVideo) : self
|
||||||
|
VideoNotification.new(video.id, video.ucid, video.published)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@video_id, @channel_id, @published)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clone : VideoNotification
|
||||||
|
VideoNotification.new(video_id.clone, channel_id.clone, published.clone)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
||||||
|
private getter notification_channel : ::Channel(VideoNotification)
|
||||||
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
|
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
|
||||||
private getter pg_url : URI
|
private getter pg_url : URI
|
||||||
|
|
||||||
def initialize(@connection_channel, @pg_url)
|
def initialize(@notification_channel, @connection_channel, @pg_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def begin
|
def begin
|
||||||
|
@ -10,6 +34,70 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
||||||
|
|
||||||
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
|
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
|
||||||
|
|
||||||
|
# hash of channels to their videos (id+published) that need notifying
|
||||||
|
to_notify = Hash(String, Set(VideoNotification)).new(
|
||||||
|
->(hash : Hash(String, Set(VideoNotification)), key : String) {
|
||||||
|
hash[key] = Set(VideoNotification).new
|
||||||
|
}
|
||||||
|
)
|
||||||
|
notify_mutex = Mutex.new
|
||||||
|
|
||||||
|
# fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job)
|
||||||
|
spawn do
|
||||||
|
begin
|
||||||
|
loop do
|
||||||
|
notification = notification_channel.receive
|
||||||
|
notify_mutex.synchronize do
|
||||||
|
to_notify[notification.channel_id] << notification
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# fiber to regularly persist all cached notifications
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
begin
|
||||||
|
LOGGER.debug("NotificationJob: waking up")
|
||||||
|
cloned = {} of String => Set(VideoNotification)
|
||||||
|
notify_mutex.synchronize do
|
||||||
|
cloned = to_notify.clone
|
||||||
|
to_notify.clear
|
||||||
|
end
|
||||||
|
|
||||||
|
cloned.each do |channel_id, notifications|
|
||||||
|
if notifications.empty?
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications")
|
||||||
|
if CONFIG.enable_user_notifications
|
||||||
|
video_ids = notifications.map(&.video_id)
|
||||||
|
Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids)
|
||||||
|
PG_DB.using_connection do |conn|
|
||||||
|
notifications.each do |n|
|
||||||
|
# Deliver notifications to `/api/v1/auth/notifications`
|
||||||
|
payload = {
|
||||||
|
"topic" => n.channel_id,
|
||||||
|
"videoId" => n.video_id,
|
||||||
|
"published" => n.published.to_unix,
|
||||||
|
}.to_json
|
||||||
|
conn.exec("NOTIFY notifications, E'#{payload}'")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Invidious::Database::Users.feed_needs_update(channel_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
LOGGER.trace("NotificationJob: Done, sleeping")
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error("NotificationJob: #{ex.message}")
|
||||||
|
end
|
||||||
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
loop do
|
loop do
|
||||||
action, connection = connection_channel.receive
|
action, connection = connection_channel.receive
|
||||||
|
|
||||||
|
|
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"]?.try &.presence
|
||||||
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
|
||||||
|
else
|
||||||
|
json.field "publishedText", ""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -78,6 +78,75 @@ module Invidious::Routes::Account
|
||||||
env.redirect referer
|
env.redirect referer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Username update
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
# Show the username change interface (GET request)
|
||||||
|
def get_change_username(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env)
|
||||||
|
|
||||||
|
if !user
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
|
csrf_token = generate_response(sid, {":change_username"}, HMAC_KEY)
|
||||||
|
|
||||||
|
templated "user/change_username"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle the username change (POST request)
|
||||||
|
def post_change_username(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
|
referer = get_referer(env)
|
||||||
|
|
||||||
|
if !user
|
||||||
|
return env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
|
begin
|
||||||
|
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
|
rescue ex
|
||||||
|
return error_template(400, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_username = env.params.body["new_username"]?.try &.downcase.byte_slice(0, 254)
|
||||||
|
if new_username.nil?
|
||||||
|
return error_template(401, "username_required_field")
|
||||||
|
end
|
||||||
|
|
||||||
|
if new_username.empty?
|
||||||
|
return error_template(401, "username_empty")
|
||||||
|
end
|
||||||
|
|
||||||
|
if new_username == user.email
|
||||||
|
return error_template(401, "username_is_the_same")
|
||||||
|
end
|
||||||
|
|
||||||
|
if Invidious::Database::Users.select(email: new_username)
|
||||||
|
return error_template(401, "username_taken")
|
||||||
|
end
|
||||||
|
|
||||||
|
Invidious::Database::Users.update_username(user, new_username.to_s)
|
||||||
|
Invidious::Database::Users.update_user_session_id(user, new_username.to_s)
|
||||||
|
Invidious::Database::Users.update_user_playlists_author(user, new_username.to_s)
|
||||||
|
|
||||||
|
env.redirect referer
|
||||||
|
end
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# Account deletion
|
# Account deletion
|
||||||
# -------------------
|
# -------------------
|
||||||
|
@ -326,17 +395,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?
|
||||||
|
companion_public_url = env.get("companion_public_url").as(String)
|
||||||
|
return env.redirect "#{companion_public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
|
||||||
|
end
|
||||||
|
|
||||||
# Since some implementations create playlists based on resolution regardless of different codecs,
|
# Since some implementations create playlists based on resolution regardless of different codecs,
|
||||||
# we can opt to only add a source to a representation if it has a unique height within that representation
|
# we can opt to only add a source to a representation if it has a unique height within that representation
|
||||||
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
||||||
|
@ -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,15 @@ 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}"
|
if CONFIG.https_only
|
||||||
|
scheme = "https://"
|
||||||
|
else
|
||||||
|
scheme = "http://"
|
||||||
|
end
|
||||||
|
|
||||||
|
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -258,7 +250,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)
|
||||||
|
@ -367,6 +368,35 @@ module Invidious::Routes::API::V1::Channels
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.courses(env)
|
||||||
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
ucid = env.params.url["ucid"]
|
||||||
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
|
# Use the macro defined above
|
||||||
|
channel = nil # Make the compiler happy
|
||||||
|
get_channel()
|
||||||
|
|
||||||
|
items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "playlists" do
|
||||||
|
json.array do
|
||||||
|
items.each do |item|
|
||||||
|
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "continuation", next_continuation if next_continuation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.community(env)
|
def self.community(env)
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -429,4 +429,90 @@ module Invidious::Routes::API::V1::Videos
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fetches transcripts from YouTube
|
||||||
|
#
|
||||||
|
# Use the `lang` and `autogen` query parameter to select which transcript to fetch
|
||||||
|
# Request without any URL parameters to see all the available transcripts.
|
||||||
|
def self.transcripts(env)
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
id = env.params.url["id"]
|
||||||
|
lang = env.params.query["lang"]?
|
||||||
|
label = env.params.query["label"]?
|
||||||
|
auto_generated = env.params.query["autogen"]? ? true : false
|
||||||
|
|
||||||
|
# Return all available transcript options when none is given
|
||||||
|
if !label && !lang
|
||||||
|
begin
|
||||||
|
video = get_video(id)
|
||||||
|
rescue ex : NotFoundException
|
||||||
|
return error_json(404, ex)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
response = JSON.build do |json|
|
||||||
|
# The amount of transcripts available to fetch is the
|
||||||
|
# same as the amount of captions available.
|
||||||
|
available_transcripts = video.captions
|
||||||
|
|
||||||
|
json.object do
|
||||||
|
json.field "transcripts" do
|
||||||
|
json.array do
|
||||||
|
available_transcripts.each do |transcript|
|
||||||
|
json.object do
|
||||||
|
json.field "label", transcript.name
|
||||||
|
json.field "languageCode", transcript.language_code
|
||||||
|
json.field "autoGenerated", transcript.auto_generated
|
||||||
|
|
||||||
|
if transcript.auto_generated
|
||||||
|
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen"
|
||||||
|
else
|
||||||
|
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
|
# If lang is not given then we attempt to fetch
|
||||||
|
# the transcript through the given label
|
||||||
|
if lang.nil?
|
||||||
|
begin
|
||||||
|
video = get_video(id)
|
||||||
|
rescue ex : NotFoundException
|
||||||
|
return error_json(404, ex)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
target_transcript = video.captions.select(&.name.== label)
|
||||||
|
if target_transcript.empty?
|
||||||
|
return error_json(404, NotFoundException.new("Requested transcript does not exist"))
|
||||||
|
else
|
||||||
|
target_transcript = target_transcript[0]
|
||||||
|
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated)
|
||||||
|
|
||||||
|
begin
|
||||||
|
transcript = Invidious::Videos::Transcript.from_raw(
|
||||||
|
YoutubeAPI.get_transcript(params), lang, auto_generated
|
||||||
|
)
|
||||||
|
rescue ex : NotFoundException
|
||||||
|
return error_json(404, ex)
|
||||||
|
rescue ex
|
||||||
|
return error_json(500, ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
return transcript.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
16
src/invidious/routes/backend_switcher.cr
Normal file
16
src/invidious/routes/backend_switcher.cr
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% skip_file if flag?(:api_only) %}
|
||||||
|
|
||||||
|
module Invidious::Routes::BackendSwitcher
|
||||||
|
def self.switch(env)
|
||||||
|
referer = get_referer(env)
|
||||||
|
backend_id = env.params.query["backend_id"]?.try &.to_i
|
||||||
|
|
||||||
|
if backend_id.nil?
|
||||||
|
return error_template(400, "Backend ID is required")
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(env.request.headers["Host"], backend_id)
|
||||||
|
|
||||||
|
env.redirect referer
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,7 @@
|
||||||
module Invidious::Routes::BeforeAll
|
module Invidious::Routes::BeforeAll
|
||||||
def self.handle(env)
|
def self.handle(env)
|
||||||
preferences = Preferences.from_json("{}")
|
preferences = Preferences.from_json("{}")
|
||||||
|
host = env.request.headers["Host"]
|
||||||
|
|
||||||
begin
|
begin
|
||||||
if prefs_cookie = env.request.cookies["PREFS"]?
|
if prefs_cookie = env.request.cookies["PREFS"]?
|
||||||
|
@ -20,18 +21,60 @@ module Invidious::Routes::BeforeAll
|
||||||
env.response.headers["X-XSS-Protection"] = "1; mode=block"
|
env.response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
|
||||||
|
extra_media_csp = ""
|
||||||
|
extra_connect_csp = ""
|
||||||
|
|
||||||
|
if CONFIG.invidious_companion.present?
|
||||||
|
current_companion_d = host.split(".")[0].scan(/(\d+)$/).last?.try &.[0].to_i
|
||||||
|
|
||||||
|
if current_companion_d
|
||||||
|
current_companion_d = current_companion_d - 1
|
||||||
|
env.set "using_domain", true
|
||||||
|
env.set "current_companion", current_companion_d
|
||||||
|
env.set "companion_public_url", CONFIG.invidious_companion[current_companion_d].public_url.to_s
|
||||||
|
else
|
||||||
|
if !env.request.cookies[CONFIG.server_id_cookie_name]?
|
||||||
|
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host)
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
current_companion = env.request.cookies[CONFIG.server_id_cookie_name].value.try &.to_i
|
||||||
|
rescue
|
||||||
|
current_companion = rand(CONFIG.invidious_companion.size)
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_companion > CONFIG.invidious_companion.size
|
||||||
|
current_companion = current_companion % CONFIG.invidious_companion.size
|
||||||
|
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
|
||||||
|
end
|
||||||
|
|
||||||
|
companion_status = BackendInfo.get_status
|
||||||
|
|
||||||
|
if companion_status[current_companion] != 2
|
||||||
|
alive_companion = companion_status.index(2)
|
||||||
|
if alive_companion
|
||||||
|
env.set "companion_switched", true
|
||||||
|
current_companion = alive_companion
|
||||||
|
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
env.set "current_companion", current_companion
|
||||||
|
|
||||||
|
if host.split(".").last == "i2p"
|
||||||
|
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].i2p_public_url.to_s
|
||||||
|
else
|
||||||
|
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].public_url.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
extra_media_csp, extra_connect_csp = BackendInfo.get_csp(env.get("current_companion").as(Int32))
|
||||||
|
end
|
||||||
|
|
||||||
# Allow media resources to be loaded from google servers
|
# Allow media resources to be loaded from google servers
|
||||||
# TODO: check if *.youtube.com can be removed
|
# TODO: check if *.youtube.com can be removed
|
||||||
if CONFIG.disabled?("local") || !preferences.local
|
if CONFIG.disabled?("local") || !preferences.local
|
||||||
extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
|
extra_media_csp += " https://*.googlevideo.com:443 https://*.youtube.com:443"
|
||||||
else
|
|
||||||
extra_media_csp = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -41,21 +84,24 @@ module Invidious::Routes::BeforeAll
|
||||||
frame_ancestors = "'none'"
|
frame_ancestors = "'none'"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || ("https" if CONFIG.https_only) || "http"
|
||||||
|
env.set "scheme", scheme
|
||||||
|
|
||||||
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
|
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
|
||||||
# inline styles (<style> [..] </style>, style=" [..] ")
|
# inline styles (<style> [..] </style>, style=" [..] ")
|
||||||
env.response.headers["Content-Security-Policy"] = {
|
env.response.headers["Content-Security-Policy"] = {
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data: " + "#{scheme}://#{env.request.headers["Host"]?}",
|
||||||
"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:",
|
||||||
"frame-src 'self'",
|
"frame-src 'self'",
|
||||||
"frame-ancestors " + frame_ancestors,
|
"frame-ancestors " + frame_ancestors,
|
||||||
}.join("; ")
|
}.join("; ") if CONFIG.csp
|
||||||
|
|
||||||
env.response.headers["Referrer-Policy"] = "same-origin"
|
env.response.headers["Referrer-Policy"] = "same-origin"
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
@ -195,7 +197,29 @@ module Invidious::Routes::Channels
|
||||||
templated "channel"
|
templated "channel"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.courses(env)
|
||||||
|
data = self.fetch_basic_information(env)
|
||||||
|
return data if !data.is_a?(Tuple)
|
||||||
|
|
||||||
|
locale, user, subscriptions, continuation, ucid, channel = data
|
||||||
|
|
||||||
|
sort_by = ""
|
||||||
|
sort_options = [] of String
|
||||||
|
|
||||||
|
items, next_continuation = fetch_channel_courses(
|
||||||
|
channel.ucid, channel.author, continuation
|
||||||
|
)
|
||||||
|
|
||||||
|
items = items.select(SearchPlaylist)
|
||||||
|
items.each(&.author = "")
|
||||||
|
|
||||||
|
selected_tab = Frontend::ChannelPage::TabsAvailable::Courses
|
||||||
|
templated "channel"
|
||||||
|
end
|
||||||
|
|
||||||
def self.community(env)
|
def self.community(env)
|
||||||
|
return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts"
|
||||||
|
|
||||||
data = self.fetch_basic_information(env)
|
data = self.fetch_basic_information(env)
|
||||||
if !data.is_a?(Tuple)
|
if !data.is_a?(Tuple)
|
||||||
return data
|
return data
|
||||||
|
@ -212,7 +236,7 @@ module Invidious::Routes::Channels
|
||||||
|
|
||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
if !channel.tabs.includes? "community"
|
if !channel.tabs.includes? "community" && "posts"
|
||||||
return env.redirect "/channel/#{channel.ucid}"
|
return env.redirect "/channel/#{channel.ucid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -305,7 +329,8 @@ module Invidious::Routes::Channels
|
||||||
|
|
||||||
private KNOWN_TABS = {
|
private KNOWN_TABS = {
|
||||||
"home", "videos", "shorts", "streams", "podcasts",
|
"home", "videos", "shorts", "streams", "podcasts",
|
||||||
"releases", "playlists", "community", "channels", "about",
|
"releases", "courses", "playlists", "community", "channels", "about",
|
||||||
|
"posts",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Redirects brand url channels to a normal /channel/:ucid route
|
# Redirects brand url channels to a normal /channel/:ucid route
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue