Compare commits

..

17 commits

Author SHA1 Message Date
7a1d294543
Tokens: Option to disable user tokens.
All checks were successful
Invidious CI / build (push) Successful in 5m59s
2024-10-25 10:36:20 -03:00
20ebfedca5
Tokens: Server side generated tokens.
All checks were successful
Invidious CI / build (push) Successful in 5m48s
#18
2024-10-17 23:44:30 -03:00
9878a3d4d6
PubSub: Use external domain for pubsub feeds
All checks were successful
Invidious CI / build (push) Successful in 6m27s
2024-10-17 17:02:12 -03:00
8d7ca9a4e2
External Proxies: Proxyfi HLS Playlists
All checks were successful
Invidious CI / build (push) Successful in 5m22s
2024-10-14 17:57:52 -03:00
9a66a7bd51
Videos: Completly disable annotations due to archive.org being down
All checks were successful
Invidious CI / build (push) Successful in 5m36s
Closes #15
2024-10-13 23:47:57 -03:00
66b481713d
Tokens: Refresh po_token and visitor_data every 5 seconds
All checks were successful
Invidious CI / build (push) Successful in 8m45s
Closes #11
2024-10-13 15:57:51 -03:00
6587528ed9
External Proxies: Proxyfi HD720 2024-10-13 15:19:49 -03:00
917cede8b7
Videos: Increase video cache to 4 hours
All checks were successful
Invidious CI / build (push) Successful in 5m19s
2024-10-12 02:59:36 -03:00
fc0a3ab307
Feat: Experimental support for potoken inside redis
All checks were successful
Invidious CI / build (push) Successful in 4m54s
Using https://git.nadeko.net/Fijxu/youtube-po-token-generator
2024-10-12 02:04:14 -03:00
62d64ca814
fixup! Feat: User supplied po_token and visitor_data
All checks were successful
Invidious CI / build (push) Successful in 5m2s
2024-10-11 23:25:09 -03:00
e78f7e5430
External Proxies: Use list of external videoplayback proxies
All checks were successful
Invidious CI / build (push) Successful in 5m14s
2024-10-11 13:50:42 -03:00
Samantaz Fox
b551fcf96a
Videos: Fix missing host parameter on playback URLs when local=true
All checks were successful
Invidious CI / build (push) Successful in 5m16s
2024-10-11 13:14:53 -03:00
7689251158
CI: Experimental branches for testing builds
Some checks failed
Invidious CI / build (push) Has been cancelled
2024-10-10 18:19:29 -03:00
b3a8866022
Feat: User supplied po_token and visitor_data
All checks were successful
Invidious CI / build (push) Successful in 4m57s
2024-10-10 17:43:18 -03:00
eac85f111c
fixup! Feeds: Get rid of feed_needs_update() since it appears to be unused
All checks were successful
Invidious CI / build (push) Successful in 5m2s
2024-10-10 15:07:50 -03:00
5dd37bfee7
Small try. 2024-10-10 15:07:33 -03:00
ab32c38719
Feeds: Get rid of feed_needs_update() since it appears to be unused
All checks were successful
Invidious CI / build (push) Successful in 5m7s
2024-10-09 18:09:23 -03:00
57 changed files with 609 additions and 1141 deletions

View file

@ -37,15 +37,13 @@ jobs:
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- uses: https://code.forgejo.org/docker/build-push-action@v6 - uses: https://code.forgejo.org/docker/build-push-action@v5
name: Build images name: Build images
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64 platforms: linux/amd64
# cache-from: type=gha
# cache-to: type=gha,mode=max
push: true push: true
build-args: | build-args: |
"release=1" "release=1"

View file

@ -38,11 +38,10 @@ jobs:
matrix: matrix:
stable: [true] stable: [true]
crystal: crystal:
- 1.9.2
- 1.10.1 - 1.10.1
- 1.11.2 - 1.11.2
- 1.12.1 - 1.12.1
- 1.13.2
- 1.14.0
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false
@ -52,11 +51,6 @@ jobs:
with: with:
submodules: true submodules: true
- name: Install required APT packages
run: |
sudo apt install -y libsqlite3-dev
shell: bash
- name: Install Crystal - name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.0 uses: crystal-lang/install-crystal@v1.8.0
with: with:
@ -65,9 +59,7 @@ jobs:
- name: Cache Shards - name: Cache Shards
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: ./lib
./lib
./bin
key: shards-${{ hashFiles('shard.lock') }} key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards - name: Install Shards
@ -79,6 +71,14 @@ jobs:
- name: Run tests - name: Run tests
run: crystal spec run: crystal spec
- name: Run lint
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Build - name: Build
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
@ -124,12 +124,8 @@ jobs:
- name: Test Docker - name: Test Docker
run: while curl -Isf http://localhost:3000; do sleep 1; done run: while curl -Isf http://localhost:3000; do sleep 1; done
lint: ameba_lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@ -149,18 +145,7 @@ jobs:
key: shards-${{ hashFiles('shard.lock') }} key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards - name: Install Shards
run: | run: shards install
if ! shards check; then
shards install
fi
- name: Check Crystal formatter compliance
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Run Ameba linter - name: Run Ameba linter
run: bin/ameba run: bin/ameba

View file

@ -13,11 +13,14 @@ jobs:
- uses: actions/stale@v8 - uses: actions/stale@v8
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 730 days-before-stale: 365
days-before-pr-stale: -1 days-before-pr-stale: 90
days-before-close: 60 days-before-close: 30
exempt-pr-labels: blocked,exempt-stale
stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.' stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.'
stale-pr-message: 'This pull request has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely abandoned or outdated. If you think this pull request is still relevant and applicable, you just have to post a comment and it will be unmarked.'
stale-issue-label: "stale" stale-issue-label: "stale"
stale-pr-label: "stale"
ascending: true ascending: true
# Exempt the following types of issues from being staled # Never mark feature requests/enhancements as stale
exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale" exempt-issue-labels: "feature-request,enhancement,exempt-stale"

View file

@ -3,98 +3,8 @@
## vX.Y.0 (future) ## vX.Y.0 (future)
## v2.20241110.0
### Wrap-up
This release is most importantly here to fix to the annoying "Youtube API returned error 400"
error that prevented all channel pages from loading.
If you're updating from the previous release, it provides no improvements on the ability to play
videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
by a previous attempt at restoring video playback on large instances.
In the preferences, a new option allows for control of video preload. When enabled, this option
tells the browser to load the video as soon as the page is loaded (this used to be the default).
When disabled, the video starts loading only when the "play" button is pressed.
New interface languages available: Bulgarian, Welsh and Lombard
New dependency required: `tzdata`.
An HTTP proxy can be configured directly in Invidious, if needed. \
**NOTE:** In that case, it is recommended to comment out `force_resolve`.
### New features & important changes
#### For users
* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
* Preferences: Addition of the new "preload" option
* New interface languages available: Bulgarian, Welsh and Lombard
* Added "Filipino (auto-generated)" to the list of caption languages available
* Lots of new translations from Weblate
#### For instance owners
* Allow the configuration of an HTTP proxy to talk to Youtube
* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
* The instance list is downloaded in the background to improve redirection speed
* New `colorize_logs` option makes each log level a different color
#### For developpers
* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
`newest`, `oldest` and `popular`
* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
`is3d` and `hasCaptions`
### Bugs fixed
#### User-side
* Channels: The second page of shorts now loads as expected
* Channels: Fixed intermittent empty "playlists" tab
* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
* Switching to another instance is much faster
* Fixed an "invalid byte sequence" error when subscribing to a playlist
* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
#### For instance owners
* Fix `force_resolve` being ignored in some cases
#### API
* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
### Full list of pull requests merged since the last release (newest first) ### Full list of pull requests merged since the last release (newest first)
* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha)
* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox)
* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox)
* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox)
* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox)
* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue)
* Stale bot updates ([#5060], thanks @syeopite)
* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox)
* Channels: Fix for live videos ([#5027], thanks @iBicha)
* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox)
* Shards: Update database dependencies ([#5034], by @SamantazFox)
* Logger: Add color support for different log levels ([#4931], thanks @Fijxu)
* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite)
* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite)
* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox)
* Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox)
* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu)
* Fix player menus hiding onHover ready ([#4750], thanks @giacomocerquone)
* Use connection pools when requesting images from YouTube ([#4326], thanks @syeopite)
* Add support for using Invidious through a HTTP Proxy ([#4270], thanks @syeopite)
* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox) * Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox)
* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite) * Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov) * Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
@ -112,12 +22,7 @@ An HTTP proxy can be configured directly in Invidious, if needed. \
[#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
@ -127,22 +32,7 @@ An HTTP proxy can be configured directly in Invidious, if needed. \
[#4923]: https://github.com/iv-org/invidious/pull/4923 [#4923]: https://github.com/iv-org/invidious/pull/4923
[#4928]: https://github.com/iv-org/invidious/pull/4928 [#4928]: https://github.com/iv-org/invidious/pull/4928
[#4930]: https://github.com/iv-org/invidious/pull/4930 [#4930]: https://github.com/iv-org/invidious/pull/4930
[#4931]: https://github.com/iv-org/invidious/pull/4931
[#4934]: https://github.com/iv-org/invidious/pull/4934
[#4942]: https://github.com/iv-org/invidious/pull/4942 [#4942]: https://github.com/iv-org/invidious/pull/4942
[#4984]: https://github.com/iv-org/invidious/pull/4984
[#4991]: https://github.com/iv-org/invidious/pull/4991
[#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)

View file

@ -7,11 +7,6 @@ STATIC := 0
NO_DBG_SYMBOLS := 0 NO_DBG_SYMBOLS := 0
# Enable multi-threading.
# Warning: Experimental feature!!
# invidious is not stable when MT is enabled.
MT := 0
FLAGS ?= FLAGS ?=
@ -24,10 +19,6 @@ ifeq ($(STATIC), 1)
FLAGS += --static FLAGS += --static
endif endif
ifeq ($(MT), 1)
FLAGS += -Dpreview_mt
endif
ifeq ($(NO_DBG_SYMBOLS), 1) ifeq ($(NO_DBG_SYMBOLS), 1)
FLAGS += --no-debug FLAGS += --no-debug

View file

@ -68,7 +68,6 @@
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu { .video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
margin-bottom: 2em; margin-bottom: 2em;
padding-top: 2em
} }
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px; .video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px;

View file

@ -54,53 +54,6 @@ db:
## ##
#signature_server: #signature_server:
##
## Invidious companion is an external program
## for loading the video streams from YouTube servers.
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
## Note: multiple URL can be configured. In this case, invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
## to work. Once said cache has expired, requesting that video's data
## again will cause a new companion URL to be picked.
##
## The parameter private_url needs to be configured for the internal
## communication between the companion and Invidious.
## And public_url is the public URL from which companion is listening
## to the requests from the user(s).
##
## If you are using a reverse proxy then you will probably need to
## configure the public_url to be the same as the domain used for Invidious.
## Also apply when used from an external IP address (without a domain).
## Examples: https://MYINVIDIOUSDOMAIN or http://192.168.1.100:8282
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"
##
## API key for Invidious companion, used for securing the communication
## between Invidious and Invidious companion.
## The size of the key needs to be more or equal to 16.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"
######################################### #########################################
# #
@ -220,17 +173,6 @@ https_only: false
## ##
#force_resolve: #force_resolve:
##
## Configuration for using a HTTP proxy
##
## If unset, then no HTTP proxy will be used.
##
http_proxy:
user:
password:
host:
port:
## ##
## Use Innertube's transcripts API instead of timedtext for closed captions ## Use Innertube's transcripts API instead of timedtext for closed captions
@ -282,13 +224,11 @@ http_proxy:
## ##
## Enables colors in logs. Useful for debugging purposes ## Enables colors in logs. Useful for debugging purposes
## This is overridden if "-k" or "--colorize" ## This is overridden if "-k" or "--colorize"
## are passed on the command line. ## are passed on the command line.
## Colors are also disabled if the environment variable
## NO_COLOR is present and has any value
## ##
## Accepted values: true, false ## Accepted values: true, false
## Default: true ## Default: false
## ##
#colorize_logs: false #colorize_logs: false

View file

@ -1,4 +1,4 @@
FROM mirror.gcr.io/crystallang/crystal:1.14.0-alpine AS builder FROM crystallang/crystal:1.12.1-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static RUN apk add --no-cache sqlite-static yaml-static
@ -23,7 +23,7 @@ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN if [[ "${release}" == 1 ]] ; then \ RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release --mcpu=x86-64-v2 \ --release --mcpu=x86-64-v3 \
--static --warnings all \ --static --warnings all \
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
else \ else \
@ -32,8 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM mirror.gcr.io/alpine:3.20 FROM alpine:3.18
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious

View file

@ -1,6 +1,5 @@
FROM alpine:3.20 AS builder FROM alpine:3.19 AS builder
RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \ RUN apk add --no-cache 'crystal=1.10.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release ARG release
@ -33,8 +32,8 @@ RUN if [[ "${release}" == 1 ]] ; then \
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma"; \
fi fi
FROM alpine:3.20 FROM alpine:3.18
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini
WORKDIR /invidious WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious

View file

@ -287,7 +287,6 @@
"Esperanto": "Esperanto", "Esperanto": "Esperanto",
"Estonian": "Estonian", "Estonian": "Estonian",
"Filipino": "Filipino", "Filipino": "Filipino",
"Filipino (auto-generated)": "Filipino (auto-generated)",
"Finnish": "Finnish", "Finnish": "Finnish",
"French": "French", "French": "French",
"French (auto-generated)": "French (auto-generated)", "French (auto-generated)": "French (auto-generated)",

2
mocks

@ -1 +1 @@
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872 Subproject commit 11ec372f72747c09d48ffef04843f72be67d5b54

View file

@ -10,23 +10,16 @@ shards:
backtracer: backtracer:
git: https://github.com/sija/backtracer.cr.git git: https://github.com/sija/backtracer.cr.git
version: 1.2.2 version: 1.2.1
db: db:
git: https://github.com/crystal-lang/crystal-db.git git: https://github.com/crystal-lang/crystal-db.git
version: 0.13.1 version: 0.10.1
exception_page: exception_page:
git: https://github.com/crystal-loot/exception_page.git git: https://github.com/crystal-loot/exception_page.git
version: 0.2.2 version: 0.2.2
inotify:
git: https://github.com/petoem/inotify.cr.git
version: 1.0.3
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.10.3
kemal: kemal:
git: https://github.com/kemalcr/kemal.git git: https://github.com/kemalcr/kemal.git
version: 1.1.2 version: 1.1.2
@ -37,7 +30,7 @@ shards:
pg: pg:
git: https://github.com/will/crystal-pg.git git: https://github.com/will/crystal-pg.git
version: 0.28.0 version: 0.24.0
pool: pool:
git: https://github.com/ysbaddaden/pool.git git: https://github.com/ysbaddaden/pool.git
@ -57,9 +50,9 @@ shards:
spectator: spectator:
git: https://github.com/icy-arctic-fox/spectator.git git: https://github.com/icy-arctic-fox/spectator.git
version: 0.10.6 version: 0.10.4
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.21.0 version: 0.18.0

View file

@ -1,20 +1,21 @@
name: invidious name: invidious
version: 2.20241110.0-dev version: 0.20.1
authors: authors:
- Invidious team <contact@invidious.io> - Omar Roth <omarroth@protonmail.com>
- Contributors! - Invidious team
description: | targets:
Invidious is an alternative front-end to YouTube invidious:
main: src/invidious.cr
dependencies: dependencies:
pg: pg:
github: will/crystal-pg github: will/crystal-pg
version: ~> 0.28.0 version: ~> 0.24.0
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: ~> 0.21.0 version: ~> 0.18.0
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 1.1.2 version: ~> 1.1.2
@ -29,12 +30,6 @@ dependencies:
version: ~> 0.1.1 version: ~> 0.1.1
redis: redis:
github: stefanwille/crystal-redis github: stefanwille/crystal-redis
inotify:
github: petoem/inotify.cr
version: 1.0.3
http_proxy:
github: mamantoha/http_proxy
version: ~> 0.10.3
development_dependencies: development_dependencies:
spectator: spectator:
@ -44,10 +39,6 @@ development_dependencies:
github: crystal-ameba/ameba github: crystal-ameba/ameba
version: ~> 1.6.1 version: ~> 1.6.1
crystal: ">= 1.10.0, < 2.0.0" crystal: ">= 1.0.0, < 2.0.0"
license: AGPLv3 license: AGPLv3
repository: https://github.com/iv-org/invidious
homepage: https://invidious.io
documentation: https://docs.invidious.io

View file

@ -17,8 +17,8 @@ Spectator.describe "parse_video_info" do
# Basic video infos # Basic video infos
expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island") expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island")
expect(info["views"].as_i).to eq(220_226_287) expect(info["views"].as_i).to eq(126_573_823)
expect(info["likes"].as_i).to eq(6_870_691) expect(info["likes"].as_i).to eq(5_157_654)
# For some reason the video length from VideoDetails and the # For some reason the video length from VideoDetails and the
# one from microformat differs by 1s... # one from microformat differs by 1s...
@ -48,12 +48,12 @@ Spectator.describe "parse_video_info" do
expect(info["relatedVideos"].as_a.size).to eq(20) expect(info["relatedVideos"].as_a.size).to eq(20)
expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4") expect(info["relatedVideos"][0]["id"]).to eq("Hwybp38GnZw")
expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!") expect(info["relatedVideos"][0]["title"]).to eq("I Built Willy Wonka's Chocolate Factory!")
expect(info["relatedVideos"][0]["author"]).to eq("MrBeast") expect(info["relatedVideos"][0]["author"]).to eq("MrBeast")
expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
expect(info["relatedVideos"][0]["view_count"]).to eq("230617484") expect(info["relatedVideos"][0]["view_count"]).to eq("179877630")
expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M") expect(info["relatedVideos"][0]["short_view_count"]).to eq("179M")
expect(info["relatedVideos"][0]["author_verified"]).to eq("true") expect(info["relatedVideos"][0]["author_verified"]).to eq("true")
# Description # Description
@ -76,11 +76,11 @@ Spectator.describe "parse_video_info" do
expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA")
expect(info["authorThumbnail"].as_s).to eq( expect(info["authorThumbnail"].as_s).to eq(
"https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj" "https://yt3.ggpht.com/ytc/AL5GRJVuqw82ERvHzsmBxL7avr1dpBtsVIXcEzBPZaloFg=s48-c-k-c0x00ffffff-no-rj"
) )
expect(info["authorVerified"].as_bool).to be_true expect(info["authorVerified"].as_bool).to be_true
expect(info["subCountText"].as_s).to eq("320M") expect(info["subCountText"].as_s).to eq("143M")
end end
it "parses a regular video with no descrition/comments" do it "parses a regular video with no descrition/comments" do
@ -99,8 +99,8 @@ Spectator.describe "parse_video_info" do
# Basic video infos # Basic video infos
expect(info["title"].as_s).to eq("Chris Rea - Auberge") expect(info["title"].as_s).to eq("Chris Rea - Auberge")
expect(info["views"].as_i).to eq(14_324_584) expect(info["views"].as_i).to eq(10_943_126)
expect(info["likes"].as_i).to eq(35_870) expect(info["likes"].as_i).to eq(0)
expect(info["lengthSeconds"].as_i).to eq(283_i64) expect(info["lengthSeconds"].as_i).to eq(283_i64)
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
@ -132,14 +132,14 @@ Spectator.describe "parse_video_info" do
# Related videos # Related videos
expect(info["relatedVideos"].as_a.size).to eq(20) expect(info["relatedVideos"].as_a.size).to eq(19)
expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4") expect(info["relatedVideos"][0]["id"]).to eq("Ww3KeZ2_Yv4")
expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version") expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea")
expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH") expect(info["relatedVideos"][0]["author"]).to eq("PanMusic")
expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ") expect(info["relatedVideos"][0]["ucid"]).to eq("UCsKAPSuh1iNbLWUga_igPyA")
expect(info["relatedVideos"][0]["view_count"]).to eq("53298661") expect(info["relatedVideos"][0]["view_count"]).to eq("31581")
expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M") expect(info["relatedVideos"][0]["short_view_count"]).to eq("31K")
expect(info["relatedVideos"][0]["author_verified"]).to eq("false") expect(info["relatedVideos"][0]["author_verified"]).to eq("false")
# Description # Description
@ -156,13 +156,11 @@ Spectator.describe "parse_video_info" do
# Author infos # Author infos
expect(info["author"].as_s).to eq("ChrisReaVideos") expect(info["author"].as_s).to eq("ChrisReaOfficial")
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA") expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
expect(info["authorThumbnail"].as_s).to eq( expect(info["authorThumbnail"].as_s).to be_empty
"https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj"
)
expect(info["authorVerified"].as_bool).to be_false expect(info["authorVerified"].as_bool).to be_false
expect(info["subCountText"].as_s).to eq("3.11K") expect(info["subCountText"].as_s).to eq("-")
end end
end end

View file

@ -23,7 +23,6 @@ require "kilt"
require "./ext/kemal_content_for.cr" require "./ext/kemal_content_for.cr"
require "./ext/kemal_static_file_handler.cr" require "./ext/kemal_static_file_handler.cr"
require "http_proxy"
require "athena-negotiation" require "athena-negotiation"
require "openssl/hmac" require "openssl/hmac"
require "option_parser" require "option_parser"
@ -33,7 +32,6 @@ require "yaml"
require "compress/zip" require "compress/zip"
require "protodec/utils" require "protodec/utils"
require "redis" require "redis"
require "inotify"
require "./invidious/database/*" require "./invidious/database/*"
require "./invidious/database/migrations/*" require "./invidious/database/migrations/*"
@ -60,20 +58,7 @@ end
# Simple alias to make code easier to read # Simple alias to make code easier to read
alias IV = Invidious alias IV = Invidious
CONFIG = Config.load CONFIG = Config.load
Signal::HUP.trap do
Config.reload
end
{% if flag?(:linux) %}
if CONFIG.reload_config_automatically
Inotify.watch("config/config.yml") do |event|
Config.reload
end
end
{% end %}
HMAC_KEY = CONFIG.hmac_key HMAC_KEY = CONFIG.hmac_key
PG_DB = DB.open CONFIG.database_url PG_DB = DB.open CONFIG.database_url
@ -88,6 +73,7 @@ REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com") YT_URL = URI.parse("https://www.youtube.com")
PUBSUB_HOST_URL = CONFIG.pubsub_domain PUBSUB_HOST_URL = CONFIG.pubsub_domain
HOST_URL = make_host_url(Kemal.config) HOST_URL = make_host_url(Kemal.config)
EXT_VIDEOP_LIST = gen_videoplayback_proxy_list()
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@ -114,10 +100,6 @@ SOFTWARE = {
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
# Image request pool
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
# CLI # CLI
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
@ -210,16 +192,10 @@ Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
if !CONFIG.external_videoplayback_proxy.empty? if !CONFIG.external_videoplayback_proxy.empty?
Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
else
# Invidious will it's own videoplayback proxy unless the admin decides to rewrite
# the /videoplayback location in the reverse proxy configuration (NGINX, Caddy, etc)
LOGGER.info("jobs: Disabling CheckExternalProxy job. Invidious will it's own videoplayback proxy")
end end
if !CONFIG.tokens_server.empty? if CONFIG.refresh_tokens
Invidious::Jobs.register Invidious::Jobs::RefreshSessionTokens.new Invidious::Jobs.register Invidious::Jobs::RefreshTokens.new
else
LOGGER.info("jobs: Disabling RefreshSessionTokens job. Invidious will use the tokens that are on the configuration file")
end end
Invidious::Jobs.start_all Invidious::Jobs.start_all

View file

@ -1,3 +1,78 @@
def produce_channel_content_continuation(ucid, content_type, page = 1, auto_generated = nil, sort_by = "newest", v2 = false)
object_inner_2 = {
"2:0:embedded" => {
"1:0:varint" => 0_i64,
},
"5:varint" => 50_i64,
"6:varint" => 1_i64,
"7:varint" => (page * 30).to_i64,
"9:varint" => 1_i64,
"10:varint" => 0_i64,
}
object_inner_2_encoded = object_inner_2
.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
content_type_numerical =
case content_type
when "videos" then 15
when "livestreams" then 14
else 15 # Fallback to "videos"
end
sort_by_numerical =
case sort_by
when "newest" then 1_i64
when "popular" then 2_i64
when "oldest" then 4_i64
else 1_i64 # Fallback to "newest"
end
object_inner_1 = {
"110:embedded" => {
"3:embedded" => {
"#{content_type_numerical}:embedded" => {
"1:embedded" => {
"1:string" => object_inner_2_encoded,
},
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"3:varint" => sort_by_numerical,
},
},
},
}
object_inner_1_encoded = object_inner_1
.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
object = {
"80226972:embedded" => {
"2:string" => ucid,
"3:string" => object_inner_1_encoded,
"35:string" => "browse-feed#{ucid}videos102",
},
}
continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
return continuation
end
def make_initial_content_ctoken(ucid, content_type, sort_by) : String
return produce_channel_content_continuation(ucid, content_type, sort_by: sort_by)
end
module Invidious::Channel::Tabs module Invidious::Channel::Tabs
extend self extend self
@ -26,7 +101,7 @@ module Invidious::Channel::Tabs
end end
def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_videos_ctoken(ucid, sort_by) continuation ||= make_initial_content_ctoken(ucid, "videos", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation) initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
@ -55,10 +130,14 @@ module Invidious::Channel::Tabs
# Shorts # Shorts
# ------------------- # -------------------
def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") def get_shorts(channel : AboutChannel, continuation : String? = nil)
continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by) if continuation.nil?
initial_data = YoutubeAPI.browse(continuation: continuation) # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts"
# TODO: try to extract the continuation tokens that allows other sorting options
initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D")
else
initial_data = YoutubeAPI.browse(continuation: continuation)
end
return extract_items(initial_data, channel.author, channel.ucid) return extract_items(initial_data, channel.author, channel.ucid)
end end
@ -66,8 +145,9 @@ module Invidious::Channel::Tabs
# Livestreams # Livestreams
# ------------------- # -------------------
def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") def get_livestreams(channel : AboutChannel, continuation : String? = nil, sort_by = "newest")
continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by) continuation ||= make_initial_content_ctoken(channel.ucid, "livestreams", sort_by)
initial_data = YoutubeAPI.browse(continuation: continuation) initial_data = YoutubeAPI.browse(continuation: continuation)
return extract_items(initial_data, channel.author, channel.ucid) return extract_items(initial_data, channel.author, channel.ucid)
@ -91,102 +171,4 @@ module Invidious::Channel::Tabs
return items, next_continuation return items, next_continuation
end end
# -------------------
# C-tokens
# -------------------
private def sort_options_videos_short(sort_by : String)
case sort_by
when "newest" then return 4_i64
when "popular" then return 2_i64
when "oldest" then return 5_i64
else return 4_i64 # Fallback to "newest"
end
end
# Generate the initial "continuation token" to get the first page of the
# "videos" tab. The following page requires the ctoken provided in that
# first page, and so on.
private def make_initial_videos_ctoken(ucid : String, sort_by = "newest")
object = {
"15:embedded" => {
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
},
}
return channel_ctoken_wrap(ucid, object)
end
# Generate the initial "continuation token" to get the first page of the
# "shorts" tab. The following page requires the ctoken provided in that
# first page, and so on.
private def make_initial_shorts_ctoken(ucid : String, sort_by = "newest")
object = {
"10:embedded" => {
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"4:varint" => sort_options_videos_short(sort_by),
},
}
return channel_ctoken_wrap(ucid, object)
end
# Generate the initial "continuation token" to get the first page of the
# "livestreams" tab. The following page requires the ctoken provided in that
# first page, and so on.
private def make_initial_livestreams_ctoken(ucid : String, sort_by = "newest")
sort_by_numerical =
case sort_by
when "newest" then 12_i64
when "popular" then 14_i64
when "oldest" then 13_i64
else 12_i64 # Fallback to "newest"
end
object = {
"14:embedded" => {
"2:embedded" => {
"1:string" => "00000000-0000-0000-0000-000000000000",
},
"5:varint" => sort_by_numerical,
},
}
return channel_ctoken_wrap(ucid, object)
end
# The protobuf structure common between videos/shorts/livestreams
private def channel_ctoken_wrap(ucid : String, object)
object_inner = {
"110:embedded" => {
"3:embedded" => object,
},
}
object_inner_encoded = object_inner
.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
object = {
"80226972:embedded" => {
"2:string" => ucid,
"3:string" => object_inner_encoded,
},
}
continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) }
return continuation
end
end end

View file

@ -45,6 +45,8 @@ struct ConfigPreferences
property vr_mode : Bool = true property vr_mode : Bool = true
property show_nick : Bool = true property show_nick : Bool = true
property save_player_pos : Bool = false property save_player_pos : Bool = false
property po_token : String = ""
property visitor_data : String = ""
def to_tuple def to_tuple
{% begin %} {% begin %}
@ -55,28 +57,9 @@ struct ConfigPreferences
end end
end end
struct HTTPProxyConfig
include YAML::Serializable
property user : String
property password : String
property host : String
property port : Int32
end
class Config class Config
include YAML::Serializable include YAML::Serializable
class CompanionConfig
include YAML::Serializable
@[YAML::Field(converter: Preferences::URIConverter)]
property private_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("")
end
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1 property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update). # Time interval between two executions of the job that crawls channel videos (subscriptions update).
@ -112,16 +95,14 @@ class Config
property hmac_key : String = "" property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required # Domain to be used for links to resources on the site where an absolute URL is required
property domain : String? property domain : String?
# Materialious redirects
property materialious_domain : String?
# Alternative domains. You can add other domains, like TOR and I2P addresses # Alternative domains. You can add other domains, like TOR and I2P addresses
property alternative_domains : Array(String) = [] of String property alternative_domains : Array(String) = [] of String
# Backend domains. Domains for numbered backends property donation_url : String?
property backend_domains : Array(String) = [] of String property contact_url : String?
property home_domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key) # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false property use_pubsub_feeds : Bool | Int32 = false
property use_innertube_for_feeds : Bool = true
property popular_enabled : Bool = true property popular_enabled : Bool = true
property captcha_enabled : Bool = true property captcha_enabled : Bool = true
property login_enabled : Bool = true property login_enabled : Bool = true
@ -174,8 +155,6 @@ class Config
property host_binding : String = "0.0.0.0" property host_binding : String = "0.0.0.0"
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
property pool_size : Int32 = 100 property pool_size : Int32 = 100
# HTTP Proxy configuration
property http_proxy : HTTPProxyConfig? = nil
# Use Innertube's transcripts API instead of timedtext for closed captions # Use Innertube's transcripts API instead of timedtext for closed captions
property use_innertube_for_captions : Bool = false property use_innertube_for_captions : Bool = false
@ -185,12 +164,6 @@ class Config
# poToken for passing bot attestation # poToken for passing bot attestation
property po_token : String? = nil property po_token : String? = nil
# Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig
# Invidious companion API key
property invidious_companion_key : String = ""
# Saved cookies in "name1=value1; name2=value2..." format # Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new property cookies : HTTP::Cookies = HTTP::Cookies.new
@ -211,17 +184,15 @@ class Config
# at the start of the URI # at the start of the URI
property external_videoplayback_proxy : Array(String) = [] of String property external_videoplayback_proxy : Array(String) = [] of String
# Job to refresh tokens from a Redis compatible DB
property refresh_tokens : Bool = true
property pubsub_domain : String = "" property pubsub_domain : String = ""
property ignore_user_tokens : Bool = false property ignore_user_tokens : Bool = false
property server_id_cookie_name : String = "INVIDIOUS_SERVER_ID" # Materialious redirects
property materialious_domain : String?
property tokens_server : String = ""
{% if flag?(:linux) %}
property reload_config_automatically : Bool = true
{% end %}
def disabled?(option) def disabled?(option)
case disabled = CONFIG.disable_proxy case disabled = CONFIG.disable_proxy
@ -238,64 +209,6 @@ class Config
end end
end end
def self.reload
LOGGER.info("Config: Reloading configuration")
# Load config from file or YAML string env var
env_config_file = "INVIDIOUS_CONFIG_FILE"
env_config_yaml = "INVIDIOUS_CONFIG"
config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
begin
config = Config.from_yaml(config_yaml)
rescue ex
LOGGER.error("Config: Error when reloading configuration: '#{ex.message}'")
config = CONFIG
end
# TODO: Preserve old config and don't exit on fail
{% for ivar in Config.instance_vars %}
CONFIG.{{ivar}} = config.{{ivar}}
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
if ENV.has_key?({{env_id}})
env_value = ENV.fetch({{env_id}})
success = false
# Use YAML converter if specified
{% ann = ivar.annotation(::YAML::Field) %}
{% if ann && ann[:converter] %}
CONFIG.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
success = true
# Use regular YAML parser otherwise
{% else %}
{% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
# Sort types to avoid parsing nulls and numbers as strings
{% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
{{ivar_types}}.each do |ivar_type|
if !success
begin
CONFIG.{{ivar.id}} = ivar_type.from_yaml(env_value)
success = true
rescue
# nop
end
end
end
{% end %}
# Exit on fail
if !success
LOGGER.error("Config: Error when reloading environment variables for the configuration, exiting (fixme!)")
exit(1)
end
end
{% end %}
LOGGER.info("Config: Reload successfull")
end
def self.load def self.load
# Load config from file or YAML string env var # Load config from file or YAML string env var
env_config_file = "INVIDIOUS_CONFIG_FILE" env_config_file = "INVIDIOUS_CONFIG_FILE"
@ -345,23 +258,6 @@ class Config
end end
{% end %} {% end %}
if config.invidious_companion.present?
# invidious_companion and signature_server can't work together
if config.signature_server
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
exit(1)
elsif config.invidious_companion_key.empty?
puts "Config: Please configure a key if you are using invidious companion."
exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!"
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
exit(1)
elsif config.invidious_companion_key.size < 16
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 or more."
exit(1)
end
end
# HMAC_key is mandatory # HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854 # See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty? if config.hmac_key.empty?

View file

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

View file

@ -1,22 +1,8 @@
# Languages requiring a better level of translation (at least 20%)
# to be added to the list below:
#
# "af" => "", # Afrikaans
# "az" => "", # Azerbaijani
# "be" => "", # Belarusian
# "bn_BD" => "", # Bengali (Bangladesh)
# "ia" => "", # Interlingua
# "or" => "", # Odia
# "tk" => "", # Turkmen
# "tok => "", # Toki Pona
#
LOCALES_LIST = { LOCALES_LIST = {
"ar" => "العربية", # Arabic "ar" => "العربية", # Arabic
"bg" => "български", # Bulgarian
"bn" => "বাংলা", # Bengali "bn" => "বাংলা", # Bengali
"ca" => "Català", # Catalan "ca" => "Català", # Catalan
"cs" => "Čeština", # Czech "cs" => "Čeština", # Czech
"cy" => "Cymraeg", # Welsh
"da" => "Dansk", # Danish "da" => "Dansk", # Danish
"de" => "Deutsch", # German "de" => "Deutsch", # German
"el" => "Ελληνικά", # Greek "el" => "Ελληνικά", # Greek
@ -37,7 +23,6 @@ LOCALES_LIST = {
"it" => "Italiano", # Italian "it" => "Italiano", # Italian
"ja" => "日本語", # Japanese "ja" => "日本語", # Japanese
"ko" => "한국어", # Korean "ko" => "한국어", # Korean
"lmo" => "Lombard", # Lombard
"lt" => "Lietuvių", # Lithuanian "lt" => "Lietuvių", # Lithuanian
"nb-NO" => "Norsk bokmål", # Norwegian Bokmål "nb-NO" => "Norsk bokmål", # Norwegian Bokmål
"nl" => "Nederlands", # Dutch "nl" => "Nederlands", # Dutch

View file

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

View file

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

View file

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

View file

@ -175,6 +175,7 @@ module Invidious::SigHelper
@queue = {} of TransactionID => Transaction @queue = {} of TransactionID => Transaction
@conn : Connection @conn : Connection
@uri_or_path : String @uri_or_path : String
def initialize(@uri_or_path) def initialize(@uri_or_path)
@ -200,7 +201,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

View file

@ -384,7 +384,7 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
return text return text
end end
# Generates a list of external videoplayback proxies for # Generates a list of external videoplayback proxies for
# CSP # CSP
def gen_videoplayback_proxy_list def gen_videoplayback_proxy_list
if !CONFIG.external_videoplayback_proxy.empty? if !CONFIG.external_videoplayback_proxy.empty?
@ -397,22 +397,3 @@ def gen_videoplayback_proxy_list
end end
return external_videoplayback_proxy return external_videoplayback_proxy
end end
def encrypt_ecb_without_salt(data, key)
cipher = OpenSSL::Cipher.new("aes-128-ecb")
cipher.encrypt
cipher.key = key
io = IO::Memory.new
io.write(cipher.update(data))
io.write(cipher.final)
io.rewind
return io
end
def invidious_companion_encrypt(data)
timestamp = Time.utc.to_unix
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
return Base64.urlsafe_encode(encrypted_data)
end

View file

@ -9,7 +9,7 @@ module Invidious::HttpServer
def check_external_proxy def check_external_proxy
CONFIG.external_videoplayback_proxy.each do |proxy| CONFIG.external_videoplayback_proxy.each do |proxy|
begin begin
response = HTTP::Client.get("#{proxy}/health") response = HTTP::Client.get(proxy)
if response.status_code == 200 if response.status_code == 200
@@proxy_alive = proxy @@proxy_alive = proxy
LOGGER.debug("CheckExternalProxy: Proxy set to: '#{proxy}'") LOGGER.debug("CheckExternalProxy: Proxy set to: '#{proxy}'")
@ -19,9 +19,6 @@ module Invidious::HttpServer
LOGGER.debug("CheckExternalProxy: Proxy '#{proxy}' is not available") LOGGER.debug("CheckExternalProxy: Proxy '#{proxy}' is not available")
end end
end end
if @@proxy_alive.empty?
LOGGER.warn("CheckExternalProxy: No proxies alive! Using own server proxy")
end
end end
def get_external_proxy def get_external_proxy

View file

@ -5,8 +5,8 @@ class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
def begin def begin
loop do loop do
HttpServer::Utils.check_external_proxy HttpServer::Utils.check_external_proxy
LOGGER.info("CheckExternalProxy: Done, sleeping for 10 seconds") LOGGER.info("CheckExternalProxy: Done, sleeping for 1 minute")
sleep 10.seconds sleep 1.minutes
Fiber.yield Fiber.yield
end end
end end

View file

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

View file

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

View file

@ -349,4 +349,40 @@ module Invidious::Routes::Account
return "{}" return "{}"
end end
end end
# -------------------
# poToken and visitorData tokens generation
# -------------------
# Generates a poToken & visitorData for the user, server side
def generate_tokens(env)
locale = env.get("preferences").as(Preferences).locale
preferences = env.get("preferences").as(Preferences)
user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env)
if !user
return env.redirect referer
end
user = user.as(User)
po_token, visitor_data = Tokens.generate_tokens(user.email)
if po_token.nil? || visitor_data.nil?
return error_template(500, "Internal server error. Please submit an issue here IF THE ISSUE PERSISTS: https://git.nadeko.net/Fijxu/invidious/issues")
end
user.preferences.po_token = po_token
user.preferences.visitor_data = visitor_data
Invidious::Database::Users.update_preferences(user)
REDIS_DB.del("invidious:#{user.email}:po_token")
REDIS_DB.del("invidious:#{user.email}:visitor_data")
templated "user/tokens"
end
end end

View file

@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]?
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
end
# Since some implementations create playlists based on resolution regardless of different codecs, # Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation # we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@ -60,10 +55,6 @@ module Invidious::Routes::API::Manifest
end end
end end
audio_streams.reject! do |z|
z if z.dig?("audioTrack", "audioIsDefault") == false
end
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
"profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static",
@ -186,8 +177,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("/")
@ -216,20 +208,14 @@ 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!
proxy = Invidious::HttpServer::Utils.get_external_proxy proxy = Invidious::HttpServer::Utils.get_external_proxy
if CONFIG.https_only
scheme = "https://"
else
scheme = "http://"
end
if !proxy.empty? if !proxy.empty?
"#{proxy}/videoplayback?#{raw_params}" "#{proxy}/videoplayback?#{raw_params}"
else else
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}" "#{HOST_URL}/videoplayback?#{raw_params}"
end end
end end
end end
@ -253,12 +239,7 @@ module Invidious::Routes::API::Manifest
manifest = response.body manifest = response.body
if local if local
if CONFIG.https_only manifest = manifest.gsub("https://www.youtube.com", HOST_URL)
scheme = "https://"
else
scheme = "http://"
end
manifest = manifest.gsub("https://www.youtube.com", "#{scheme}#{env.request.headers["Host"]}")
manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true")
end end

View file

@ -226,7 +226,7 @@ module Invidious::Routes::API::V1::Authenticated
end end
playlist = create_playlist(title, privacy, user) playlist = create_playlist(title, privacy, user)
env.response.headers["Location"] = "#{env.request.headers["Host"]}/api/v1/auth/playlists/#{playlist.id}" env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
env.response.status_code = 201 env.response.status_code = 201
{ {
"title" => title, "title" => title,

View file

@ -197,7 +197,6 @@ module Invidious::Routes::API::V1::Channels
get_channel() get_channel()
# Retrieve continuation from URL parameters # Retrieve continuation from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
if channel.is_age_gated if channel.is_age_gated
@ -212,7 +211,7 @@ module Invidious::Routes::API::V1::Channels
else else
begin begin
videos, next_continuation = Channel::Tabs.get_shorts( videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation, sort_by: sort_by channel, continuation: continuation
) )
rescue ex rescue ex
return error_json(500, ex) return error_json(500, ex)

View file

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

View file

@ -20,25 +20,12 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff" env.response.headers["X-Content-Type-Options"] = "nosniff"
extra_media_csp = ""
extra_connect_csp = ""
if CONFIG.invidious_companion.present?
extra_media_csp = " #{CONFIG.invidious_companion.sample.public_url}"
extra_connect_csp = " #{CONFIG.invidious_companion.sample.public_url}"
end
if !CONFIG.external_videoplayback_proxy.empty?
CONFIG.external_videoplayback_proxy.each do |proxy|
extra_media_csp += " #{proxy}"
extra_connect_csp += " #{proxy}"
end
end
# Allow media resources to be loaded from google servers # Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed # TODO: check if *.youtube.com can be removed
if CONFIG.disabled?("local") || !preferences.local if CONFIG.disabled?("local") || !preferences.local
extra_media_csp += " https://*.googlevideo.com:443 https://*.youtube.com:443" extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
else
extra_media_csp = ""
end end
# Only allow the pages at /embed/* to be embedded # Only allow the pages at /embed/* to be embedded
@ -56,9 +43,9 @@ module Invidious::Routes::BeforeAll
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data:", "img-src 'self' data:",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self'" + extra_connect_csp, "connect-src 'self'" + EXT_VIDEOP_LIST,
"manifest-src 'self'", "manifest-src 'self'",
"media-src 'self' blob:" + extra_media_csp, "media-src 'self' blob:" + extra_media_csp + EXT_VIDEOP_LIST,
"child-src 'self' blob:", "child-src 'self' blob:",
"frame-src 'self'", "frame-src 'self'",
"frame-ancestors " + frame_ancestors, "frame-ancestors " + frame_ancestors,

View file

@ -20,11 +20,10 @@ module Invidious::Routes::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase sort_by = env.params.query["sort_by"]?.try &.downcase
if channel.auto_generated if channel.auto_generated
sort_by ||= "last"
sort_options = {"last", "oldest", "newest"} sort_options = {"last", "oldest", "newest"}
items, next_continuation = fetch_channel_playlists( items, next_continuation = fetch_channel_playlists(
channel.ucid, channel.author, continuation, sort_by channel.ucid, channel.author, continuation, (sort_by || "last")
) )
items.uniq! do |item| items.uniq! do |item|
@ -50,11 +49,9 @@ module Invidious::Routes::Channels
end end
next_continuation = nil next_continuation = nil
else else
sort_by ||= "newest"
sort_options = {"newest", "oldest", "popular"} sort_options = {"newest", "oldest", "popular"}
items, next_continuation = Channel::Tabs.get_videos(
items, next_continuation = Channel::Tabs.get_60_videos( channel, continuation: continuation, sort_by: (sort_by || "newest")
channel, continuation: continuation, sort_by: sort_by
) )
end end
end end
@ -85,12 +82,13 @@ module Invidious::Routes::Channels
end end
next_continuation = nil next_continuation = nil
else else
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" # TODO: support sort option for shorts
sort_options = {"newest", "oldest", "popular"} sort_by = ""
sort_options = [] of String
# Fetch items and continuation token # Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_shorts( items, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation, sort_by: sort_by channel, continuation: continuation
) )
end end

View file

@ -416,22 +416,18 @@ module Invidious::Routes::Feeds
author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content)
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
title = entry.xpath_node("default:title", namespaces).not_nil!.content
if CONFIG.use_innertube_for_feeds begin
begin video = get_video(id, force_refresh: true)
video_ = get_video(id, force_refresh: true) rescue
rescue next # skip this video since it raised an exception (e.g. it is a scheduled live event)
next # skip this video since it raised an exception (e.g. it is a scheduled live event)
end
end end
if CONFIG.enable_user_notifications if CONFIG.enable_user_notifications
# Deliver notifications to `/api/v1/auth/notifications` # Deliver notifications to `/api/v1/auth/notifications`
payload = { payload = {
"topic" => ucid, "topic" => video.ucid,
"videoId" => id, "videoId" => video.id,
"published" => published.to_unix, "published" => published.to_unix,
}.to_json }.to_json
PG_DB.exec("NOTIFY notifications, E'#{payload}'") PG_DB.exec("NOTIFY notifications, E'#{payload}'")
@ -439,15 +435,15 @@ module Invidious::Routes::Feeds
video = ChannelVideo.new({ video = ChannelVideo.new({
id: id, id: id,
title: title, title: video.title,
published: published, published: published,
updated: updated, updated: updated,
ucid: ucid, ucid: video.ucid,
author: author, author: author,
length_seconds: video_.try &.length_seconds || 0, length_seconds: video.length_seconds,
live_now: video_.try &.live_now || false, live_now: video.live_now,
premiere_timestamp: video_.try &.premiere_timestamp || nil, premiere_timestamp: video.premiere_timestamp,
views: video_.try &.views || nil, views: video.views,
}) })
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)

View file

@ -11,9 +11,29 @@ module Invidious::Routes::Images
end end
end end
# We're encapsulating this into a proc in order to easily reuse this
# portion of the code for each request block below.
request_proc = ->(response : HTTP::Client::Response) {
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300
env.response.headers.delete("Transfer-Encoding")
return
end
proxy_file(response, env)
}
begin begin
GGPHT_POOL.client &.get(url, headers) do |resp| HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp|
return self.proxy_image(env, resp) return request_proc.call(resp)
end end
rescue ex rescue ex
end end
@ -41,10 +61,27 @@ module Invidious::Routes::Images
end end
end end
request_proc = ->(response : HTTP::Client::Response) {
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Connection"] = "close"
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300
return env.response.headers.delete("Transfer-Encoding")
end
proxy_file(response, env)
}
begin begin
get_ytimg_pool(authority).client &.get(url, headers) do |resp| HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp|
env.response.headers["Connection"] = "close" return request_proc.call(resp)
return self.proxy_image(env, resp)
end end
rescue ex rescue ex
end end
@ -64,9 +101,26 @@ module Invidious::Routes::Images
end end
end end
request_proc = ->(response : HTTP::Client::Response) {
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300 && response.status_code != 404
return env.response.headers.delete("Transfer-Encoding")
end
proxy_file(response, env)
}
begin begin
get_ytimg_pool("i9").client &.get(url, headers) do |resp| HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp|
return self.proxy_image(env, resp) return request_proc.call(resp)
end end
rescue ex rescue ex
end end
@ -111,7 +165,8 @@ module Invidious::Routes::Images
if name == "maxres.jpg" if name == "maxres.jpg"
build_thumbnails(id).each do |thumb| build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200 # This can likely be optimized into a (small) pool sometime in the future.
if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200
name = thumb[:url] + ".jpg" name = thumb[:url] + ".jpg"
break break
end end
@ -126,28 +181,29 @@ module Invidious::Routes::Images
end end
end end
request_proc = ->(response : HTTP::Client::Response) {
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300 && response.status_code != 404
return env.response.headers.delete("Transfer-Encoding")
end
proxy_file(response, env)
}
begin begin
get_ytimg_pool("i").client &.get(url, headers) do |resp| # This can likely be optimized into a (small) pool sometime in the future.
return self.proxy_image(env, resp) HTTP::Client.get("https://i.ytimg.com#{url}") do |resp|
return request_proc.call(resp)
end end
rescue ex rescue ex
end end
end end
private def self.proxy_image(env, response)
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300
return env.response.headers.delete("Transfer-Encoding")
end
return proxy_file(response, env)
end
end end

View file

@ -64,8 +64,6 @@ module Invidious::Routes::Login
# TOR or I2P address # TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"]) if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid) env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
else else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end end
@ -172,8 +170,6 @@ module Invidious::Routes::Login
# TOR or I2P address # TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"]) if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid) env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
else else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end end

View file

@ -86,6 +86,12 @@ module Invidious::Routes::PreferencesRoute
show_nick ||= "off" show_nick ||= "off"
show_nick = show_nick == "on" show_nick = show_nick == "on"
po_token = env.params.body["po_token"]?.try &.as(String)
po_token ||= CONFIG.default_user_preferences.po_token
visitor_data = env.params.body["visitor_data"]?.try &.as(String)
visitor_data ||= CONFIG.default_user_preferences.visitor_data
comments = [] of String comments = [] of String
2.times do |i| 2.times do |i|
comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i])
@ -180,6 +186,8 @@ module Invidious::Routes::PreferencesRoute
vr_mode: vr_mode, vr_mode: vr_mode,
show_nick: show_nick, show_nick: show_nick,
save_player_pos: save_player_pos, save_player_pos: save_player_pos,
po_token: po_token,
visitor_data: visitor_data,
}.to_json) }.to_json)
if user = env.get? "user" if user = env.get? "user"
@ -228,8 +236,6 @@ module Invidious::Routes::PreferencesRoute
# TOR or I2P address # TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"]) if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences) env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
else else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end end
@ -271,8 +277,6 @@ module Invidious::Routes::PreferencesRoute
# TOR or I2P address # TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"]) if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences) env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
else else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end end

View file

@ -0,0 +1,18 @@
{% skip_file if flag?(:api_only) %}
module Invidious::Routes::BackendSwitcher
def self.switch(env)
referer = get_referer(env)
backend_id = env.params.query["backend_id"]
# Checks if there is any alternative domain, like a second domain name,
# TOR or I2P address
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
env.response.cookies["SERVER_ID"] = Invidious::User::Cookies.server_id(CONFIG.alternative_domains[alt], backend_id)
else
env.response.cookies["SERVER_ID"] = Invidious::User::Cookies.server_id(CONFIG.domain, backend_id)
end
env.redirect referer
end
end

View file

@ -3,11 +3,6 @@ module Invidious::Routes::VideoPlayback
def self.get_video_playback(env) def self.get_video_playback(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
query_params = env.params.query query_params = env.params.query
array = UInt8[0x78, 0]
protobuf = Bytes.new(array.size)
array.each_with_index do |byte, index|
protobuf[index] = byte
end
fvip = query_params["fvip"]? || "3" fvip = query_params["fvip"]? || "3"
mns = query_params["mn"]?.try &.split(",") mns = query_params["mn"]?.try &.split(",")
@ -47,7 +42,11 @@ module Invidious::Routes::VideoPlayback
headers["Range"] = "bytes=#{range_for_head}" headers["Range"] = "bytes=#{range_for_head}"
end end
client = make_client(URI.parse(host), region, force_resolve: true) headers["Origin"] = "https://www.youtube.com"
headers["Referer"] = "https://www.youtube.com/"
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
client = make_client(URI.parse(host), region, force_resolve = true)
response = HTTP::Client::Response.new(500) response = HTTP::Client::Response.new(500)
error = "" error = ""
5.times do 5.times do
@ -62,7 +61,7 @@ module Invidious::Routes::VideoPlayback
if new_host != host if new_host != host
host = new_host host = new_host
client.close client.close
client = make_client(URI.parse(new_host), region, force_resolve: true) client = make_client(URI.parse(new_host), region, force_resolve = true)
end end
url = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}" url = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
@ -76,7 +75,7 @@ module Invidious::Routes::VideoPlayback
fvip = "3" fvip = "3"
host = "https://r#{fvip}---#{mn}.googlevideo.com" host = "https://r#{fvip}---#{mn}.googlevideo.com"
client = make_client(URI.parse(host), region, force_resolve: true) client = make_client(URI.parse(host), region, force_resolve = true)
rescue ex rescue ex
error = ex.message error = ex.message
end end
@ -101,7 +100,7 @@ module Invidious::Routes::VideoPlayback
end end
begin begin
client.post(url, headers, protobuf) do |resp| client.get(url, headers) do |resp|
resp.headers.each do |key, value| resp.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
@ -152,7 +151,7 @@ module Invidious::Routes::VideoPlayback
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
begin begin
client.post(url, headers, protobuf) do |resp| client.get(url, headers) do |resp|
if first_chunk if first_chunk
if !env.request.headers["Range"]? && resp.status_code == 206 if !env.request.headers["Range"]? && resp.status_code == 206
env.response.status_code = 200 env.response.status_code = 200
@ -201,7 +200,7 @@ module Invidious::Routes::VideoPlayback
break break
else else
client.close client.close
client = make_client(URI.parse(host), region, force_resolve: true) client = make_client(URI.parse(host), region, force_resolve = true)
end end
end end
@ -258,11 +257,6 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours, # YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version # so we have a mechanism here to redirect to the latest version
def self.latest_version(env) def self.latest_version(env)
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
end
id = env.params.query["id"]? id = env.params.query["id"]?
itag = env.params.query["itag"]?.try &.to_i? itag = env.params.query["itag"]?.try &.to_i?

View file

@ -3,6 +3,14 @@
module Invidious::Routes::Watch module Invidious::Routes::Watch
def self.handle(env) def self.handle(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
if !CONFIG.ignore_user_tokens
user_po_token = env.get("preferences").as(Preferences).po_token
user_visitor_data = env.get("preferences").as(Preferences).visitor_data
else
user_po_token = ""
user_visitor_data = ""
end
region = env.params.query["region"]? region = env.params.query["region"]?
if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
@ -52,7 +60,7 @@ module Invidious::Routes::Watch
env.params.query.delete_all("listen") env.params.query.delete_all("listen")
begin begin
video = get_video(id, region: params.region) video = get_video(id, region: params.region, po_token: user_po_token, visitor_data: user_visitor_data)
rescue ex : NotFoundException rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}") LOGGER.error("get_video not found: #{id} : #{ex.message}")
return error_template(404, ex) return error_template(404, ex)
@ -144,11 +152,6 @@ module Invidious::Routes::Watch
end end
end end
# Removes non default audio tracks
audio_streams.reject! do |z|
z if z.dig?("audioTrack", "audioIsDefault") == false
end
# Older videos may not have audio sources available. # Older videos may not have audio sources available.
# We redirect here so they're not unplayable # We redirect here so they're not unplayable
if audio_streams.empty? && !video.live_now if audio_streams.empty? && !video.live_now
@ -211,12 +214,6 @@ module Invidious::Routes::Watch
captions: video.captions captions: video.captions
) )
begin
video_url = fmt_stream[0]["url"].to_s
rescue
video_url = nil
end
templated "watch" templated "watch"
end end
@ -347,18 +344,14 @@ module Invidious::Routes::Watch
env.params.query["label"] = URI.decode_www_form(label.as_s) env.params.query["label"] = URI.decode_www_form(label.as_s)
return Invidious::Routes::API::V1::Videos.captions(env) return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i.to_s elsif itag = download_widget["itag"]?.try &.as_i
# URL params specific to /latest_version # URL params specific to /latest_version
env.params.query["id"] = video_id env.params.query["id"] = video_id
env.params.query["itag"] = itag.to_s
env.params.query["title"] = filename env.params.query["title"] = filename
env.params.query["local"] = "true" env.params.query["local"] = "true"
if (CONFIG.invidious_companion.present?) return Invidious::Routes::VideoPlayback.latest_version(env)
video = get_video(video_id)
return env.redirect "#{video.invidious_companion["baseUrl"].as_s}/latest_version?#{env.params.query}"
else
return Invidious::Routes::VideoPlayback.latest_version(env)
end
else else
return error_template(400, "Invalid label or itag") return error_template(400, "Invalid label or itag")
end end

View file

@ -21,6 +21,7 @@ module Invidious::Routing
get "/privacy", Routes::Misc, :privacy get "/privacy", Routes::Misc, :privacy
get "/licenses", Routes::Misc, :licenses get "/licenses", Routes::Misc, :licenses
get "/redirect", Routes::Misc, :cross_instance_redirect get "/redirect", Routes::Misc, :cross_instance_redirect
get "/switchbackend", Routes::BackendSwitcher, :switch
self.register_channel_routes self.register_channel_routes
self.register_watch_routes self.register_watch_routes
@ -76,6 +77,7 @@ module Invidious::Routing
post "/authorize_token", Routes::Account, :post_authorize_token post "/authorize_token", Routes::Account, :post_authorize_token
get "/token_manager", Routes::Account, :token_manager get "/token_manager", Routes::Account, :token_manager
post "/token_ajax", Routes::Account, :token_ajax post "/token_ajax", Routes::Account, :token_ajax
get "/generate_tokens", Routes::Account, :generate_tokens
post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription
get "/subscription_manager", Routes::Subscriptions, :subscription_manager get "/subscription_manager", Routes::Subscriptions, :subscription_manager
end end
@ -243,16 +245,17 @@ module Invidious::Routing
# Channels # Channels
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest
get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists
get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search
{% for route in {"videos", "latest", "playlists", "community", "search"} %}
get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
{% end %}
# Posts # Posts
get "/api/v1/post/:id", {{namespace}}::Channels, :post get "/api/v1/post/:id", {{namespace}}::Channels, :post
@ -270,6 +273,11 @@ module Invidious::Routing
# Authenticated # Authenticated
# The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
#
# Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
# Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences

View file

@ -45,5 +45,18 @@ struct Invidious::User
samesite: HTTP::Cookie::SameSite::Lax samesite: HTTP::Cookie::SameSite::Lax
) )
end end
# Server ID (SERVER_ID) cookie used for Sticky Sessions
# Parameter "domain" comes from the global config
def server_id(domain : String?, server_id) : HTTP::Cookie
return HTTP::Cookie.new(
name: "SERVER_ID",
domain: domain,
value: server_id,
secure: false,
http_only: true,
samesite: HTTP::Cookie::SameSite::Lax
)
end
end end
end end

View file

@ -57,6 +57,10 @@ struct Preferences
property volume : Int32 = CONFIG.default_user_preferences.volume property volume : Int32 = CONFIG.default_user_preferences.volume
property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos
@[YAML::Field(converter: Preferences::ProcessString)]
property po_token : String = ""
property visitor_data : String = ""
module BoolToString module BoolToString
def self.to_json(value : String, json : JSON::Builder) def self.to_json(value : String, json : JSON::Builder)
json.string value json.string value

View file

@ -15,7 +15,7 @@ struct Video
# NOTE: don't forget to bump this number if any change is made to # NOTE: don't forget to bump this number if any change is made to
# the `params` structure in videos/parser.cr!!! # the `params` structure in videos/parser.cr!!!
# #
SCHEMA_VERSION = 3 SCHEMA_VERSION = 2
property id : String property id : String
@ -192,10 +192,6 @@ struct Video
} }
end end
def invidious_companion : Hash(String, JSON::Any)?
info["invidiousCompanion"]?.try &.as_h || {} of String => JSON::Any
end
# Macros defining getters/setters for various types of data # Macros defining getters/setters for various types of data
private macro getset_string(name) private macro getset_string(name)
@ -298,7 +294,7 @@ struct Video
predicate_bool upcoming, isUpcoming predicate_bool upcoming, isUpcoming
end end
def get_video(id, refresh = true, region = nil, force_refresh = false) def get_video(id, refresh = true, region = nil, force_refresh = false, po_token = "", visitor_data = "")
if (video = Invidious::Database::Videos.select(id)) && !region if (video = Invidious::Database::Videos.select(id)) && !region
# If record was last updated over 10 minutes ago, or video has since premiered, # If record was last updated over 10 minutes ago, or video has since premiered,
# refresh (expire param in response lasts for 6 hours) # refresh (expire param in response lasts for 6 hours)
@ -308,7 +304,7 @@ def get_video(id, refresh = true, region = nil, force_refresh = false)
force_refresh || force_refresh ||
video.schema_version != Video::SCHEMA_VERSION # cache control video.schema_version != Video::SCHEMA_VERSION # cache control
begin begin
video = fetch_video(id, region) video = fetch_video(id, region, po_token, visitor_data)
Invidious::Database::Videos.insert(video) Invidious::Database::Videos.insert(video)
rescue ex rescue ex
Invidious::Database::Videos.delete(id) Invidious::Database::Videos.delete(id)
@ -316,7 +312,7 @@ def get_video(id, refresh = true, region = nil, force_refresh = false)
end end
end end
else else
video = fetch_video(id, region) video = fetch_video(id, region, po_token, visitor_data)
Invidious::Database::Videos.insert(video) if !region Invidious::Database::Videos.insert(video) if !region
end end
@ -324,11 +320,11 @@ def get_video(id, refresh = true, region = nil, force_refresh = false)
rescue DB::Error rescue DB::Error
# Avoid common `DB::PoolRetryAttemptsExceeded` error and friends # Avoid common `DB::PoolRetryAttemptsExceeded` error and friends
# Note: All DB errors inherit from `DB::Error` # Note: All DB errors inherit from `DB::Error`
return fetch_video(id, region) return fetch_video(id, region, po_token, visitor_data)
end end
def fetch_video(id, region) def fetch_video(id, region, po_token, visitor_data)
info = extract_video_info(video_id: id) info = extract_video_info(video_id: id, user_po_token: po_token, user_visitor_data: visitor_data)
if reason = info["reason"]? if reason = info["reason"]?
if reason == "Video unavailable" if reason == "Video unavailable"

View file

@ -123,7 +123,6 @@ module Invidious::Videos
"Esperanto", "Esperanto",
"Estonian", "Estonian",
"Filipino", "Filipino",
"Filipino (auto-generated)",
"Finnish", "Finnish",
"French", "French",
"French (auto-generated)", "French (auto-generated)",

View file

@ -50,12 +50,17 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
} }
end end
def extract_video_info(video_id : String) def extract_video_info(video_id : String, user_po_token, user_visitor_data)
# Init client config for the API # Init client config for the API
client_config = YoutubeAPI::ClientConfig.new client_config = YoutubeAPI::ClientConfig.new
redis_po_token, redis_visitor_data = Tokens.get_tokens
po_token = (user_po_token if !user_po_token.empty?) || redis_po_token || CONFIG.po_token
visitor_data = (user_visitor_data if !user_visitor_data.empty?) || redis_visitor_data || CONFIG.visitor_data
# Fetch data from the player endpoint # Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config, po_token: po_token, visitor_data: visitor_data)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
@ -100,32 +105,39 @@ def extract_video_info(video_id : String)
params = parse_video_info(video_id, player_response) params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason params["reason"] = JSON::Any.new(reason) if reason
if CONFIG.invidious_companion.present? new_player_response = nil
new_player_response = nil
# Don't use Android test suite client if po_token is passed because po_token doesn't # Don't use Android client if po_token is passed because po_token doesn't
# work for Android test suite client. # work for Android client.
if reason.nil? && CONFIG.po_token.nil? if reason.nil? && po_token.nil?
# Fetch the video streams using an Android client in order to get the # Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the # decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs: # following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config) new_player_response = try_fetch_streaming_data(video_id, client_config, po_token, visitor_data)
end
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response
params.delete("reason")
end
end end
{"captions", "playabilityStatus", "playerConfig", "storyboards", "invidiousCompanion"}.each do |f| # Last hope
# Only trigger if reason found and po_token or didn't work wth Android client.
# TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required
# if the IP address is not blocked.
if po_token.nil? && reason || po_token.nil? && new_player_response.nil?
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
new_player_response = try_fetch_streaming_data(video_id, client_config, po_token, visitor_data)
end
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response
params.delete("reason")
end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]? params[f] = player_response[f] if player_response[f]?
end end
@ -133,7 +145,7 @@ def extract_video_info(video_id : String)
if streaming_data = player_response["streamingData"]? if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key| %w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format| streaming_data.as_h[key]?.try &.as_a.each do |format|
format.as_h["url"] = JSON::Any.new(convert_url(format)) format.as_h["url"] = JSON::Any.new(convert_url(format, po_token))
end end
end end
@ -146,9 +158,9 @@ def extract_video_info(video_id : String)
return params return params
end end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig, po_token, visitor_data) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config) response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config, po_token: po_token, visitor_data: visitor_data)
playability_status = response["playabilityStatus"]["status"] playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
@ -217,17 +229,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) } .try { |t| Time.parse_rfc3339(t.as_s) }
premiere_timestamp ||= player_response.dig?(
"playabilityStatus", "liveStreamability",
"liveStreamabilityRenderer", "offlineSlate",
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
)
.try &.as_s.to_i64
.try { |t| Time.unix(t) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool .try &.as_bool || false
live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr") post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false .try &.as_bool || false
@ -457,7 +460,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
return params return params
end end
private def convert_url(fmt) private def convert_url(fmt, po_token)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
sp = cfr["sp"] sp = cfr["sp"]
url = URI.parse(cfr["url"]) url = URI.parse(cfr["url"])
@ -475,7 +478,9 @@ private def convert_url(fmt)
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n params["n"] = n if n
if token = CONFIG.po_token if !po_token.nil?
params["pot"] = po_token
elsif token = CONFIG.po_token
params["pot"] = token params["pot"] = token
end end

View file

@ -2,7 +2,6 @@
ucid = channel.ucid ucid = channel.ucid
author = HTML.escape(channel.author) author = HTML.escape(channel.author)
channel_profile_pic = URI.parse(channel.author_thumbnail).request_target channel_profile_pic = URI.parse(channel.author_thumbnail).request_target
host = env.request.headers["Host"]
relative_url = relative_url =
case selected_tab case selected_tab
@ -29,15 +28,15 @@
<%- if selected_tab.videos? -%> <%- if selected_tab.videos? -%>
<meta name="description" content="<%= channel.description %>"> <meta name="description" content="<%= channel.description %>">
<meta property="og:site_name" content="Invidious"> <meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= host %>/channel/<%= ucid %>"> <meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta property="og:title" content="<%= author %>"> <meta property="og:title" content="<%= author %>">
<meta property="og:image" content="<%= host %>/ggpht<%= channel_profile_pic %>"> <meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<meta property="og:description" content="<%= channel.description %>"> <meta property="og:description" content="<%= channel.description %>">
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:url" content="<%= host %>/channel/<%= ucid %>"> <meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>">
<meta name="twitter:title" content="<%= author %>"> <meta name="twitter:title" content="<%= author %>">
<meta name="twitter:description" content="<%= channel.description %>"> <meta name="twitter:description" content="<%= channel.description %>">
<meta name="twitter:image" content="<%= host %>/ggpht<%= channel_profile_pic %>"> <meta name="twitter:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>">
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" /> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%> <%- end -%>

View file

@ -22,8 +22,6 @@
audio_streams.each_with_index do |fmt, i| audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
bitrate = fmt["bitrate"] bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)
@ -36,12 +34,8 @@
<% end %> <% end %>
<% end %> <% end %>
<% else %> <% else %>
<% if params.quality == "dash" <% if params.quality == "dash" %>
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1" <source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
%>
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
<% end %> <% end %>
<% <%
@ -50,8 +44,6 @@
fmt_stream.each_with_index do |fmt, i| fmt_stream.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (CONFIG.invidious_companion.present?)
quality = fmt["quality"] quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)

View file

@ -1,8 +1,7 @@
<% <%
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
dark_mode = env.get("preferences").as(Preferences).dark_mode dark_mode = env.get("preferences").as(Preferences).dark_mode
current_backend = env.request.cookies[CONFIG.server_id_cookie_name]?.try &.value || env.request.headers["Host"] current_backend = env.request.cookies["SERVER_ID"]?.try &.value
current_external_videoplayback_proxy = Invidious::HttpServer::Utils.get_external_proxy()
%> %>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<%= locale %>"> <html lang="<%= locale %>">
@ -107,7 +106,6 @@
</div> </div>
<% if !CONFIG.backends.empty? %> <% if !CONFIG.backends.empty? %>
<% if !CONFIG.backend_domains.includes?(env.request.headers["Host"]) %>
<div class="h-box"> <div class="h-box">
<b>Switch Backend:</b> <b>Switch Backend:</b>
<% CONFIG.backends.each do | backend | %> <% CONFIG.backends.each do | backend | %>
@ -130,7 +128,6 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% end %>
<% if CONFIG.banner %> <% if CONFIG.banner %>
<div class="h-box"> <div class="h-box">
@ -313,10 +310,7 @@
</div> </div>
<hr/> <hr/>
<div class="footer-footer"> <div class="footer-footer">
<div class="box">You are currently using Backend: <%= current_backend %></div> <div class="box">You are currently using Backend: <%= current_backend %></p>
<% if !current_external_videoplayback_proxy.empty? %>
<div class="box">External Videoplayback Proxy: <%= current_external_videoplayback_proxy %></div>
<% end %>
<span class="left"> <span class="left">
<% if CONFIG.modified_source_code_url %> <% if CONFIG.modified_source_code_url %>
<%= translate(locale, "footer_current_version_modified") %> <%= translate(locale, "footer_current_version_modified") %>

View file

@ -126,6 +126,24 @@
<input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>> <input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>>
</div> </div>
<% if !CONFIG.ignore_user_tokens %>
<div class="pure-control-group">
<label for="po_token"><%= translate(locale, "preferences_po_token") %></label>
<input name="po_token" id="po_token" type="text" value="<%= preferences.po_token %>">
</div>
<div class="pure-control-group">
<label for="visitor_data"><%= translate(locale, "preferences_visitor_data") %></label>
<input name="visitor_data" id="visitor_data" type="text" value="<%= preferences.visitor_data %>">
</div>
<% if env.get?("user") %>
<div class="pure-control-group">
<a href="/generate_tokens?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Generate po_token and visitor_data for your account") %></a>
</div>
<% end %>
<% end %>
<legend><%= translate(locale, "preferences_category_visual") %></legend> <legend><%= translate(locale, "preferences_category_visual") %></legend>
<div class="pure-control-group"> <div class="pure-control-group">

View file

@ -0,0 +1,15 @@
<% content_for "header" do %>
<title><%= translate(locale, "Invidious token generator") %> - Invidious</title>
<% end %>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<p>po_token and visitor_data successfully generated!</p>
<p>po_token: <%= po_token %></p>
<p>visitor_data: <%= visitor_data %></p>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
</div>

View file

@ -1,7 +1,6 @@
<% ucid = video.ucid %> <% ucid = video.ucid %>
<% title = HTML.escape(video.title) %> <% title = HTML.escape(video.title) %>
<% author = HTML.escape(video.author) %> <% author = HTML.escape(video.author) %>
<% host = env.request.headers["Host"] %>
<% content_for "header" do %> <% content_for "header" do %>
@ -9,24 +8,22 @@
<meta name="description" content="<%= HTML.escape(video.short_description) %>"> <meta name="description" content="<%= HTML.escape(video.short_description) %>">
<meta name="keywords" content="<%= video.keywords.join(",") %>"> <meta name="keywords" content="<%= video.keywords.join(",") %>">
<meta property="og:site_name" content="<%= author %> | Invidious"> <meta property="og:site_name" content="<%= author %> | Invidious">
<meta property="og:url" content="<%= host %>/watch?v=<%= video.id %>"> <meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= title %>"> <meta property="og:title" content="<%= title %>">
<meta property="og:image" content="<%= host %>/vi/<%= video.id %>/maxres.jpg"> <meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>"> <meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<!-- This shouldn't be empty, ever. --> <meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>">
<meta property="og:video" content="<%= video_url %>"> <meta property="og:video:secure_url" content="<%= HOST_URL %>/embed/<%= video.id %>">
<meta property="og:video:url" content="<%= video_url %>"> <meta property="og:video:type" content="text/html">
<meta property="og:video:secure_url" content="<%= video_url %>"> <meta property="og:video:width" content="1280">
<meta property="og:video:type" content="video/mp4"> <meta property="og:video:height" content="720">
<meta property="og:video:width" content="640">
<meta property="og:video:height" content="360">
<meta name="twitter:card" content="player"> <meta name="twitter:card" content="player">
<meta name="twitter:url" content="<%= host %>/watch?v=<%= video.id %>"> <meta name="twitter:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>">
<meta name="twitter:title" content="<%= title %>"> <meta name="twitter:title" content="<%= title %>">
<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>"> <meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>">
<meta name="twitter:image" content="<%= host %>/vi/<%= video.id %>/maxres.jpg"> <meta name="twitter:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg">
<meta name="twitter:player" content="<%= host %>/embed/<%= video.id %>"> <meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>">
<meta name="twitter:player:width" content="1280"> <meta name="twitter:player:width" content="1280">
<meta name="twitter:player:height" content="720"> <meta name="twitter:player:height" content="720">
<link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>"> <link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>">

View file

@ -1,6 +1,17 @@
# Mapping of subdomain => YoutubeConnectionPool def add_yt_headers(request)
# This is needed as we may need to access arbitrary subdomains of ytimg request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
private YTIMG_POOLS = {} of String => YoutubeConnectionPool request.headers["User-Agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
request.headers["Accept-Language"] ||= "en-US,en;q=0.9"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
struct YoutubeConnectionPool struct YoutubeConnectionPool
property! url : URI property! url : URI
@ -15,15 +26,15 @@ struct YoutubeConnectionPool
def client(&) def client(&)
conn = pool.checkout conn = pool.checkout
# Proxy needs to be reinstated every time we get a client from the pool
conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
begin begin
response = yield conn response = yield conn
rescue ex rescue ex
conn.close conn.close
conn = make_client(url, force_resolve: true) conn = HTTP::Client.new(url)
conn.family = CONFIG.force_resolve
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
response = yield conn response = yield conn
ensure ensure
pool.release(conn) pool.release(conn)
@ -33,84 +44,36 @@ struct YoutubeConnectionPool
end end
private def build_pool private def build_pool
options = DB::Pool::Options.new( DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
initial_pool_size: 0, conn = HTTP::Client.new(url)
max_pool_size: capacity, conn.family = CONFIG.force_resolve
max_idle_pool_size: capacity, conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
checkout_timeout: timeout conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
) conn
DB::Pool(HTTP::Client).new(options) do
next make_client(url, force_resolve: true)
end end
end end
end end
def add_yt_headers(request) def make_client(url : URI, region = nil, force_resolve : Bool = false)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url) client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
# Force the usage of a specific configured IP Family # Force the usage of a specific configured IP Family
if force_resolve if force_resolve
client.family = CONFIG.force_resolve client.family = CONFIG.force_resolve
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end end
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
client.read_timeout = 10.seconds client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds client.connect_timeout = 10.seconds
return client return client
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &) def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy) client = make_client(url, region, force_resolve)
begin begin
yield client yield client
ensure ensure
client.close client.close
end end
end end
def make_configured_http_proxy_client
# This method is only called when configuration for an HTTP proxy are set
config_proxy = CONFIG.http_proxy.not_nil!
return HTTP::Proxy::Client.new(
config_proxy.host,
config_proxy.port,
username: config_proxy.user,
password: config_proxy.password,
)
end
# Fetches a HTTP pool for the specified subdomain of ytimg.com
#
# Creates a new one when the specified pool for the subdomain does not exist
def get_ytimg_pool(subdomain)
if pool = YTIMG_POOLS[subdomain]?
return pool
else
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size)
YTIMG_POOLS[subdomain] = pool
return pool
end
end

View file

@ -21,7 +21,6 @@ private ITEM_PARSERS = {
Parsers::ItemSectionRendererParser, Parsers::ItemSectionRendererParser,
Parsers::ContinuationItemRendererParser, Parsers::ContinuationItemRendererParser,
Parsers::HashtagRendererParser, Parsers::HashtagRendererParser,
Parsers::LockupViewModelParser,
} }
private alias InitialData = Hash(String, JSON::Any) private alias InitialData = Hash(String, JSON::Any)
@ -468,9 +467,9 @@ private module Parsers
# Parses an InnerTube richItemRenderer into a SearchVideo. # Parses an InnerTube richItemRenderer into a SearchVideo.
# Returns nil when the given object isn't a RichItemRenderer # Returns nil when the given object isn't a RichItemRenderer
# #
# A richItemRenderer seems to be a simple wrapper for a various other types, # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
# used on the hashtags result page and the channel podcast tab. It is located # by the result page for hashtags and for the podcast tab on channels.
# itself inside a richGridRenderer container. # It is located inside a continuationItems container for hashtags.
# #
module RichItemRendererParser module RichItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback) def self.process(item : JSON::Any, author_fallback : AuthorFallback)
@ -483,8 +482,6 @@ private module Parsers
child = VideoRendererParser.process(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback)
child ||= ReelItemRendererParser.process(item_contents, author_fallback) child ||= ReelItemRendererParser.process(item_contents, author_fallback)
child ||= PlaylistRendererParser.process(item_contents, author_fallback) child ||= PlaylistRendererParser.process(item_contents, author_fallback)
child ||= LockupViewModelParser.process(item_contents, author_fallback)
child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback)
return child return child
end end
@ -499,9 +496,6 @@ private module Parsers
# reelItemRenderer items are used in the new (2022) channel layout, # reelItemRenderer items are used in the new (2022) channel layout,
# in the "shorts" tab. # in the "shorts" tab.
# #
# NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel
# TODO: Confirm that hypothesis
#
module ReelItemRendererParser module ReelItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback) def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["reelItemRenderer"]? if item_contents = item["reelItemRenderer"]?
@ -588,135 +582,6 @@ private module Parsers
end end
end end
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
# Returns nil when the given object is not a lockupViewModel.
#
# This structure is present since November 2024 on the "podcasts" and
# "playlists" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer.
#
module LockupViewModelParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["lockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def self.parse(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s
thumbnail_view_model = item_contents.dig(
"contentImage", "collectionThumbnailViewModel",
"primaryThumbnail", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
# This complicated sequences tries to extract the following data structure:
# "overlays": [{
# "thumbnailOverlayBadgeViewModel": {
# "thumbnailBadges": [{
# "thumbnailBadgeViewModel": {
# "text": "430 episodes",
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
# }
# }]
# }
# }]
#
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
video_count = thumbnail_view_model.dig("overlays").as_a
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
})
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s
# TODO: Retrieve "updated" info from metadata parts
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
# One of these parts should contain a string like: "Updated 2 days ago"
# TODO: Maybe add a button to access the first video of the playlist?
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
# Available fields: "videoId", "playlistId", "params"
return SearchPlaylist.new({
title: title,
id: playlist_id,
author: author_fallback.name,
ucid: author_fallback.id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
end
def self.parser_name
return {{@type.name}}
end
end
# Parses an InnerTube shortsLockupViewModel into a SearchVideo.
# Returns nil when the given object is not a shortsLockupViewModel.
#
# This structure is present since around October 2024 on the "shorts" tab of
# the channel page and likely replaces the reelItemRenderer structure. It is
# usually (always?) encapsulated in a richItemRenderer.
#
module ShortsLockupViewModelParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["shortsLockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def self.parse(item_contents, author_fallback)
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
video_id = item_contents.dig(
"onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"
).as_s
title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s
view_count = short_text_to_number(
item_contents.dig("overlayMetadata", "secondaryText", "content").as_s
)
# Approximate to one minute, as "shorts" generally don't exceed that.
# NOTE: The actual duration is not provided by Youtube anymore.
# TODO: Maybe use -1 as an error value and handle that on the frontend?
duration = 60_i32
SearchVideo.new({
title: title,
id: video_id,
author: author_fallback.name,
ucid: author_fallback.id,
published: Time.unix(0),
views: view_count,
description_html: "",
length_seconds: duration,
premiere_timestamp: Time.unix(0),
author_verified: false,
badges: VideoBadges::None,
})
end
def self.parser_name
return {{@type.name}}
end
end
# Parses an InnerTube continuationItemRenderer into a Continuation. # Parses an InnerTube continuationItemRenderer into a Continuation.
# Returns nil when the given object isn't a continuationItemRenderer. # Returns nil when the given object isn't a continuationItemRenderer.
# #

View file

@ -3,6 +3,8 @@
# #
module YoutubeAPI module YoutubeAPI
@@visitor_data : String = ""
extend self extend self
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
@ -320,7 +322,9 @@ module YoutubeAPI
client_context["client"]["platform"] = platform client_context["client"]["platform"] = platform
end end
if CONFIG.visitor_data.is_a?(String) if !@@visitor_data.empty?
client_context["client"]["visitorData"] = @@visitor_data
elsif CONFIG.visitor_data.is_a?(String)
client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String) client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String)
end end
@ -456,7 +460,12 @@ module YoutubeAPI
*, # Force the following parameters to be passed by name *, # Force the following parameters to be passed by name
params : String, params : String,
client_config : ClientConfig | Nil = nil, client_config : ClientConfig | Nil = nil,
po_token : String | Nil,
visitor_data : String | Nil,
) )
if visitor_data
@@visitor_data = visitor_data
end
# Playback context, separate because it can be different between clients # Playback context, separate because it can be different between clients
playback_ctx = { playback_ctx = {
"html5Preference" => "HTML5_PREF_WANTS", "html5Preference" => "HTML5_PREF_WANTS",
@ -482,7 +491,7 @@ module YoutubeAPI
"contentPlaybackContext" => playback_ctx, "contentPlaybackContext" => playback_ctx,
}, },
"serviceIntegrityDimensions" => { "serviceIntegrityDimensions" => {
"poToken" => CONFIG.po_token, "poToken" => po_token || CONFIG.po_token,
}, },
} }
@ -491,11 +500,7 @@ module YoutubeAPI
data["params"] = params data["params"] = params
end end
if CONFIG.invidious_companion.present? return self._post_json("/youtubei/v1/player", data, client_config)
return self._post_invidious_companion("/youtubei/v1/player", data)
else
return self._post_json("/youtubei/v1/player", data, client_config)
end
end end
#################################################################### ####################################################################
@ -620,7 +625,9 @@ module YoutubeAPI
headers["User-Agent"] = user_agent headers["User-Agent"] = user_agent
end end
if CONFIG.visitor_data.is_a?(String) if !@@visitor_data.empty?
headers["X-Goog-Visitor-Id"] = @@visitor_data
elsif CONFIG.visitor_data.is_a?(String)
headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String) headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String)
end end
@ -632,11 +639,6 @@ module YoutubeAPI
# Send the POST request # Send the POST request
body = YT_POOL.client() do |client| body = YT_POOL.client() do |client|
client.post(url, headers: headers, body: data.to_json) do |response| client.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?) self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end end
end end
@ -661,51 +663,6 @@ module YoutubeAPI
return initial_data return initial_data
end end
####################################################################
# _post_invidious_companion(endpoint, data)
#
# Internal function that does the actual request to Invidious companion
# and handles errors.
#
# The requested data is an endpoint (URL without the domain part)
# and the data as a Hash object.
#
def _post_invidious_companion(
endpoint : String,
data : Hash
) : Hash(String, JSON::Any)
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
"Authorization" => "Bearer #{CONFIG.invidious_companion_key}",
}
# Logging
LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("Invidious companion: POST data: #{data}")
# Send the POST request
begin
invidious_companion = CONFIG.invidious_companion.sample
response = make_client(invidious_companion.private_url, use_http_proxy: false,
&.post(endpoint, headers: headers, body: data.to_json))
body = response.body
if (response.status_code != 200)
raise Exception.new(
"Error while communicating with Invidious companion: \
status code: #{response.status_code} and body: #{body.dump}"
)
end
rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end
# Convert result to Hash
initial_data = JSON.parse(body).as_h
return initial_data
end
#################################################################### ####################################################################
# _decompress(body_io, headers) # _decompress(body_io, headers)
# #