1 Commits

Author SHA1 Message Date
Emilien Devos
e476dbe25b limit feeds and delete materialized views 2024-08-14 19:38:54 +02:00
100 changed files with 908 additions and 2069 deletions

View File

@@ -38,9 +38,6 @@ Style/RedundantBegin:
Style/RedundantReturn: Style/RedundantReturn:
Enabled: false Enabled: false
Style/RedundantNext:
Enabled: false
Style/ParenthesesAroundCondition: Style/ParenthesesAroundCondition:
Enabled: false Enabled: false

2
.github/CODEOWNERS vendored
View File

@@ -6,7 +6,7 @@ docker/ @unixfox
kubernetes/ @unixfox kubernetes/ @unixfox
README.md @thefrenchghosty README.md @thefrenchghosty
config/config.example.yml @SamantazFox @unixfox config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
scripts/ @syeopite scripts/ @syeopite
shards.lock @syeopite shards.lock @syeopite

View File

@@ -1,7 +1,6 @@
name: Build and release container name: Build and release container
on: on:
workflow_dispatch:
push: push:
tags: tags:
- "v*" - "v*"
@@ -47,11 +46,9 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: quay.io/invidious/invidious images: quay.io/invidious/invidious
flavor: |
latest=false
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=latest type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
labels: | labels: |
quay.expires-after=12w quay.expires-after=12w
@@ -73,11 +70,10 @@ jobs:
with: with:
images: quay.io/invidious/invidious images: quay.io/invidious/invidious
flavor: | flavor: |
latest=false
suffix=-arm64 suffix=-arm64
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=latest type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
labels: | labels: |
quay.expires-after=12w quay.expires-after=12w

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

@@ -1,330 +1,6 @@
# CHANGELOG # CHANGELOG
## v2.20241110.0 ## 2024-04-26
### Wrap-up
This release is most importantly here to fix to the annoying "Youtube API returned error 400"
error that prevented all channel pages from loading.
If you're updating from the previous release, it provides no improvements on the ability to play
videos. If updating from a commit in-between release, it removes the "Please sign in" error caused
by a previous attempt at restoring video playback on large instances.
In the preferences, a new option allows for control of video preload. When enabled, this option
tells the browser to load the video as soon as the page is loaded (this used to be the default).
When disabled, the video starts loading only when the "play" button is pressed.
New interface languages available: Bulgarian, Welsh and Lombard
New dependency required: `tzdata`.
An HTTP proxy can be configured directly in Invidious, if needed. \
**NOTE:** In that case, it is recommended to comment out `force_resolve`.
### New features & important changes
#### For users
* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading
* Channels: Shorts can now be sorted by "newest", "oldest" and "popular"
* Preferences: Addition of the new "preload" option
* New interface languages available: Bulgarian, Welsh and Lombard
* Added "Filipino (auto-generated)" to the list of caption languages available
* Lots of new translations from Weblate
#### For instance owners
* Allow the configuration of an HTTP proxy to talk to Youtube
* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed
* The instance list is downloaded in the background to improve redirection speed
* New `colorize_logs` option makes each log level a different color
#### For developpers
* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values:
`newest`, `oldest` and `popular`
* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed
* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`,
`is3d` and `hasCaptions`
### Bugs fixed
#### User-side
* Channels: The second page of shorts now loads as expected
* Channels: Fixed intermittent empty "playlists" tab
* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page
* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker)
* Switching to another instance is much faster
* Fixed an "invalid byte sequence" error when subscribing to a playlist
* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used
#### For instance owners
* Fix `force_resolve` being ignored in some cases
#### API
* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values
### Full list of pull requests merged since the last release (newest first)
* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha)
* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox)
* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox)
* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox)
* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox)
* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue)
* Stale bot updates ([#5060], thanks @syeopite)
* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox)
* Channels: Fix for live videos ([#5027], thanks @iBicha)
* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox)
* Shards: Update database dependencies ([#5034], by @SamantazFox)
* Logger: Add color support for different log levels ([#4931], thanks @Fijxu)
* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite)
* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite)
* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox)
* Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox)
* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu)
* Fix player menus hiding onHover ready ([#4750], thanks @giacomocerquone)
* Use connection pools when requesting images from YouTube ([#4326], thanks @syeopite)
* Add support for using Invidious through a HTTP Proxy ([#4270], thanks @syeopite)
* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox)
* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite)
* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov)
* Parse more metadata badges for SearchVideos ([#4863], thanks @ChunkyProgrammer)
* Translations update from Hosted Weblate ([#4862], thanks to our many translators)
* Videos: Convert URL before putting result into cache ([#4850], by @SamantazFox)
* HTML: Add error message to "search issues on GitHub" link ([#4652], thanks @tracedgod)
* Preferences: Add option to control preloading of video data ([#4122], thanks @Nerdmind)
* Performance: Improve speed of automatic instance redirection ([#4193], thanks @syeopite)
* Remove myself from CODEOWNERS on the config file ([#4942], by @TheFrenchGhosty)
* Update latest version WEB_CREATOR + fix comment web embed ([#4930], thanks @unixfox)
* use WEB_CREATOR when po_token with WEB_EMBED as a fallback ([#4928], thanks @unixfox)
* Revert "use web screen embed for fixing potoken functionality"
* use web screen embed for fixing potoken functionality ([#4923], thanks @unixfox)
[#4122]: https://github.com/iv-org/invidious/pull/4122
[#4193]: https://github.com/iv-org/invidious/pull/4193
[#4270]: https://github.com/iv-org/invidious/pull/4270
[#4326]: https://github.com/iv-org/invidious/pull/4326
[#4652]: https://github.com/iv-org/invidious/pull/4652
[#4709]: https://github.com/iv-org/invidious/pull/4709
[#4750]: https://github.com/iv-org/invidious/pull/4750
[#4754]: https://github.com/iv-org/invidious/pull/4754
[#4850]: https://github.com/iv-org/invidious/pull/4850
[#4862]: https://github.com/iv-org/invidious/pull/4862
[#4863]: https://github.com/iv-org/invidious/pull/4863
[#4887]: https://github.com/iv-org/invidious/pull/4887
[#4888]: https://github.com/iv-org/invidious/pull/4888
[#4894]: https://github.com/iv-org/invidious/pull/4894
[#4923]: https://github.com/iv-org/invidious/pull/4923
[#4928]: https://github.com/iv-org/invidious/pull/4928
[#4930]: https://github.com/iv-org/invidious/pull/4930
[#4931]: https://github.com/iv-org/invidious/pull/4931
[#4934]: https://github.com/iv-org/invidious/pull/4934
[#4942]: https://github.com/iv-org/invidious/pull/4942
[#4984]: https://github.com/iv-org/invidious/pull/4984
[#4991]: https://github.com/iv-org/invidious/pull/4991
[#4993]: https://github.com/iv-org/invidious/pull/4993
[#4995]: https://github.com/iv-org/invidious/pull/4995
[#5027]: https://github.com/iv-org/invidious/pull/5027
[#5034]: https://github.com/iv-org/invidious/pull/5034
[#5045]: https://github.com/iv-org/invidious/pull/5045
[#5046]: https://github.com/iv-org/invidious/pull/5046
[#5059]: https://github.com/iv-org/invidious/pull/5059
[#5060]: https://github.com/iv-org/invidious/pull/5060
[#5063]: https://github.com/iv-org/invidious/pull/5063
[#5070]: https://github.com/iv-org/invidious/pull/5070
[#5071]: https://github.com/iv-org/invidious/pull/5071
## v2.20240825.2 (2024-08-26)
This releases fixes the container tags pushed on quay.io.
Previously, the ARM64 build was released under the `latest` tag, instead of `latest-arm64`.
### Full list of pull requests merged since the last release (newest first)
CI: Fix docker container tags ([#4883], by @SamantazFox)
[#4877]: https://github.com/iv-org/invidious/pull/4877
## v2.20240825.1 (2024-08-25)
Add patch component to be [semver] compliant and make github actions happy.
[semver]: https://semver.org/
### Full list of pull requests merged since the last release (newest first)
Allow manual trigger of release-container build ([#4877], thanks @syeopite)
[#4877]: https://github.com/iv-org/invidious/pull/4877
## v2.20240825.0 (2024-08-25)
### New features & important changes
#### For users
* The search bar now has a button that you can click!
* Youtube URLs can be pasted directly in the search bar. Prepend search query with a
backslash (`\`) to disable that feature (useful if you need to search for a video whose
title contains some youtube URL).
* On the channel page the "streams" tab can be sorted by either: "newest", "oldest" or "popular"
* Lots of translations have been updated (thanks to our contributors on Weblate!)
* Videos embedded in local HTML files (e.g: a webpage saved from a blog) can now be played
#### For instance owners
* Invidious now has the ability to provide a `po_token` and `visitordata` to Youtube in order to
circumvent current Youtube restrictions.
* Invidious can use an (optional) external signature server like [inv_sig_helper]. Please note that
some videos can't be played without that signature server.
* The Helm charts were moved to a separate repo: https://github.com/iv-org/invidious-helm-chart
* We have changed how containers are released: the `latest` tag now tracks tagged releases, whereas
the `master` tag tracks the most recent commits of the `master` branch ("nightly" builds).
[inv_sig_helper]: https://github.com/iv-org/inv_sig_helper
#### For developpers
* The versions of Crystal that we test in CI/CD are now: `1.9.2`, `1.10.1`, `1.11.2`, `1.12.1`.
Please note that due to a bug in the `libxml` bindings (See [#4256]), versions prior to `1.10.0`
are not recommended to use.
* Thanks to @syeopite, the code is now [ameba] compliant.
* Ameba is part of our CI/CD pipeline, and its rules will be enforced in future PRs.
* The transcript code has been rewritten to permit transcripts as a feature rather than being
only a workaround for captions. Trancripts feature is coming soon!
* Various fixes regarding the logic interacting with Youtube
* The `sort_by` parameter can be used on the `/api/v1/channels/{id}/streams` endpoint. Accepted
values are: "newest", "oldest" and "popular"
[ameba]: https://github.com/crystal-ameba/ameba
[#4256]: https://github.com/iv-org/invidious/issues/4256
### Bugs fixed
#### User-side
* Channels: fixed broken "subscribers" and "views" counters
* Watch page: playback position is reset at the end of a video, so that the next time this video
is watched, it will start from the beginning rather than 15 seconds before the end
* Watch page: the items in the "add to playlist" drop down are now sorted alphabetically
* Videos: the "genre" URL is now always pointing to a valid webpage
* Playlists: Fixed `Could not parse N episodes` error on podcast playlists
* All external links should now have the [`rel`] attibute set to `noreferrer noopener` for
increased privacy.
* Preferences: Fixed the admin-only "modified source code" input being ignored
* Watch/channel pages: use the full image URL in `og:image` and `twitter:image` meta tags
[`rel`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel
#### API
* fixed the `local` parameter not applying to `formatStreams` on `/api/v1/videos/{id}`
* fixed an `Index out of bounds` error hapenning when a playlist had no videos
* fixed duplicated query parameters in proxied video URLs
* Return actual video height/width/fps rather than hard coded values
* Fixed the `/api/v1/popular` endpoint not returning a proper error code/message when the
popular page/endpoint are disabled.
### Full list of pull requests merged since the last release (newest first)
* HTML: Sort playlists alphabetically in watch page drop down ([#4853], by @SamantazFox)
* Videos: Fix XSS vulnerability in description/comments ([#4852], thanks _anonymous_)
* YtAPI: Bump client versions ([#4849], by @SamantazFox)
* SigHelper: Fix inverted time comparison in 'check_update' ([#4845], by @SamantazFox)
* Storyboards: Various fixes and code cleaning ([#4153], by SamantazFox)
* Fix lint errors introduced in #4146 and #4295 ([#4876], thanks @syeopite)
* Search: Add support for Youtube URLs ([#4146], by @SamantazFox)
* Channel: Render age restricted channels ([#4295], thanks @ChunkyProgrammer)
* Ameba: Miscellaneous fixes ([#4807], thanks @syeopite)
* API: Proxy formatStreams URLs too ([#4859], thanks @colinleroy)
* UI: Add search button to search bar ([#4706], thanks @thansk)
* Add ability to set po_token and visitordata ID ([#4789], thanks @unixfox)
* Add support for an external signature server ([#4772], by @SamantazFox)
* Ameba: Fix Naming/VariableNames ([#4790], thanks @syeopite)
* Translations update from Hosted Weblate ([#4659])
* Ameba: Fix Lint/UselessAssign ([#4795], thanks @syeopite)
* HTML: Add rel="noreferrer noopener" to external links ([#4667], thanks @ulmemxpoc)
* Remove unused methods in Invidious::LogHandler ([#4812], thanks @syeopite)
* Ameba: Fix Lint/NotNilAfterNoBang ([#4796], thanks @syeopite)
* Ameba: Fix unused argument Lint warnings ([#4805], thanks @syeopite)
* Ameba: i18next.cr fixes ([#4806], thanks @syeopite)
* Ameba: Disable rules ([#4792], thanks @syeopite)
* Channel: parse subscriber count and channel banner ([#4785], thanks @ChunkyProgrammer)
* Player: Fix playback position of already watched videos ([#4731], thanks @Fijxu)
* Videos: Fix genre url being unusable ([#4717], thanks @meatball133)
* API: Fix out of bound error on empty playlists ([#4696], thanks @Fijxu)
* Handle playlists cataloged as Podcast ([#4695], thanks @Fijxu)
* API: Fix duplicated query parameters in proxied video URLs ([#4587], thanks @absidue)
* API: Return actual stream height, width and fps ([#4586], thanks @absidue)
* Preferences: Fix handling of modified source code URL ([#4437], thanks @nooptek)
* API: Fix URL for vtt subtitles ([#4221], thanks @karelrooted)
* Channels: Add sort options to streams ([#4224], thanks @src-tinkerer)
* API: Fix error code for disabled popular endpoint ([#4296], thanks @iBicha)
* Allow embedding videos in local HTML files ([#4450], thanks @tomasz1986)
* CI: Bump Crystal version matrix ([#4654], by @SamantazFox)
* YtAPI: Remove API keys like official clients ([#4655], by @SamantazFox)
* HTML: Use full URL in the og:image property ([#4675], thanks @Fijxu)
* Rewrite transcript logic to be more generic ([#4747], thanks @syeopite)
* CI: Run Ameba ([#4753], thanks @syeopite)
* CI: Add release based containers ([#4763], thanks @syeopite)
* move helm chart to a dedicated github repository ([#4711], thanks @unixfox)
[#4146]: https://github.com/iv-org/invidious/pull/4146
[#4153]: https://github.com/iv-org/invidious/pull/4153
[#4221]: https://github.com/iv-org/invidious/pull/4221
[#4224]: https://github.com/iv-org/invidious/pull/4224
[#4295]: https://github.com/iv-org/invidious/pull/4295
[#4296]: https://github.com/iv-org/invidious/pull/4296
[#4437]: https://github.com/iv-org/invidious/pull/4437
[#4450]: https://github.com/iv-org/invidious/pull/4450
[#4586]: https://github.com/iv-org/invidious/pull/4586
[#4587]: https://github.com/iv-org/invidious/pull/4587
[#4654]: https://github.com/iv-org/invidious/pull/4654
[#4655]: https://github.com/iv-org/invidious/pull/4655
[#4659]: https://github.com/iv-org/invidious/pull/4659
[#4667]: https://github.com/iv-org/invidious/pull/4667
[#4675]: https://github.com/iv-org/invidious/pull/4675
[#4695]: https://github.com/iv-org/invidious/pull/4695
[#4696]: https://github.com/iv-org/invidious/pull/4696
[#4706]: https://github.com/iv-org/invidious/pull/4706
[#4711]: https://github.com/iv-org/invidious/pull/4711
[#4717]: https://github.com/iv-org/invidious/pull/4717
[#4731]: https://github.com/iv-org/invidious/pull/4731
[#4747]: https://github.com/iv-org/invidious/pull/4747
[#4753]: https://github.com/iv-org/invidious/pull/4753
[#4763]: https://github.com/iv-org/invidious/pull/4763
[#4772]: https://github.com/iv-org/invidious/pull/4772
[#4785]: https://github.com/iv-org/invidious/pull/4785
[#4789]: https://github.com/iv-org/invidious/pull/4789
[#4790]: https://github.com/iv-org/invidious/pull/4790
[#4792]: https://github.com/iv-org/invidious/pull/4792
[#4795]: https://github.com/iv-org/invidious/pull/4795
[#4796]: https://github.com/iv-org/invidious/pull/4796
[#4805]: https://github.com/iv-org/invidious/pull/4805
[#4806]: https://github.com/iv-org/invidious/pull/4806
[#4807]: https://github.com/iv-org/invidious/pull/4807
[#4812]: https://github.com/iv-org/invidious/pull/4812
[#4845]: https://github.com/iv-org/invidious/pull/4845
[#4849]: https://github.com/iv-org/invidious/pull/4849
[#4852]: https://github.com/iv-org/invidious/pull/4852
[#4853]: https://github.com/iv-org/invidious/pull/4853
[#4859]: https://github.com/iv-org/invidious/pull/4859
[#4876]: https://github.com/iv-org/invidious/pull/4876
## v2.20240427 (2024-04-27)
Major bug fixes: Major bug fixes:
* Videos: Use android test suite client (#4650, thanks @SamantazFox) * Videos: Use android test suite client (#4650, thanks @SamantazFox)

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

@@ -278,14 +278,7 @@ div.thumbnail > .bottom-right-overlay {
display: inline; display: inline;
} }
.searchbar .pure-form { .searchbar .pure-form fieldset { padding: 0; }
display: flex;
}
.searchbar .pure-form fieldset {
padding: 0;
flex: 1;
}
.searchbar input[type="search"] { .searchbar input[type="search"] {
width: 100%; width: 100%;
@@ -317,16 +310,6 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 14px; background-size: 14px;
} }
.searchbar #searchbutton {
border: none;
background: none;
margin-top: 0;
}
.searchbar #searchbutton:hover {
color: rgb(0, 182, 240);
}
.user-field { .user-field {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

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

@@ -3,6 +3,7 @@ var player_data = JSON.parse(document.getElementById('player_data').textContent)
var video_data = JSON.parse(document.getElementById('video_data').textContent); var video_data = JSON.parse(document.getElementById('video_data').textContent);
var options = { var options = {
preload: 'auto',
liveui: true, liveui: true,
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0],
controlBar: { controlBar: {

View File

@@ -173,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
@@ -233,17 +222,6 @@ http_proxy:
## ##
#log_level: Info #log_level: Info
##
## Enables colors in logs. Useful for debugging purposes
## This is overridden if "-k" or "--colorize"
## 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
## Default: true
##
#colorize_logs: false
# ----------------------------- # -----------------------------
# Features # Features
@@ -729,22 +707,6 @@ default_user_preferences:
# Video player behavior # Video player behavior
# ----------------------------- # -----------------------------
##
## This option controls the value of the HTML5 <video> element's
## "preload" attribute.
##
## If set to 'false', no video data will be loaded until the user
## explicitly starts the video by clicking the "Play" button.
## If set to 'true', the web browser will buffer some video data
## while the page is loading.
##
## See: https://www.w3schools.com/tags/att_video_preload.asp
##
## Accepted values: true, false
## Default: true
##
#preload: true
## ##
## Automatically play videos on page load. ## Automatically play videos on page load.
## ##

View File

@@ -0,0 +1,6 @@
CREATE INDEX channel_videos_ucid_published_idx
ON public.channel_videos
USING btree
(ucid COLLATE pg_catalog."default", published);
DROP INDEX channel_videos_ucid_idx;

View File

@@ -19,12 +19,12 @@ CREATE TABLE IF NOT EXISTS public.channel_videos
GRANT ALL ON TABLE public.channel_videos TO current_user; GRANT ALL ON TABLE public.channel_videos TO current_user;
-- Index: public.channel_videos_ucid_idx -- Index: public.channel_videos_ucid_published_idx
-- DROP INDEX public.channel_videos_ucid_idx; -- DROP INDEX public.channel_videos_ucid_published_idx;
CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx CREATE INDEX IF NOT EXISTS channel_videos_ucid_published_idx
ON public.channel_videos ON public.channel_videos
USING btree USING btree
(ucid COLLATE pg_catalog."default"); (ucid COLLATE pg_catalog."default", published);

View File

@@ -1,4 +1,4 @@
FROM crystallang/crystal:1.12.2-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
@@ -32,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

@@ -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

60
kubernetes/values.yaml Normal file
View File

@@ -0,0 +1,60 @@
name: invidious
image:
repository: quay.io/invidious/invidious
tag: latest
pullPolicy: Always
replicaCount: 1
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 16
targetCPUUtilizationPercentage: 50
service:
type: ClusterIP
port: 3000
#loadBalancerIP:
resources: {}
#requests:
# cpu: 100m
# memory: 64Mi
#limits:
# cpu: 800m
# memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
# See https://github.com/bitnami/charts/tree/master/bitnami/postgresql
postgresql:
image:
tag: 13
auth:
username: kemal
password: kemal
database: invidious
primary:
initdb:
username: kemal
password: kemal
scriptsConfigMap: invidious-postgresql-init
# Adapted from ../config/config.yml
config:
channel_threads: 1
db:
user: kemal
password: kemal
host: invidious-postgresql
port: 5432
dbname: invidious
full_refresh: false
https_only: false
domain:

View File

@@ -483,7 +483,7 @@
"comments_view_x_replies_3": "عرض رد {{count}}", "comments_view_x_replies_3": "عرض رد {{count}}",
"comments_view_x_replies_4": "عرض الردود {{count}}", "comments_view_x_replies_4": "عرض الردود {{count}}",
"comments_view_x_replies_5": "عرض رد {{count}}", "comments_view_x_replies_5": "عرض رد {{count}}",
"search_message_use_another_instance": "يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.", "search_message_use_another_instance": " يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.",
"comments_points_count_0": "{{count}} نقطة", "comments_points_count_0": "{{count}} نقطة",
"comments_points_count_1": "نقطة واحدة", "comments_points_count_1": "نقطة واحدة",
"comments_points_count_2": "نقطتان", "comments_points_count_2": "نقطتان",

View File

@@ -471,7 +471,7 @@
"search_filters_title": "Filtry", "search_filters_title": "Filtry",
"search_filters_duration_option_medium": "Střední (4 - 20 minut)", "search_filters_duration_option_medium": "Střední (4 - 20 minut)",
"search_filters_duration_option_long": "Dlouhá (> 20 minut)", "search_filters_duration_option_long": "Dlouhá (> 20 minut)",
"search_message_use_another_instance": "Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.", "search_message_use_another_instance": " Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.",
"search_filters_features_label": "Vlastnosti", "search_filters_features_label": "Vlastnosti",
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",

View File

@@ -47,7 +47,6 @@
"Preferences": "Einstellungen", "Preferences": "Einstellungen",
"preferences_category_player": "Wiedergabeeinstellungen", "preferences_category_player": "Wiedergabeeinstellungen",
"preferences_video_loop_label": "Immer wiederholen: ", "preferences_video_loop_label": "Immer wiederholen: ",
"preferences_preload_label": "Videodaten vorladen: ",
"preferences_autoplay_label": "Automatisch abspielen: ", "preferences_autoplay_label": "Automatisch abspielen: ",
"preferences_continue_label": "Immer automatisch nächstes Video abspielen: ", "preferences_continue_label": "Immer automatisch nächstes Video abspielen: ",
"preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ", "preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ",
@@ -323,7 +322,7 @@
"channel_tab_community_label": "Gemeinschaft", "channel_tab_community_label": "Gemeinschaft",
"search_filters_sort_option_relevance": "Relevanz", "search_filters_sort_option_relevance": "Relevanz",
"search_filters_sort_option_rating": "Bewertung", "search_filters_sort_option_rating": "Bewertung",
"search_filters_sort_option_date": "Hochladedatum", "search_filters_sort_option_date": "Datum",
"search_filters_sort_option_views": "Aufrufe", "search_filters_sort_option_views": "Aufrufe",
"search_filters_type_label": "Inhaltstyp", "search_filters_type_label": "Inhaltstyp",
"search_filters_duration_label": "Dauer", "search_filters_duration_label": "Dauer",
@@ -455,7 +454,7 @@
"Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)", "Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)",
"search_filters_title": "Filtern", "search_filters_title": "Filtern",
"search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.", "search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.",
"search_message_use_another_instance": "Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.", "search_message_use_another_instance": " Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.",
"Popular enabled: ": "„Beliebt“-Seite aktiviert: ", "Popular enabled: ": "„Beliebt“-Seite aktiviert: ",
"search_message_no_results": "Keine Ergebnisse gefunden.", "search_message_no_results": "Keine Ergebnisse gefunden.",
"search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)", "search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)",
@@ -494,8 +493,5 @@
"Add to playlist": "Einer Wiedergabeliste hinzufügen", "Add to playlist": "Einer Wiedergabeliste hinzufügen",
"Search for videos": "Nach Videos suchen", "Search for videos": "Nach Videos suchen",
"toggle_theme": "Thema wechseln", "toggle_theme": "Thema wechseln",
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ", "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: "
"carousel_go_to": "Zu Folie `x` gehen",
"carousel_slide": "Folie {{current}} von {{total}}",
"carousel_skip": "Karussell überspringen"
} }

View File

@@ -489,10 +489,5 @@
"search_filters_date_label": "Ημερομηνία αναφόρτωσης", "search_filters_date_label": "Ημερομηνία αναφόρτωσης",
"Search for videos": "Αναζήτηση βίντεο", "Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.", "The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση", "Answer": "Απάντηση"
"Add to playlist": "Λίιστα αναπαραγωγής",
"Add to playlist: ": "Λίστα αναπαραγωγής: ",
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
"toggle_theme": "Αλλαγή θέματος"
} }

View File

@@ -71,7 +71,6 @@
"Preferences": "Preferences", "Preferences": "Preferences",
"preferences_category_player": "Player preferences", "preferences_category_player": "Player preferences",
"preferences_video_loop_label": "Always loop: ", "preferences_video_loop_label": "Always loop: ",
"preferences_preload_label": "Preload video data: ",
"preferences_autoplay_label": "Autoplay: ", "preferences_autoplay_label": "Autoplay: ",
"preferences_continue_label": "Play next by default: ", "preferences_continue_label": "Play next by default: ",
"preferences_continue_autoplay_label": "Autoplay next video: ", "preferences_continue_autoplay_label": "Autoplay next video: ",
@@ -191,7 +190,7 @@
"Switch Invidious Instance": "Switch Invidious Instance", "Switch Invidious Instance": "Switch Invidious Instance",
"search_message_no_results": "No results found.", "search_message_no_results": "No results found.",
"search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.", "search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.",
"search_message_use_another_instance": "You can also <a href=\"`x`\">search on another instance</a>.", "search_message_use_another_instance": " You can also <a href=\"`x`\">search on another instance</a>.",
"Hide annotations": "Hide annotations", "Hide annotations": "Hide annotations",
"Show annotations": "Show annotations", "Show annotations": "Show annotations",
"Genre: ": "Genre: ", "Genre: ": "Genre: ",
@@ -286,7 +285,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)",
@@ -424,7 +422,7 @@
"search_filters_title": "Filters", "search_filters_title": "Filters",
"search_filters_date_label": "Upload date", "search_filters_date_label": "Upload date",
"search_filters_date_option_none": "Any date", "search_filters_date_option_none": "Any date",
"search_filters_date_option_hour": "Last hour", "search_filters_date_option_hour": "Last Hour",
"search_filters_date_option_today": "Today", "search_filters_date_option_today": "Today",
"search_filters_date_option_week": "This week", "search_filters_date_option_week": "This week",
"search_filters_date_option_month": "This month", "search_filters_date_option_month": "This month",
@@ -456,7 +454,7 @@
"search_filters_sort_label": "Sort By", "search_filters_sort_label": "Sort By",
"search_filters_sort_option_relevance": "Relevance", "search_filters_sort_option_relevance": "Relevance",
"search_filters_sort_option_rating": "Rating", "search_filters_sort_option_rating": "Rating",
"search_filters_sort_option_date": "Upload date", "search_filters_sort_option_date": "Upload Date",
"search_filters_sort_option_views": "View count", "search_filters_sort_option_views": "View count",
"search_filters_apply_button": "Apply selected filters", "search_filters_apply_button": "Apply selected filters",
"Current version: ": "Current version: ", "Current version: ": "Current version: ",

View File

@@ -478,7 +478,7 @@
"tokens_count_0": "{{count}} token", "tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokens", "tokens_count_1": "{{count}} tokens",
"tokens_count_2": "{{count}} tokens", "tokens_count_2": "{{count}} tokens",
"search_message_use_another_instance": "También puedes <a href=\"`x`\">buscar en otra instancia</a>.", "search_message_use_another_instance": " También puede <a href=\"`x`\">buscar en otra instancia</a>.",
"Popular enabled: ": "¿Habilitar la sección popular? ", "Popular enabled: ": "¿Habilitar la sección popular? ",
"error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>", "error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>",
"channel_tab_streams_label": "Directos", "channel_tab_streams_label": "Directos",

View File

@@ -360,7 +360,7 @@
"search_filters_duration_label": "مدت", "search_filters_duration_label": "مدت",
"search_filters_features_label": "ویژگی‌ها", "search_filters_features_label": "ویژگی‌ها",
"search_filters_sort_label": "به ترتیب", "search_filters_sort_label": "به ترتیب",
"search_filters_date_option_hour": "ساعت گذشته", "search_filters_date_option_hour": "یک ساعت گذشته",
"search_filters_date_option_today": "امروز", "search_filters_date_option_today": "امروز",
"search_filters_date_option_week": "این هفته", "search_filters_date_option_week": "این هفته",
"search_filters_date_option_month": "این ماه", "search_filters_date_option_month": "این ماه",
@@ -461,7 +461,7 @@
"Song: ": "آهنگ: ", "Song: ": "آهنگ: ",
"Channel Sponsor": "اسپانسر کانال", "Channel Sponsor": "اسپانسر کانال",
"Standard YouTube license": "پروانه استاندارد YouTube", "Standard YouTube license": "پروانه استاندارد YouTube",
"search_message_use_another_instance": "همچنین می‌توانید <a href=\"`x`\">در نمونه‌ای دیگر هم جست‌وجو کنید</a>.", "search_message_use_another_instance": " شما همچنین می‌توانید <a href=\"`x`\">در نمونه دیگر هم جستجو کنید</a>.",
"Download is disabled": "دریافت غیرفعال است", "Download is disabled": "دریافت غیرفعال است",
"crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:", "crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:",
"playlist_button_add_items": "افزودن ویدیو", "playlist_button_add_items": "افزودن ویدیو",

View File

@@ -484,7 +484,7 @@
"search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)", "search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)",
"search_filters_apply_button": "Appliquer les filtres", "search_filters_apply_button": "Appliquer les filtres",
"search_message_no_results": "Aucun résultat.", "search_message_no_results": "Aucun résultat.",
"search_message_use_another_instance": "Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.", "search_message_use_another_instance": " Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.",
"search_filters_type_option_all": "Tous les types", "search_filters_type_option_all": "Tous les types",
"search_filters_date_label": "Date d'ajout", "search_filters_date_label": "Date d'ajout",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",

View File

@@ -449,30 +449,30 @@
"Cantonese (Hong Kong)": "Kantonski (Hong Kong)", "Cantonese (Hong Kong)": "Kantonski (Hong Kong)",
"Chinese": "Kineski", "Chinese": "Kineski",
"Chinese (Taiwan)": "Kineski (Tajvan)", "Chinese (Taiwan)": "Kineski (Tajvan)",
"Dutch (auto-generated)": "Nizozemski (automatski generirano)", "Dutch (auto-generated)": "Nizozemski (automatski generiran)",
"French (auto-generated)": "Francuski (automatski generirano)", "French (auto-generated)": "Francuski (automatski generiran)",
"Indonesian (auto-generated)": "Indonezijski (automatski generirano)", "Indonesian (auto-generated)": "Indonezijski (automatski generiran)",
"Interlingue": "Interlingua", "Interlingue": "Interlingua",
"Japanese (auto-generated)": "Japanski (automatski generirano)", "Japanese (auto-generated)": "Japanski (automatski generiran)",
"Russian (auto-generated)": "Ruski (automatski generirano)", "Russian (auto-generated)": "Ruski (automatski generiran)",
"Turkish (auto-generated)": "Turski (automatski generirano)", "Turkish (auto-generated)": "Turski (automatski generiran)",
"Vietnamese (auto-generated)": "Vijetnamski (automatski generirano)", "Vietnamese (auto-generated)": "Vijetnamski (automatski generiran)",
"Spanish (Spain)": "Španjolski (Španjolska)", "Spanish (Spain)": "Španjolski (Španjolska)",
"Italian (auto-generated)": "Talijanski (automatski generirano)", "Italian (auto-generated)": "Talijanski (automatski generiran)",
"Portuguese (Brazil)": "Portugalski (Brazil)", "Portuguese (Brazil)": "Portugalski (Brazil)",
"Spanish (Mexico)": "Španjolski (Meksiko)", "Spanish (Mexico)": "Španjolski (Meksiko)",
"German (auto-generated)": "Njemački (automatski generirano)", "German (auto-generated)": "Njemački (automatski generiran)",
"Chinese (China)": "Kineski (Kina)", "Chinese (China)": "Kineski (Kina)",
"Chinese (Hong Kong)": "Kineski (Hong Kong)", "Chinese (Hong Kong)": "Kineski (Hong Kong)",
"Korean (auto-generated)": "Korejski (automatski generirano)", "Korean (auto-generated)": "Korejski (automatski generiran)",
"Portuguese (auto-generated)": "Portugalski (automatski generirano)", "Portuguese (auto-generated)": "Portugalski (automatski generiran)",
"Spanish (auto-generated)": "Španjolski (automatski generirano)", "Spanish (auto-generated)": "Španjolski (automatski generiran)",
"preferences_watch_history_label": "Aktiviraj povijest gledanja: ", "preferences_watch_history_label": "Aktiviraj povijest gledanja: ",
"search_filters_title": "Filtri", "search_filters_title": "Filtri",
"search_filters_date_option_none": "Bilo koji datum", "search_filters_date_option_none": "Bilo koji datum",
"search_filters_date_label": "Datum prijenosa", "search_filters_date_label": "Datum prijenosa",
"search_message_no_results": "Nema rezultata.", "search_message_no_results": "Nema rezultata.",
"search_message_use_another_instance": "Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.", "search_message_use_another_instance": " Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.",
"search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.", "search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_filters_duration_option_none": "Bilo koje duljine", "search_filters_duration_option_none": "Bilo koje duljine",

View File

@@ -7,7 +7,7 @@
"invidious": "Invidious", "invidious": "Invidious",
"Image CAPTCHA": "Imagine CAPTCHA", "Image CAPTCHA": "Imagine CAPTCHA",
"newest": "plus nove", "newest": "plus nove",
"generic_button_save": "Salveguardar", "generic_button_save": "Salvar",
"Dark mode: ": "Modo obscur: ", "Dark mode: ": "Modo obscur: ",
"preferences_dark_mode_label": "Thema: ", "preferences_dark_mode_label": "Thema: ",
"preferences_category_subscription": "Preferentias de subscription", "preferences_category_subscription": "Preferentias de subscription",
@@ -23,7 +23,7 @@
"light": "clar", "light": "clar",
"No": "Non", "No": "Non",
"youtube": "YouTube", "youtube": "YouTube",
"LIVE": "IN DIRECTO", "LIVE": "IN DIRECTE",
"reddit": "Reddit", "reddit": "Reddit",
"preferences_category_player": "Preferentias de reproductor", "preferences_category_player": "Preferentias de reproductor",
"Preferences": "Preferentias", "Preferences": "Preferentias",

View File

@@ -396,7 +396,7 @@
"toggle_theme": "Víxla þema", "toggle_theme": "Víxla þema",
"carousel_skip": "Sleppa hringekjunni", "carousel_skip": "Sleppa hringekjunni",
"preferences_quality_option_medium": "Miðlungs", "preferences_quality_option_medium": "Miðlungs",
"search_message_use_another_instance": "Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.", "search_message_use_another_instance": " Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.",
"footer_source_code": "Grunnkóði", "footer_source_code": "Grunnkóði",
"English (United Kingdom)": "Enska (Bretland)", "English (United Kingdom)": "Enska (Bretland)",
"English (United States)": "Enska (Bandarísk)", "English (United States)": "Enska (Bandarísk)",

View File

@@ -449,7 +449,7 @@
"Portuguese (Brazil)": "Portoghese (Brasile)", "Portuguese (Brazil)": "Portoghese (Brasile)",
"preferences_watch_history_label": "Attiva cronologia di riproduzione: ", "preferences_watch_history_label": "Attiva cronologia di riproduzione: ",
"French (auto-generated)": "Francese (generati automaticamente)", "French (auto-generated)": "Francese (generati automaticamente)",
"search_message_use_another_instance": "Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.", "search_message_use_another_instance": " Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.",
"search_message_no_results": "Nessun risultato trovato.", "search_message_no_results": "Nessun risultato trovato.",
"search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.", "search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.",
"English (United States)": "Inglese (Stati Uniti)", "English (United States)": "Inglese (Stati Uniti)",

View File

@@ -363,7 +363,7 @@
"search_filters_features_option_location": "場所", "search_filters_features_option_location": "場所",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"Current version: ": "現在のバージョン: ", "Current version: ": "現在のバージョン: ",
"next_steps_error_message": "以下をお試しください: ", "next_steps_error_message": "以下をお試しください: ",
"next_steps_error_message_refresh": "再読み込み", "next_steps_error_message_refresh": "再読み込み",
"next_steps_error_message_go_to_youtube": "YouTubeを開く", "next_steps_error_message_go_to_youtube": "YouTubeを開く",
"search_filters_duration_option_short": "4分未満", "search_filters_duration_option_short": "4分未満",
@@ -396,7 +396,7 @@
"download_subtitles": "字幕 - `x` (.vtt)", "download_subtitles": "字幕 - `x` (.vtt)",
"search_filters_features_option_purchased": "購入済み", "search_filters_features_option_purchased": "購入済み",
"preferences_quality_option_dash": "DASH (適応的画質)", "preferences_quality_option_dash": "DASH (適応的画質)",
"preferences_quality_dash_option_worst": "最", "preferences_quality_dash_option_worst": "最",
"preferences_quality_dash_option_best": "最高", "preferences_quality_dash_option_best": "最高",
"videoinfo_started_streaming_x_ago": "`x`前に配信を開始", "videoinfo_started_streaming_x_ago": "`x`前に配信を開始",
"videoinfo_watch_on_youTube": "YouTubeで視聴", "videoinfo_watch_on_youTube": "YouTubeで視聴",
@@ -434,7 +434,7 @@
"crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す", "crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す",
"crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む", "crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む",
"Popular enabled: ": "人気動画を有効化 ", "Popular enabled: ": "人気動画を有効化 ",
"search_message_use_another_instance": "<a href=\"`x`\">別のインスタンス上での検索</a>も可能です。", "search_message_use_another_instance": " <a href=\"`x`\">別のインスタンス上での検索</a>も可能です。",
"search_filters_apply_button": "選択したフィルターを適用", "search_filters_apply_button": "選択したフィルターを適用",
"user_saved_playlists": "`x`個の保存済みの再生リスト", "user_saved_playlists": "`x`個の保存済みの再生リスト",
"crash_page_you_found_a_bug": "Invidious のバグのようです!", "crash_page_you_found_a_bug": "Invidious のバグのようです!",

View File

@@ -18,8 +18,8 @@
"preferences_related_videos_label": "관련 동영상 보기: ", "preferences_related_videos_label": "관련 동영상 보기: ",
"Fallback captions: ": "대체 자막: ", "Fallback captions: ": "대체 자막: ",
"preferences_captions_label": "기본 자막: ", "preferences_captions_label": "기본 자막: ",
"reddit": "레딧", "reddit": "Reddit",
"youtube": "유튜브", "youtube": "YouTube",
"preferences_comments_label": "기본 댓글: ", "preferences_comments_label": "기본 댓글: ",
"preferences_volume_label": "플레이어 볼륨: ", "preferences_volume_label": "플레이어 볼륨: ",
"preferences_quality_label": "선호하는 비디오 품질: ", "preferences_quality_label": "선호하는 비디오 품질: ",
@@ -48,7 +48,7 @@
"An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안",
"History": "시청 기록", "History": "시청 기록",
"Delete account?": "계정을 삭제 하시겠습니까?", "Delete account?": "계정을 삭제 하시겠습니까?",
"Export data as JSON": "인비디어스 데이터 내보내기 (.json)", "Export data as JSON": "JSON으로 데이터 내보내기",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)",
"Export subscriptions as OPML": "OPML로 구독 내보내기", "Export subscriptions as OPML": "OPML로 구독 내보내기",
"Export": "내보내기", "Export": "내보내기",
@@ -78,10 +78,10 @@
"Subscribe": "구독", "Subscribe": "구독",
"Unsubscribe": "구독 취소", "Unsubscribe": "구독 취소",
"LIVE": "실시간", "LIVE": "실시간",
"generic_views_count_0": "{{count}} 조회수", "generic_views_count_0": "조회수 {{count}}",
"generic_videos_count_0": "{{count}} 동영상", "generic_videos_count_0": "동영상 {{count}}",
"generic_playlists_count_0": "{{count}} 재생목록", "generic_playlists_count_0": "재생목록 {{count}}",
"generic_subscribers_count_0": "{{count}} 구독자", "generic_subscribers_count_0": "구독자 {{count}}",
"generic_subscriptions_count_0": "{{count}} 구독", "generic_subscriptions_count_0": "{{count}} 구독",
"search_filters_type_option_playlist": "재생목록", "search_filters_type_option_playlist": "재생목록",
"Korean": "한국어", "Korean": "한국어",
@@ -109,14 +109,14 @@
"This channel does not exist.": "이 채널은 존재하지 않습니다.", "This channel does not exist.": "이 채널은 존재하지 않습니다.",
"Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널", "Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널",
"channel:`x`": "채널:`x`", "channel:`x`": "채널:`x`",
"Show replies": "댓글 보기", "Show replies": "댓글 보기",
"Hide replies": "댓글 숨기기", "Hide replies": "댓글 숨기기",
"Incorrect password": "잘못된 비밀번호", "Incorrect password": "잘못된 비밀번호",
"License: ": "라이선스: ", "License: ": "라이선스: ",
"Genre: ": "장르: ", "Genre: ": "장르: ",
"Editing playlist `x`": "재생목록 `x` 수정하기", "Editing playlist `x`": "재생목록 `x` 수정하기",
"Playlist privacy": "재생목록 공개 범위", "Playlist privacy": "재생목록 공개 범위",
"Watch on YouTube": "유튜브에서 보기", "Watch on YouTube": "YouTube에서 보기",
"Show less": "간략히", "Show less": "간략히",
"Show more": "더보기", "Show more": "더보기",
"Title": "제목", "Title": "제목",
@@ -125,7 +125,7 @@
"Delete playlist": "재생목록 삭제", "Delete playlist": "재생목록 삭제",
"Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?", "Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?",
"Updated `x` ago": "`x` 전에 업데이트됨", "Updated `x` ago": "`x` 전에 업데이트됨",
"Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.", "Released under the AGPLv3 on Github.": "GitHub에 AGPLv3 으로 배포됩니다.",
"View all playlists": "모든 재생목록 보기", "View all playlists": "모든 재생목록 보기",
"Private": "비공개", "Private": "비공개",
"Unlisted": "목록에 없음", "Unlisted": "목록에 없음",
@@ -135,12 +135,12 @@
"Source available here.": "소스는 여기에서 사용할 수 있습니다.", "Source available here.": "소스는 여기에서 사용할 수 있습니다.",
"Log out": "로그아웃", "Log out": "로그아웃",
"search": "검색", "search": "검색",
"subscriptions_unseen_notifs_count_0": "{{count}} 읽지 않은 알림", "subscriptions_unseen_notifs_count_0": "읽지 않은 알림 {{count}}개",
"Subscriptions": "구독", "Subscriptions": "구독",
"revoke": "철회", "revoke": "철회",
"unsubscribe": "구독 취소", "unsubscribe": "구독 취소",
"Import/export": "가져오기/내보내기", "Import/export": "가져오기/내보내기",
"tokens_count_0": "{{count}} 토큰", "tokens_count_0": "토큰 {{count}}",
"Token": "토큰", "Token": "토큰",
"Token manager": "토큰 관리자", "Token manager": "토큰 관리자",
"Subscription manager": "구독 관리자", "Subscription manager": "구독 관리자",
@@ -163,7 +163,7 @@
"Clear watch history": "시청 기록 지우기", "Clear watch history": "시청 기록 지우기",
"preferences_category_data": "데이터 설정", "preferences_category_data": "데이터 설정",
"`x` is live": "`x` 이(가) 라이브 중입니다", "`x` is live": "`x` 이(가) 라이브 중입니다",
"`x` uploaded a video": "`x` 동영상 게시", "`x` uploaded a video": "`x` 이(가) 동영상 게시했습니다",
"Enable web notifications": "웹 알림 활성화", "Enable web notifications": "웹 알림 활성화",
"preferences_notifications_only_label": "알림만 표시 (있는 경우): ", "preferences_notifications_only_label": "알림만 표시 (있는 경우): ",
"preferences_unseen_only_label": "시청하지 않은 것만 표시: ", "preferences_unseen_only_label": "시청하지 않은 것만 표시: ",
@@ -241,7 +241,7 @@
"Could not create mix.": "믹스를 생성할 수 없습니다.", "Could not create mix.": "믹스를 생성할 수 없습니다.",
"`x` ago": "`x` 전", "`x` ago": "`x` 전",
"comments_view_x_replies_0": "답글 {{count}}개 보기", "comments_view_x_replies_0": "답글 {{count}}개 보기",
"View Reddit comments": "레딧 댓글 보기", "View Reddit comments": "Reddit 댓글 보기",
"Engagement: ": "약속: ", "Engagement: ": "약속: ",
"Wilson score: ": "Wilson Score: ", "Wilson score: ": "Wilson Score: ",
"Family friendly? ": "전연령 영상입니까? ", "Family friendly? ": "전연령 영상입니까? ",
@@ -267,8 +267,8 @@
"Bulgarian": "불가리아어", "Bulgarian": "불가리아어",
"Bosnian": "보스니아어", "Bosnian": "보스니아어",
"Belarusian": "벨라루스어", "Belarusian": "벨라루스어",
"View more comments on Reddit": "레딧에서 댓글 더 보기", "View more comments on Reddit": "Reddit에서 댓글 더 보기",
"View YouTube comments": "유튜브 댓글 보기", "View YouTube comments": "YouTube 댓글 보기",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.",
"Shared `x`": "`x` 업로드", "Shared `x`": "`x` 업로드",
"Whitelisted regions: ": "차단되지 않은 지역: ", "Whitelisted regions: ": "차단되지 않은 지역: ",
@@ -289,7 +289,7 @@
"Empty playlist": "재생목록 비어 있음", "Empty playlist": "재생목록 비어 있음",
"Show annotations": "주석 보이기", "Show annotations": "주석 보이기",
"Hide annotations": "주석 숨기기", "Hide annotations": "주석 숨기기",
"Switch Invidious Instance": "인비디어스 인스턴스 변경", "Switch Invidious Instance": "Invidious 인스턴스 변경",
"Spanish": "스페인어", "Spanish": "스페인어",
"Southern Sotho": "소토어", "Southern Sotho": "소토어",
"Somali": "소말리어", "Somali": "소말리어",
@@ -329,7 +329,7 @@
"Swedish": "스웨덴어", "Swedish": "스웨덴어",
"Spanish (Latin America)": "스페인어 (라틴 아메리카)", "Spanish (Latin America)": "스페인어 (라틴 아메리카)",
"comments_points_count_0": "{{count}} 포인트", "comments_points_count_0": "{{count}} 포인트",
"Invidious Private Feed for `x`": "`x` 에 대한 인비디어스 비공개 피드", "Invidious Private Feed for `x`": "`x` 에 대한 Invidious 비공개 피드",
"Premieres `x`": "최초 공개 `x`", "Premieres `x`": "최초 공개 `x`",
"Premieres in `x`": "`x` 후 최초 공개", "Premieres in `x`": "`x` 후 최초 공개",
"next_steps_error_message": "다음 방법을 시도해 보세요: ", "next_steps_error_message": "다음 방법을 시도해 보세요: ",
@@ -408,7 +408,7 @@
"preferences_quality_dash_option_1080p": "1080p", "preferences_quality_dash_option_1080p": "1080p",
"preferences_quality_dash_option_worst": "최저", "preferences_quality_dash_option_worst": "최저",
"preferences_watch_history_label": "시청 기록 저장: ", "preferences_watch_history_label": "시청 기록 저장: ",
"invidious": "인비디어스", "invidious": "Invidious",
"preferences_quality_option_small": "낮음", "preferences_quality_option_small": "낮음",
"preferences_quality_dash_option_auto": "자동", "preferences_quality_dash_option_auto": "자동",
"preferences_quality_dash_option_480p": "480p", "preferences_quality_dash_option_480p": "480p",
@@ -453,7 +453,7 @@
"channel_tab_streams_label": "실시간 스트리밍", "channel_tab_streams_label": "실시간 스트리밍",
"channel_tab_channels_label": "채널", "channel_tab_channels_label": "채널",
"channel_tab_playlists_label": "재생목록", "channel_tab_playlists_label": "재생목록",
"Standard YouTube license": "표준 유튜브 라이선스", "Standard YouTube license": "표준 YouTube 라이선스",
"Song: ": "제목: ", "Song: ": "제목: ",
"Channel Sponsor": "채널 스폰서", "Channel Sponsor": "채널 스폰서",
"Album: ": "앨범: ", "Album: ": "앨범: ",

View File

@@ -322,13 +322,13 @@
"channel_tab_community_label": "Gemenskap", "channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "relevans", "search_filters_sort_option_relevance": "relevans",
"search_filters_sort_option_rating": "vurdering", "search_filters_sort_option_rating": "vurdering",
"search_filters_sort_option_date": "Opplastingsdato", "search_filters_sort_option_date": "dato",
"search_filters_sort_option_views": "visninger", "search_filters_sort_option_views": "visninger",
"search_filters_type_label": "innholdstype", "search_filters_type_label": "innholdstype",
"search_filters_duration_label": "varighet", "search_filters_duration_label": "varighet",
"search_filters_features_label": "funksjoner", "search_filters_features_label": "funksjoner",
"search_filters_sort_label": "sorter", "search_filters_sort_label": "sorter",
"search_filters_date_option_hour": "Siste time", "search_filters_date_option_hour": "time",
"search_filters_date_option_today": "i dag", "search_filters_date_option_today": "i dag",
"search_filters_date_option_week": "uke", "search_filters_date_option_week": "uke",
"search_filters_date_option_month": "måned", "search_filters_date_option_month": "måned",
@@ -459,7 +459,7 @@
"search_message_no_results": "Resultatløst.", "search_message_no_results": "Resultatløst.",
"search_filters_type_option_all": "Alle typer", "search_filters_type_option_all": "Alle typer",
"search_filters_duration_option_none": "Enhver varighet", "search_filters_duration_option_none": "Enhver varighet",
"search_message_use_another_instance": "Du kan også <a href=\"`x`\">søke på en annen instans</a>.", "search_message_use_another_instance": " Du kan også <a href=\"`x`\">søke på en annen instans</a>.",
"search_filters_date_label": "Opplastningsdato", "search_filters_date_label": "Opplastningsdato",
"search_filters_apply_button": "Bruk valgte filtre", "search_filters_apply_button": "Bruk valgte filtre",
"search_filters_date_option_none": "Siden begynnelsen", "search_filters_date_option_none": "Siden begynnelsen",
@@ -494,7 +494,5 @@
"carousel_slide": "Lysark {{current}} av {{total}}", "carousel_slide": "Lysark {{current}} av {{total}}",
"carousel_skip": "Hopp over karusellen", "carousel_skip": "Hopp over karusellen",
"Add to playlist": "Legg til i spilleliste", "Add to playlist": "Legg til i spilleliste",
"Add to playlist: ": "Legg til i spilleliste: ", "Add to playlist: ": "Legg til i spilleliste: "
"The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
"toggle_theme": "Endre utseende"
} }

View File

@@ -317,13 +317,13 @@
"channel_tab_community_label": "Gemeenschap", "channel_tab_community_label": "Gemeenschap",
"search_filters_sort_option_relevance": "relevantie", "search_filters_sort_option_relevance": "relevantie",
"search_filters_sort_option_rating": "beoordeling", "search_filters_sort_option_rating": "beoordeling",
"search_filters_sort_option_date": "Upload datum", "search_filters_sort_option_date": "datum",
"search_filters_sort_option_views": "keren bekeken", "search_filters_sort_option_views": "keren bekeken",
"search_filters_type_label": "Type inhoud", "search_filters_type_label": "Type inhoud",
"search_filters_duration_label": "duur", "search_filters_duration_label": "duur",
"search_filters_features_label": "eigenschappen", "search_filters_features_label": "eigenschappen",
"search_filters_sort_label": "sorteren", "search_filters_sort_label": "sorteren",
"search_filters_date_option_hour": "Laatste uur", "search_filters_date_option_hour": "uur",
"search_filters_date_option_today": "vandaag", "search_filters_date_option_today": "vandaag",
"search_filters_date_option_week": "week", "search_filters_date_option_week": "week",
"search_filters_date_option_month": "maand", "search_filters_date_option_month": "maand",
@@ -357,7 +357,7 @@
"footer_original_source_code": "Originele bron-code", "footer_original_source_code": "Originele bron-code",
"footer_modfied_source_code": "Gewijzigde bron-code", "footer_modfied_source_code": "Gewijzigde bron-code",
"adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats", "adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats",
"next_steps_error_message": "Waarna u zou kunnen proberen om: ", "next_steps_error_message": "Daarna moet u proberen om: ",
"footer_source_code": "Bron-code", "footer_source_code": "Bron-code",
"search_filters_duration_option_long": "Lang (> 20 minuten)", "search_filters_duration_option_long": "Lang (> 20 minuten)",
"preferences_quality_option_dash": "DASH (adaptieve kwaliteit)", "preferences_quality_option_dash": "DASH (adaptieve kwaliteit)",
@@ -450,7 +450,7 @@
"Chinese (Hong Kong)": "Chinees (Hongkong)", "Chinese (Hong Kong)": "Chinees (Hongkong)",
"Korean (auto-generated)": "Koreaans (automatisch gegenereerd)", "Korean (auto-generated)": "Koreaans (automatisch gegenereerd)",
"search_filters_apply_button": "Geselecteerde filters toepassen", "search_filters_apply_button": "Geselecteerde filters toepassen",
"search_message_use_another_instance": "Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.", "search_message_use_another_instance": " Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.",
"Cantonese (Hong Kong)": "Kantonees (Hongkong)", "Cantonese (Hong Kong)": "Kantonees (Hongkong)",
"Chinese (China)": "Chinees (China)", "Chinese (China)": "Chinees (China)",
"crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt", "crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt",
@@ -477,7 +477,7 @@
"Song: ": "Lied: ", "Song: ": "Lied: ",
"generic_channels_count": "{{count}} kanaal", "generic_channels_count": "{{count}} kanaal",
"generic_channels_count_plural": "{{count}} kanalen", "generic_channels_count_plural": "{{count}} kanalen",
"Popular enabled: ": "Populair ingeschakeld: ", "Popular enabled: ": "Populair geactiveerd: ",
"channel_tab_playlists_label": "Afspeellijsten", "channel_tab_playlists_label": "Afspeellijsten",
"generic_button_edit": "Bewerken", "generic_button_edit": "Bewerken",
"Music in this video": "Muziek in deze video", "Music in this video": "Muziek in deze video",

View File

@@ -478,7 +478,7 @@
"search_filters_date_label": "Data przesłania", "search_filters_date_label": "Data przesłania",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_filters_date_option_none": "Dowolna data", "search_filters_date_option_none": "Dowolna data",
"search_message_use_another_instance": "Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.", "search_message_use_another_instance": " Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.",
"search_filters_type_option_all": "Dowolny typ", "search_filters_type_option_all": "Dowolny typ",
"search_filters_duration_option_none": "Dowolna długość", "search_filters_duration_option_none": "Dowolna długość",
"search_filters_duration_option_medium": "Średnia (4-20 minut)", "search_filters_duration_option_medium": "Średnia (4-20 minut)",

View File

@@ -474,7 +474,7 @@
"Spanish (auto-generated)": "Espanhol (gerado automaticamente)", "Spanish (auto-generated)": "Espanhol (gerado automaticamente)",
"Spanish (Mexico)": "Espanhol (México)", "Spanish (Mexico)": "Espanhol (México)",
"search_filters_duration_option_none": "Qualquer duração", "search_filters_duration_option_none": "Qualquer duração",
"search_message_use_another_instance": "Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.", "search_message_use_another_instance": " Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.",
"Spanish (Spain)": "Espanhol (Espanha)", "Spanish (Spain)": "Espanhol (Espanha)",
"Turkish (auto-generated)": "Turco (gerado automaticamente)", "Turkish (auto-generated)": "Turco (gerado automaticamente)",
"search_filters_duration_option_medium": "Médio (4 - 20 minutos)", "search_filters_duration_option_medium": "Médio (4 - 20 minutos)",

View File

@@ -448,7 +448,7 @@
"Chinese (Taiwan)": "Chinês (Taiwan)", "Chinese (Taiwan)": "Chinês (Taiwan)",
"search_message_no_results": "Nenhum resultado encontrado.", "search_message_no_results": "Nenhum resultado encontrado.",
"search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.", "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.",
"search_message_use_another_instance": "Também pode <a href=\"`x`\">pesquisar noutra instância</a>.", "search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.",
"English (United Kingdom)": "Inglês (Reino Unido)", "English (United Kingdom)": "Inglês (Reino Unido)",
"English (United States)": "Inglês (Estados Unidos)", "English (United States)": "Inglês (Estados Unidos)",
"Cantonese (Hong Kong)": "Cantonês (Hong Kong)", "Cantonese (Hong Kong)": "Cantonês (Hong Kong)",
@@ -508,7 +508,7 @@
"toggle_theme": "Trocar tema", "toggle_theme": "Trocar tema",
"Add to playlist": "Adicionar à lista de reprodução", "Add to playlist": "Adicionar à lista de reprodução",
"Add to playlist: ": "Adicionar à lista de reprodução: ", "Add to playlist: ": "Adicionar à lista de reprodução: ",
"Answer": "Responder", "Answer": "Resposta",
"Search for videos": "Procurar vídeos", "Search for videos": "Procurar vídeos",
"carousel_slide": "Diapositivo {{current}} de{{total}}", "carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel", "carousel_skip": "Ignorar carrossel",

View File

@@ -509,9 +509,6 @@
"Add to playlist: ": "Добавить в плейлист: ", "Add to playlist: ": "Добавить в плейлист: ",
"Answer": "Ответить", "Answer": "Ответить",
"Search for videos": "Поиск видео", "Search for videos": "Поиск видео",
"The Popular feed has been disabled by the administrator.": "Лента популярного была отключена администратором.", "The Popular feed has been disabled by the administrator.": "Популярная лента была отключена администратором.",
"toggle_theme": "Переключатель тем", "toggle_theme": "Переключатель тем"
"carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё",
"carousel_go_to": "Перейти к странице `x`"
} }

View File

@@ -257,13 +257,13 @@
"Video mode": "Mënyrë video", "Video mode": "Mënyrë video",
"channel_tab_videos_label": "Video", "channel_tab_videos_label": "Video",
"search_filters_sort_option_rating": "Vlerësim", "search_filters_sort_option_rating": "Vlerësim",
"search_filters_sort_option_date": "Datë ngarkimi", "search_filters_sort_option_date": "Datë Ngarkimi",
"search_filters_sort_option_views": "Numër parjesh", "search_filters_sort_option_views": "Numër parjesh",
"search_filters_type_label": "Lloj", "search_filters_type_label": "Lloj",
"search_filters_duration_label": "Kohëzgjatje", "search_filters_duration_label": "Kohëzgjatje",
"search_filters_features_label": "Veçori", "search_filters_features_label": "Veçori",
"search_filters_sort_label": "Renditi Sipas", "search_filters_sort_label": "Renditi Sipas",
"search_filters_date_option_hour": "Orën e fundit", "search_filters_date_option_hour": "Orën e Fundit",
"search_filters_date_option_today": "Sot", "search_filters_date_option_today": "Sot",
"search_filters_duration_option_long": "E gjatë (> 20 minuta)", "search_filters_duration_option_long": "E gjatë (> 20 minuta)",
"search_filters_features_option_hd": "HD", "search_filters_features_option_hd": "HD",
@@ -435,14 +435,14 @@
"tokens_count_plural": "{{count}} tokenë", "tokens_count_plural": "{{count}} tokenë",
"preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ", "preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ",
"Import Invidious data": "Importoni të dhëna JSON Invidious", "Import Invidious data": "Importoni të dhëna JSON Invidious",
"Import YouTube subscriptions": "Importoni pajtime YouTube CSV ose OPML", "Import YouTube subscriptions": "Importoni pajtime YouTube/OPML",
"Export data as JSON": "Eksportoji të dhënat Invidious si JSON", "Export data as JSON": "Eksportoji të dhënat Invidious si JSON",
"preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ", "preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ",
"Shared `x`": "Ndarë me të tjerë më `x`", "Shared `x`": "Ndarë me të tjerë më `x`",
"search_filters_title": "Filtra", "search_filters_title": "Filtra",
"Popular enabled: ": "Me populloret të aktivizuara: ", "Popular enabled: ": "Me populloret të aktivizuara: ",
"error_video_not_in_playlist": "Videoja e kërkuar sekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>", "error_video_not_in_playlist": "Videoja e kërkuar sekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>",
"search_message_use_another_instance": "Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.", "search_message_use_another_instance": " Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.",
"search_filters_date_label": "Datë ngarkimi", "search_filters_date_label": "Datë ngarkimi",
"preferences_watch_history_label": "Aktivizo historik parjesh: ", "preferences_watch_history_label": "Aktivizo historik parjesh: ",
"Top enabled: ": "Me kryesueset të aktivizuara: ", "Top enabled: ": "Me kryesueset të aktivizuara: ",
@@ -484,13 +484,5 @@
"Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)", "Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)",
"preferences_local_label": "Video përmes ndërmjetësi: ", "preferences_local_label": "Video përmes ndërmjetësi: ",
"Fallback captions: ": "Titra nga halli: ", "Fallback captions: ": "Titra nga halli: ",
"Erroneous challenge": "Zgjidhje e gabuar", "Erroneous challenge": "Zgjidhje e gabuar"
"Add to playlist: ": "Shtoje te luajlistë: ",
"Add to playlist": "Shtoje te luajlistë",
"Answer": "Përgjigje",
"Search for videos": "Kërko për video",
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
"carousel_skip": "Anashkaloje Rrotullamen",
"carousel_slide": "Diapozitiv {{current}} nga {{total}}",
"carousel_go_to": "Kalo te diapozitivi `x`"
} }

View File

@@ -404,7 +404,7 @@
"generic_count_months_0": "{{count}} mesec", "generic_count_months_0": "{{count}} mesec",
"generic_count_months_1": "{{count}} meseca", "generic_count_months_1": "{{count}} meseca",
"generic_count_months_2": "{{count}} meseci", "generic_count_months_2": "{{count}} meseci",
"search_message_use_another_instance": "Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.", "search_message_use_another_instance": " Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.",
"generic_subscribers_count_0": "{{count}} pratilac", "generic_subscribers_count_0": "{{count}} pratilac",
"generic_subscribers_count_1": "{{count}} pratioca", "generic_subscribers_count_1": "{{count}} pratioca",
"generic_subscribers_count_2": "{{count}} pratilaca", "generic_subscribers_count_2": "{{count}} pratilaca",

View File

@@ -404,7 +404,7 @@
"generic_count_months_0": "{{count}} месец", "generic_count_months_0": "{{count}} месец",
"generic_count_months_1": "{{count}} месеца", "generic_count_months_1": "{{count}} месеца",
"generic_count_months_2": "{{count}} месеци", "generic_count_months_2": "{{count}} месеци",
"search_message_use_another_instance": "Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.", "search_message_use_another_instance": " Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.",
"generic_subscribers_count_0": "{{count}} пратилац", "generic_subscribers_count_0": "{{count}} пратилац",
"generic_subscribers_count_1": "{{count}} пратиоца", "generic_subscribers_count_1": "{{count}} пратиоца",
"generic_subscribers_count_2": "{{count}} пратилаца", "generic_subscribers_count_2": "{{count}} пратилаца",

View File

@@ -320,13 +320,13 @@
"channel_tab_community_label": "Gemenskap", "channel_tab_community_label": "Gemenskap",
"search_filters_sort_option_relevance": "Relevans", "search_filters_sort_option_relevance": "Relevans",
"search_filters_sort_option_rating": "Rankning", "search_filters_sort_option_rating": "Rankning",
"search_filters_sort_option_date": "Uppladdnings datum", "search_filters_sort_option_date": "Uppladdnings Datum",
"search_filters_sort_option_views": "Visningar", "search_filters_sort_option_views": "Visningar",
"search_filters_type_label": "Typ", "search_filters_type_label": "Typ",
"search_filters_duration_label": "Varaktighet", "search_filters_duration_label": "Varaktighet",
"search_filters_features_label": "Funktioner", "search_filters_features_label": "Funktioner",
"search_filters_sort_label": "Sortera efter", "search_filters_sort_label": "Sortera efter",
"search_filters_date_option_hour": "Senaste timmen", "search_filters_date_option_hour": "Senaste Timmen",
"search_filters_date_option_today": "Idag", "search_filters_date_option_today": "Idag",
"search_filters_date_option_week": "Denna vecka", "search_filters_date_option_week": "Denna vecka",
"search_filters_date_option_month": "Denna månad", "search_filters_date_option_month": "Denna månad",
@@ -393,7 +393,7 @@
"Artist: ": "Artist: ", "Artist: ": "Artist: ",
"generic_count_months": "{{count}}månad", "generic_count_months": "{{count}}månad",
"generic_count_months_plural": "{{count}}månader", "generic_count_months_plural": "{{count}}månader",
"search_message_use_another_instance": "Du kan också <a href=\"`x`\">söka på en annan instans</a>.", "search_message_use_another_instance": " Du kan också <a href=\"`x`\">söka på en annan instans</a>.",
"generic_subscribers_count": "{{count}} prenumerant", "generic_subscribers_count": "{{count}} prenumerant",
"generic_subscribers_count_plural": "{{count}} prenumeranter", "generic_subscribers_count_plural": "{{count}} prenumeranter",
"download_subtitles": "Undertexter - `x` (.vtt)", "download_subtitles": "Undertexter - `x` (.vtt)",

View File

@@ -322,13 +322,13 @@
"channel_tab_community_label": "Topluluk", "channel_tab_community_label": "Topluluk",
"search_filters_sort_option_relevance": "İlgi", "search_filters_sort_option_relevance": "İlgi",
"search_filters_sort_option_rating": "Değerlendirme", "search_filters_sort_option_rating": "Değerlendirme",
"search_filters_sort_option_date": "Yükleme tarihi", "search_filters_sort_option_date": "Yükleme Tarihi",
"search_filters_sort_option_views": "Görüntüleme Sayısı", "search_filters_sort_option_views": "Görüntüleme Sayısı",
"search_filters_type_label": "Tür", "search_filters_type_label": "Tür",
"search_filters_duration_label": "Süre", "search_filters_duration_label": "Süre",
"search_filters_features_label": "Özellikler", "search_filters_features_label": "Özellikler",
"search_filters_sort_label": "Sıralama Ölçütü", "search_filters_sort_label": "Sıralama Ölçütü",
"search_filters_date_option_hour": "Son saat", "search_filters_date_option_hour": "Son Saat",
"search_filters_date_option_today": "Bugün", "search_filters_date_option_today": "Bugün",
"search_filters_date_option_week": "Bu Hafta", "search_filters_date_option_week": "Bu Hafta",
"search_filters_date_option_month": "Bu Ay", "search_filters_date_option_month": "Bu Ay",
@@ -452,7 +452,7 @@
"Spanish (Spain)": "İspanyolca (İspanya)", "Spanish (Spain)": "İspanyolca (İspanya)",
"Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)", "Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)",
"preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ", "preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ",
"search_message_use_another_instance": "Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.", "search_message_use_another_instance": " Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.",
"search_filters_type_option_all": "Herhangi Bir Tür", "search_filters_type_option_all": "Herhangi Bir Tür",
"search_filters_duration_option_none": "Herhangi Bir Süre", "search_filters_duration_option_none": "Herhangi Bir Süre",
"search_message_no_results": "Sonuç bulunamadı.", "search_message_no_results": "Sonuç bulunamadı.",

View File

@@ -455,7 +455,7 @@
"search_filters_date_option_week": "Цей тиждень", "search_filters_date_option_week": "Цей тиждень",
"search_filters_type_label": "Тип", "search_filters_type_label": "Тип",
"search_filters_type_option_channel": "Канал", "search_filters_type_option_channel": "Канал",
"search_message_use_another_instance": "Можете також <a href=\"`x`\">пошукати на іншому сервері</a>.", "search_message_use_another_instance": " Можете також <a href=\"`x`\">пошукати іншим сервером</a>.",
"search_filters_title": "Фільтри", "search_filters_title": "Фільтри",
"search_filters_date_option_hour": "Остання година", "search_filters_date_option_hour": "Остання година",
"search_filters_date_option_month": "Цей місяць", "search_filters_date_option_month": "Цей місяць",
@@ -472,7 +472,7 @@
"search_filters_features_option_three_sixty": "360°", "search_filters_features_option_three_sixty": "360°",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"search_filters_sort_label": "Спершу", "search_filters_sort_label": "Спершу",
"search_filters_sort_option_date": "Дата вивантаження", "search_filters_sort_option_date": "Нещодавні",
"search_filters_apply_button": "Застосувати фільтри", "search_filters_apply_button": "Застосувати фільтри",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_filters_features_option_purchased": "Придбано", "search_filters_features_option_purchased": "Придбано",

View File

@@ -436,7 +436,7 @@
"Turkish (auto-generated)": "土耳其语 (自动生成)", "Turkish (auto-generated)": "土耳其语 (自动生成)",
"Spanish (Spain)": "西班牙语 (西班牙)", "Spanish (Spain)": "西班牙语 (西班牙)",
"preferences_watch_history_label": "启用观看历史: ", "preferences_watch_history_label": "启用观看历史: ",
"search_message_use_another_instance": "你也可以 <a href=\"`x`\">在另一实例上搜索</a>。", "search_message_use_another_instance": " 你也可以 <a href=\"`x`\">在另一实例上搜索</a>。",
"search_filters_title": "过滤器", "search_filters_title": "过滤器",
"search_filters_date_label": "上传日期", "search_filters_date_label": "上传日期",
"search_filters_apply_button": "应用所选过滤器", "search_filters_apply_button": "应用所选过滤器",

View File

@@ -338,13 +338,13 @@
"channel_tab_community_label": "社群", "channel_tab_community_label": "社群",
"search_filters_sort_option_relevance": "關聯", "search_filters_sort_option_relevance": "關聯",
"search_filters_sort_option_rating": "評分", "search_filters_sort_option_rating": "評分",
"search_filters_sort_option_date": "上傳日期", "search_filters_sort_option_date": "日期",
"search_filters_sort_option_views": "檢視", "search_filters_sort_option_views": "檢視",
"search_filters_type_label": "內容類型", "search_filters_type_label": "內容類型",
"search_filters_duration_label": "時長", "search_filters_duration_label": "時長",
"search_filters_features_label": "特色", "search_filters_features_label": "特色",
"search_filters_sort_label": "排序", "search_filters_sort_label": "排序",
"search_filters_date_option_hour": "最後一小時", "search_filters_date_option_hour": "小時",
"search_filters_date_option_today": "今天", "search_filters_date_option_today": "今天",
"search_filters_date_option_week": "週", "search_filters_date_option_week": "週",
"search_filters_date_option_month": "月", "search_filters_date_option_month": "月",
@@ -442,7 +442,7 @@
"search_filters_duration_option_none": "任何時長", "search_filters_duration_option_none": "任何時長",
"search_filters_duration_option_medium": "中等4到20分鐘", "search_filters_duration_option_medium": "中等4到20分鐘",
"search_filters_features_option_vr180": "VR180", "search_filters_features_option_vr180": "VR180",
"search_message_use_another_instance": "您也可以<a href=\"`x`\">在其他站台上搜尋</a>。", "search_message_use_another_instance": " 您也可以<a href=\"`x`\">在其他站台上搜尋</a>。",
"search_filters_title": "過濾條件", "search_filters_title": "過濾條件",
"search_filters_date_label": "上傳日期", "search_filters_date_label": "上傳日期",
"search_filters_type_option_all": "任何類型", "search_filters_type_option_all": "任何類型",

2
mocks

Submodule mocks updated: b55d58dea9...11ec372f72

View File

@@ -10,20 +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
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
@@ -34,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
protodec: protodec:
git: https://github.com/iv-org/protodec.git git: https://github.com/iv-org/protodec.git
@@ -46,9 +42,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 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
@@ -27,9 +28,6 @@ dependencies:
athena-negotiation: athena-negotiation:
github: athena-framework/negotiation github: athena-framework/negotiation
version: ~> 0.1.1 version: ~> 0.1.1
http_proxy:
github: mamantoha/http_proxy
version: ~> 0.10.3
development_dependencies: development_dependencies:
spectator: spectator:
@@ -39,10 +37,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

@@ -27,8 +27,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32) expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32)
expect(video_11.views).to eq(40_504_893) expect(video_11.views).to eq(40_504_893)
expect(video_11.badges.live_now?).to be_false expect(video_11.live_now).to be_false
expect(video_11.badges.premium?).to be_false expect(video_11.premium).to be_false
expect(video_11.premiere_timestamp).to be_nil expect(video_11.premiere_timestamp).to be_nil
# #
@@ -49,8 +49,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32) expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32)
expect(video_35.views).to eq(30_790_049) expect(video_35.views).to eq(30_790_049)
expect(video_35.badges.live_now?).to be_false expect(video_35.live_now).to be_false
expect(video_35.badges.premium?).to be_false expect(video_35.premium).to be_false
expect(video_35.premiere_timestamp).to be_nil expect(video_35.premiere_timestamp).to be_nil
end end
@@ -80,8 +80,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32) expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32)
expect(video_41.views).to eq(63_240) expect(video_41.views).to eq(63_240)
expect(video_41.badges.live_now?).to be_false expect(video_41.live_now).to be_false
expect(video_41.badges.premium?).to be_false expect(video_41.premium).to be_false
expect(video_41.premiere_timestamp).to be_nil expect(video_41.premiere_timestamp).to be_nil
# #
@@ -102,8 +102,8 @@ Spectator.describe Invidious::Hashtag do
expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32) expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32)
expect(video_48.views).to eq(68_704) expect(video_48.views).to eq(68_704)
expect(video_48.badges.live_now?).to be_false expect(video_48.live_now).to be_false
expect(video_48.badges.premium?).to be_false expect(video_48.premium).to be_false
expect(video_48.premiere_timestamp).to be_nil expect(video_48.premiere_timestamp).to be_nil
end end
end end

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"
@@ -93,10 +92,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]"
@@ -108,23 +103,12 @@ Kemal.config.extra_options do |parser|
exit exit
end end
end end
parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{CONFIG.feed_threads})") do |number|
begin
CONFIG.feed_threads = number.to_i
rescue ex
puts "THREADS must be integer"
exit
end
end
parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{CONFIG.output})") do |output| parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: #{CONFIG.output})") do |output|
CONFIG.output = output CONFIG.output = output
end end
parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level| parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level|
CONFIG.log_level = LogLevel.parse(log_level) CONFIG.log_level = LogLevel.parse(log_level)
end end
parser.on("-k", "--colorize", "Colorize logs") do
CONFIG.colorize_logs = true
end
parser.on("-v", "--version", "Print version") do parser.on("-v", "--version", "Print version") do
puts SOFTWARE.to_pretty_json puts SOFTWARE.to_pretty_json
exit exit
@@ -141,7 +125,7 @@ if CONFIG.output.upcase != "STDOUT"
FileUtils.mkdir_p(File.dirname(CONFIG.output)) FileUtils.mkdir_p(File.dirname(CONFIG.output))
end end
OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a")
LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs) LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level)
# Check table integrity # Check table integrity
Invidious::Database.check_integrity(CONFIG) Invidious::Database.check_integrity(CONFIG)
@@ -176,10 +160,6 @@ if CONFIG.channel_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB)
end end
if CONFIG.feed_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB)
end
if CONFIG.statistics_enabled if CONFIG.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE)
end end
@@ -197,8 +177,6 @@ Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
Invidious::Jobs.start_all Invidious::Jobs.start_all
def popular_videos def popular_videos

View File

@@ -15,8 +15,7 @@ record AboutChannel,
allowed_regions : Array(String), allowed_regions : Array(String),
tabs : Array(String), tabs : Array(String),
tags : Array(String), tags : Array(String),
verified : Bool, verified : Bool
is_age_gated : Bool
def get_about_info(ucid, locale) : AboutChannel def get_about_info(ucid, locale) : AboutChannel
begin begin
@@ -46,102 +45,46 @@ def get_about_info(ucid, locale) : AboutChannel
end end
tags = [] of String tags = [] of String
tab_names = [] of String
total_views = 0_i64
joined = Time.unix(0)
if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") if auto_generated
description_node = nil author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s
author = age_gate_renderer["channelTitle"].as_s author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s
ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s
author_url = "https://www.youtube.com/channel/#{ucid}"
author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s # Raises a KeyError on failure.
banner = nil banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
is_family_friendly = false banner = banners.try &.[-1]?.try &.["url"].as_s?
is_age_gated = true
tab_names = ["videos", "shorts", "streams"] description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
auto_generated = false # some channels have the description in a simpleText
# ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/
description_node = description_base_node.dig?("simpleText") || description_base_node
tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges")
.try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String
else else
if auto_generated author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
# Raises a KeyError on failure. ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banner = banners.try &.[-1]?.try &.["url"].as_s?
description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] # Raises a KeyError on failure.
# some channels have the description in a simpleText banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
# ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
description_node = description_base_node.dig?("simpleText") || description_base_node banner = banners.try &.[-1]?.try &.["url"].as_s?
tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") # if banner.includes? "channels/c4/default_banner"
.try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String # banner = nil
else # end
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
# Raises a KeyError on failure.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
banner = banners.try &.[-1]?.try &.["url"].as_s?
# if banner.includes? "channels/c4/default_banner"
# banner = nil
# end
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String
end
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
# Get the name of the tabs available on this channel
tab_names = tabs_json.as_a.compact_map do |entry|
name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
# This is a small fix to not add extra code on the HTML side
# I.e, the URL for the "live" tab is .../streams, so use "streams"
# everywhere for the sake of simplicity
(name == "live") ? "streams" : name
end
# Get the currently active tab ("About")
about_tab = extract_selected_tab(tabs_json)
# Try to find the about metadata section
channel_about_meta = about_tab.dig?(
"content",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"channelAboutFullMetadataRenderer"
)
if !channel_about_meta.nil?
total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
joined = extract_text(channel_about_meta["joinedDateText"]?)
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Normal Auto-generated channels
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has
# ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
auto_generated = (
(channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
)
end
end
end end
is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
allowed_regions = initdata allowed_regions = initdata
.dig?("microformat", "microformatDataRenderer", "availableCountries") .dig?("microformat", "microformatDataRenderer", "availableCountries")
.try &.as_a.map(&.as_s) || [] of String .try &.as_a.map(&.as_s) || [] of String
@@ -159,6 +102,52 @@ def get_about_info(ucid, locale) : AboutChannel
end end
end end
total_views = 0_i64
joined = Time.unix(0)
tab_names = [] of String
if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?
# Get the name of the tabs available on this channel
tab_names = tabs_json.as_a.compact_map do |entry|
name = entry.dig?("tabRenderer", "title").try &.as_s.downcase
# This is a small fix to not add extra code on the HTML side
# I.e, the URL for the "live" tab is .../streams, so use "streams"
# everywhere for the sake of simplicity
(name == "live") ? "streams" : name
end
# Get the currently active tab ("About")
about_tab = extract_selected_tab(tabs_json)
# Try to find the about metadata section
channel_about_meta = about_tab.dig?(
"content",
"sectionListRenderer", "contents", 0,
"itemSectionRenderer", "contents", 0,
"channelAboutFullMetadataRenderer"
)
if !channel_about_meta.nil?
total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64
# The joined text is split to several sub strings. The reduce joins those strings before parsing the date.
joined = extract_text(channel_about_meta["joinedDateText"]?)
.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0)
# Normal Auto-generated channels
# https://support.google.com/youtube/answer/2579942
# For auto-generated channels, channel_about_meta only has
# ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"]
auto_generated = (
(channel_about_meta["primaryLinks"]?.try &.size) == 1 && \
extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" ||
channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube"
)
end
end
sub_count = 0 sub_count = 0
if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a) if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a)
@@ -188,7 +177,6 @@ def get_about_info(ucid, locale) : AboutChannel
tabs: tab_names, tabs: tab_names,
tags: tags, tags: tags,
verified: author_verified || false, verified: author_verified || false,
is_age_gated: is_age_gated || false,
) )
end end

View File

@@ -223,7 +223,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
length_seconds = channel_video.try &.length_seconds length_seconds = channel_video.try &.length_seconds
length_seconds ||= 0 length_seconds ||= 0
live_now = channel_video.try &.badges.live_now? live_now = channel_video.try &.live_now
live_now ||= false live_now ||= false
premiere_timestamp = channel_video.try &.premiere_timestamp premiere_timestamp = channel_video.try &.premiere_timestamp
@@ -275,7 +275,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
ucid: video.ucid, ucid: video.ucid,
author: video.author, author: video.author,
length_seconds: video.length_seconds, length_seconds: video.length_seconds,
live_now: video.badges.live_now?, live_now: video.live_now,
premiere_timestamp: video.premiere_timestamp, premiere_timestamp: video.premiere_timestamp,
views: video.views, views: video.views,
}) })

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

@@ -13,7 +13,6 @@ struct ConfigPreferences
property annotations : Bool = false property annotations : Bool = false
property annotations_subscribed : Bool = false property annotations_subscribed : Bool = false
property preload : Bool = true
property autoplay : Bool = false property autoplay : Bool = false
property captions : Array(String) = ["", "", ""] property captions : Array(String) = ["", "", ""]
property comments : Array(String) = ["youtube", ""] property comments : Array(String) = ["youtube", ""]
@@ -55,15 +54,6 @@ 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
@@ -72,14 +62,10 @@ class Config
# 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).
@[YAML::Field(converter: Preferences::TimeSpanConverter)] @[YAML::Field(converter: Preferences::TimeSpanConverter)]
property channel_refresh_interval : Time::Span = 30.minutes property channel_refresh_interval : Time::Span = 30.minutes
# Number of threads to use for updating feeds
property feed_threads : Int32 = 1
# Log file path or STDOUT # Log file path or STDOUT
property output : String = "STDOUT" property output : String = "STDOUT"
# Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
property log_level : LogLevel = LogLevel::Info property log_level : LogLevel = LogLevel::Info
# Enables colors in logs. Useful for debugging purposes
property colorize_logs : Bool = false
# Database configuration with separate parameters (username, hostname, etc) # Database configuration with separate parameters (username, hostname, etc)
property db : DBConfig? = nil property db : DBConfig? = nil
@@ -140,8 +126,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

View File

@@ -140,7 +140,6 @@ module Invidious::Database::Playlists
request = <<-SQL request = <<-SQL
SELECT id,title FROM playlists SELECT id,title FROM playlists
WHERE author = $1 AND id LIKE 'IV%' WHERE author = $1 AND id LIKE 'IV%'
ORDER BY title
SQL SQL
PG_DB.query_all(request, email, as: {String, String}) PG_DB.query_all(request, email, as: {String, String})

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

@@ -43,8 +43,6 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
# URLs for the error message below # URLs for the error message below
url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md"
url_search_issues = "https://github.com/iv-org/invidious/issues" url_search_issues = "https://github.com/iv-org/invidious/issues"
url_search_issues += "?q=is:issue+is:open+"
url_search_issues += URI.encode_www_form("[Bug] #{issue_title}")
url_switch = "https://redirect.invidious.io" + env.request.resource url_switch = "https://redirect.invidious.io" + env.request.resource

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

@@ -1,5 +1,3 @@
require "colorize"
enum LogLevel enum LogLevel
All = 0 All = 0
Trace = 1 Trace = 1
@@ -12,9 +10,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)
Colorize.enabled = use_color
Colorize.on_tty_only!
end end
def call(context : HTTP::Server::Context) def call(context : HTTP::Server::Context)
@@ -43,22 +39,10 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
@io.flush @io.flush
end end
def color(level)
case level
when LogLevel::Trace then :cyan
when LogLevel::Debug then :green
when LogLevel::Info then :white
when LogLevel::Warn then :yellow
when LogLevel::Error then :red
when LogLevel::Fatal then :magenta
else :default
end
end
{% 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}")
end end
end end
{% end %} {% end %}

View File

@@ -1,16 +1,3 @@
@[Flags]
enum VideoBadges
LiveNow
Premium
ThreeD
FourK
New
EightK
VR180
VR360
ClosedCaptions
end
struct SearchVideo struct SearchVideo
include DB::Serializable include DB::Serializable
@@ -22,9 +9,10 @@ struct SearchVideo
property views : Int64 property views : Int64
property description_html : String property description_html : String
property length_seconds : Int32 property length_seconds : Int32
property live_now : Bool
property premium : Bool
property premiere_timestamp : Time? property premiere_timestamp : Time?
property author_verified : Bool property author_verified : Bool
property badges : VideoBadges
def to_xml(auto_generated, query_params, xml : XML::Builder) def to_xml(auto_generated, query_params, xml : XML::Builder)
query_params["v"] = self.id query_params["v"] = self.id
@@ -100,20 +88,13 @@ struct SearchVideo
json.field "published", self.published.to_unix json.field "published", self.published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "lengthSeconds", self.length_seconds json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.badges.live_now? json.field "liveNow", self.live_now
json.field "premium", self.badges.premium? json.field "premium", self.premium
json.field "isUpcoming", self.upcoming? json.field "isUpcoming", self.is_upcoming
if self.premiere_timestamp if self.premiere_timestamp
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
end end
json.field "isNew", self.badges.new?
json.field "is4k", self.badges.four_k?
json.field "is8k", self.badges.eight_k?
json.field "isVr180", self.badges.vr180?
json.field "isVr360", self.badges.vr360?
json.field "is3d", self.badges.three_d?
json.field "hasCaptions", self.badges.closed_captions?
end end
end end
@@ -128,7 +109,7 @@ struct SearchVideo
to_json(nil, json) to_json(nil, json)
end end
def upcoming? def is_upcoming
premiere_timestamp ? true : false premiere_timestamp ? true : false
end end
end end

View File

@@ -175,9 +175,8 @@ module Invidious::SigHelper
@queue = {} of TransactionID => Transaction @queue = {} of TransactionID => Transaction
@conn : Connection @conn : Connection
@uri_or_path : String
def initialize(@uri_or_path) def initialize(uri_or_path)
@conn = Connection.new(uri_or_path) @conn = Connection.new(uri_or_path)
listen listen
end end
@@ -187,26 +186,10 @@ module Invidious::SigHelper
LOGGER.debug("SigHelper: Multiplexor listening") LOGGER.debug("SigHelper: Multiplexor listening")
# TODO: reopen socket if unexpectedly closed
spawn do spawn do
loop do loop do
begin receive_data
receive_data
rescue ex
LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...")
# We close the socket because for some reason is not closed.
@conn.close
loop do
begin
@conn = Connection.new(@uri_or_path)
LOGGER.info("SigHelper: Reconnected to SigHelper!")
rescue ex
LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying")
sleep 500.milliseconds
next
end
break if !@conn.closed?
end
end
Fiber.yield Fiber.yield
end end
end end

View File

@@ -10,8 +10,10 @@ class Invidious::DecryptFunction
end end
def check_update def check_update
now = Time.utc
# If we have updated in the last 5 minutes, do nothing # If we have updated in the last 5 minutes, do nothing
return if (Time.utc - @last_update) < 5.minutes return if (now - @last_update) > 5.minutes
# Get the amount of time elapsed since when the player was updated, in the # Get the amount of time elapsed since when the player was updated, in the
# event where multiple invidious processes are run in parallel. # event where multiple invidious processes are run in parallel.

View File

@@ -323,6 +323,68 @@ def parse_range(range)
return 0_i64, nil return 0_i64, nil
end end
def fetch_random_instance
begin
instance_api_client = make_client(URI.parse("https://api.invidious.io"))
# Timeouts
instance_api_client.connect_timeout = 10.seconds
instance_api_client.dns_timeout = 10.seconds
instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a
instance_api_client.close
rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException
instance_list = [] of JSON::Any
end
filtered_instance_list = [] of String
instance_list.each do |data|
# TODO Check if current URL is onion instance and use .onion types if so.
if data[1]["type"] == "https"
# Instances can have statistics disabled, which is an requirement of version validation.
# as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails.
begin
data[1]["stats"].as_nil
next
rescue TypeCastError
end
# stats endpoint could also lack the software dict.
next if data[1]["stats"]["software"]?.nil?
# Makes sure the instance isn't too outdated.
if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"]
remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/)
next if !remote_commit_date
remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC)
local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC)
next if (remote_commit_date - local_commit_date).abs.days > 30
begin
data[1]["monitor"].as_nil
health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"]
filtered_instance_list << data[0].as_s if health.to_s.to_f > 90
rescue TypeCastError
# We can't check the health if the monitoring is broken. Thus we'll just add it to the list
# and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that
# it's an error that often occurs with all the instances at the same time, we have to just skip the check.
filtered_instance_list << data[0].as_s
end
end
end
end
# If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io
if filtered_instance_list.size == 0
return "redirect.invidious.io"
end
return filtered_instance_list.sample(1)[0]
end
def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "") : String def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "") : String
str = uri.to_s.sub(/^https?:\/\//, "") str = uri.to_s.sub(/^https?:\/\//, "")
if str.size > max_length if str.size > max_length

View File

@@ -1,97 +0,0 @@
class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob
# We update the internals of a constant as so it can be accessed from anywhere
# within the codebase
#
# "INSTANCES" => Array(Tuple(String, String)) # region, instance
INSTANCES = {"INSTANCES" => [] of Tuple(String, String)}
def initialize
end
def begin
loop do
refresh_instances
LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes")
sleep 30.minute
Fiber.yield
end
end
# Refreshes the list of instances used for redirects.
#
# Does the following three checks for each instance
# - Is it a clear-net instance?
# - Is it an instance with a good uptime?
# - Is it an updated instance?
private def refresh_instances
raw_instance_list = self.fetch_instances
filtered_instance_list = [] of Tuple(String, String)
raw_instance_list.each do |instance_data|
# TODO allow Tor hidden service instances when the current instance
# is also a hidden service. Same for i2p and any other non-clearnet instances.
begin
domain = instance_data[0]
info = instance_data[1]
stats = info["stats"]
next unless info["type"] == "https"
next if bad_uptime?(info["monitor"])
next if outdated?(stats["software"]["version"])
filtered_instance_list << {info["region"].as_s, domain.as_s}
rescue ex
if domain
LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ")
else
LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ")
end
end
end
if !filtered_instance_list.empty?
INSTANCES["INSTANCES"] = filtered_instance_list
end
end
# Fetches information regarding instances from api.invidious.io or an otherwise configured URL
private def fetch_instances : Array(JSON::Any)
begin
# We directly call the stdlib HTTP::Client here as it allows us to negate the effects
# of the force_resolve config option. This is needed as api.invidious.io does not support ipv6
# and as such the following request raises if we were to use force_resolve with the ipv6 value.
instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io"))
# Timeouts
instance_api_client.connect_timeout = 10.seconds
instance_api_client.dns_timeout = 10.seconds
raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a
instance_api_client.close
rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException
raw_instance_list = [] of JSON::Any
end
return raw_instance_list
end
# Checks if the given target instance is outdated
private def outdated?(target_instance_version) : Bool
remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/)
return false if !remote_commit_date
remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC)
local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC)
return (remote_commit_date - local_commit_date).abs.days > 30
end
# Checks if the uptime of the target instance is greater than 90% over a 30 day period
private def bad_uptime?(target_instance_health_monitor) : Bool
return true if !target_instance_health_monitor["down"].as_bool == false
return true if target_instance_health_monitor["uptime"].as_f < 90
return false
end
end

View File

@@ -1,75 +0,0 @@
class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
private getter db : DB::Database
def initialize(@db)
end
def begin
max_fibers = CONFIG.feed_threads
active_fibers = 0
active_channel = ::Channel(Bool).new
loop do
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
rs.each do
email = rs.read(String)
view_name = "subscriptions_#{sha256(email)}"
if active_fibers >= max_fibers
if active_channel.receive
active_fibers -= 1
end
end
active_fibers += 1
spawn do
begin
# Drop outdated views
column_array = Invidious::Database.get_column_array(db, view_name)
ChannelVideo.type_array.each_with_index do |name, i|
if name != column_array[i]?
LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
raise "view does not exist"
end
end
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
LOGGER.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
end
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
rescue ex
# Rename old views
begin
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
LOGGER.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}")
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
rescue ex
begin
# While iterating through, we may have an email stored from a deleted account
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
LOGGER.info("RefreshFeedsJob: CREATE #{view_name}")
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
end
rescue ex
LOGGER.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}")
end
end
end
active_channel.send(true)
end
end
end
sleep 5.seconds
Fiber.yield
end
end
end

View File

@@ -63,7 +63,7 @@ module Invidious::JSONify::APIv1
json.field "isListed", video.is_listed json.field "isListed", video.is_listed
json.field "liveNow", video.live_now json.field "liveNow", video.live_now
json.field "isPostLiveDvr", video.post_live_dvr json.field "isPostLiveDvr", video.post_live_dvr
json.field "isUpcoming", video.upcoming? json.field "isUpcoming", video.is_upcoming
if video.premiere_timestamp if video.premiere_timestamp
json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix
@@ -109,7 +109,7 @@ module Invidious::JSONify::APIv1
# On livestreams, it's not present, so always fall back to the # On livestreams, it's not present, so always fall back to the
# current unix timestamp (up to mS precision) for compatibility. # current unix timestamp (up to mS precision) for compatibility.
last_modified = fmt["lastModified"]? last_modified = fmt["lastModified"]?
last_modified ||= "#{Time.utc.to_unix_ms}000" last_modified ||= "#{Time.utc.to_unix_ms.to_s}000"
json.field "lmt", last_modified json.field "lmt", last_modified
json.field "projectionType", fmt["projectionType"] json.field "projectionType", fmt["projectionType"]
@@ -162,13 +162,7 @@ module Invidious::JSONify::APIv1
json.array do json.array do
video.fmt_stream.each do |fmt| video.fmt_stream.each do |fmt|
json.object do json.object do
if proxy json.field "url", fmt["url"]
json.field "url", Invidious::HttpServer::Utils.proxy_video_url(
fmt["url"].to_s, absolute: true
)
else
json.field "url", fmt["url"]
end
json.field "itag", fmt["itag"].as_i.to_s json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"] json.field "type", fmt["mimeType"]
json.field "quality", fmt["quality"] json.field "quality", fmt["quality"]
@@ -277,17 +271,17 @@ module Invidious::JSONify::APIv1
def storyboards(json, id, storyboards) def storyboards(json, id, storyboards)
json.array do json.array do
storyboards.each do |sb| storyboards.each do |storyboard|
json.object do json.object do
json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}" json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
json.field "templateUrl", sb.url.to_s json.field "templateUrl", storyboard[:url]
json.field "width", sb.width json.field "width", storyboard[:width]
json.field "height", sb.height json.field "height", storyboard[:height]
json.field "count", sb.count json.field "count", storyboard[:count]
json.field "interval", sb.interval json.field "interval", storyboard[:interval]
json.field "storyboardWidth", sb.columns json.field "storyboardWidth", storyboard[:storyboard_width]
json.field "storyboardHeight", sb.rows json.field "storyboardHeight", storyboard[:storyboard_height]
json.field "storyboardCount", sb.images_count json.field "storyboardCount", storyboard[:storyboard_count]
end end
end end
end end

View File

@@ -46,14 +46,8 @@ struct PlaylistVideo
XML.build { |xml| to_xml(xml) } XML.build { |xml| to_xml(xml) }
end end
def to_json(locale : String?, json : JSON::Builder)
to_json(json)
end
def to_json(json : JSON::Builder, index : Int32? = nil) def to_json(json : JSON::Builder, index : Int32? = nil)
json.object do json.object do
json.field "type", "video"
json.field "title", self.title json.field "title", self.title
json.field "videoId", self.id json.field "videoId", self.id
@@ -73,7 +67,6 @@ struct PlaylistVideo
end end
json.field "lengthSeconds", self.length_seconds json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.live_now
end end
end end
@@ -270,7 +263,7 @@ end
def subscribe_playlist(user, playlist) def subscribe_playlist(user, playlist)
playlist = InvidiousPlaylist.new({ playlist = InvidiousPlaylist.new({
title: playlist.title[..150], title: playlist.title.byte_slice(0, 150),
id: playlist.id, id: playlist.id,
author: user.email, author: user.email,
description: "", # Max 5000 characters description: "", # Max 5000 characters

View File

@@ -123,10 +123,8 @@ module Invidious::Routes::Account
return error_template(400, ex) return error_template(400, ex)
end end
view_name = "subscriptions_#{sha256(user.email)}"
Invidious::Database::Users.delete(user) Invidious::Database::Users.delete(user)
Invidious::Database::SessionIDs.delete(email: user.email) Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie| env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1) cookie.expires = Time.utc(1990, 1, 1)

View File

@@ -27,21 +27,10 @@ module Invidious::Routes::API::V1::Channels
# Retrieve "sort by" setting from URL parameters # Retrieve "sort by" setting from URL parameters
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
if channel.is_age_gated begin
begin videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
playlist = get_playlist(channel.ucid.sub("UC", "UULF")) rescue ex
videos = get_playlist_videos(playlist, offset: 0) return error_json(500, ex)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
return error_json(500, ex)
end
end end
JSON.build do |json| JSON.build do |json|
@@ -95,7 +84,6 @@ module Invidious::Routes::API::V1::Channels
json.field "joined", channel.joined.to_unix json.field "joined", channel.joined.to_unix
json.field "autoGenerated", channel.auto_generated json.field "autoGenerated", channel.auto_generated
json.field "ageGated", channel.is_age_gated
json.field "isFamilyFriendly", channel.is_family_friendly json.field "isFamilyFriendly", channel.is_family_friendly
json.field "description", html_to_content(channel.description_html) json.field "description", html_to_content(channel.description_html)
json.field "descriptionHtml", channel.description_html json.field "descriptionHtml", channel.description_html
@@ -154,23 +142,12 @@ module Invidious::Routes::API::V1::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" 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 begin
begin videos, next_continuation = Channel::Tabs.get_60_videos(
playlist = get_playlist(channel.ucid.sub("UC", "UULF")) channel, continuation: continuation, sort_by: sort_by
videos = get_playlist_videos(playlist, offset: 0) )
rescue ex : InfoException rescue ex
# playlist doesnt exist. return error_json(500, ex)
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_60_videos(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
end
end end
return JSON.build do |json| return JSON.build do |json|
@@ -197,26 +174,14 @@ 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 begin
begin videos, next_continuation = Channel::Tabs.get_shorts(
playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) channel, continuation: continuation
videos = get_playlist_videos(playlist, offset: 0) )
rescue ex : InfoException rescue ex
# playlist doesnt exist. return error_json(500, ex)
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_shorts(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
end
end end
return JSON.build do |json| return JSON.build do |json|
@@ -246,23 +211,12 @@ module Invidious::Routes::API::V1::Channels
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" 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 begin
begin videos, next_continuation = Channel::Tabs.get_60_livestreams(
playlist = get_playlist(channel.ucid.sub("UC", "UULV")) channel, continuation: continuation, sort_by: sort_by
videos = get_playlist_videos(playlist, offset: 0) )
rescue ex : InfoException rescue ex
# playlist doesnt exist. return error_json(500, ex)
videos = [] of PlaylistVideo
end
next_continuation = nil
else
begin
videos, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
end
end end
return JSON.build do |json| return JSON.build do |json|

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

@@ -1,5 +1,3 @@
require "html"
module Invidious::Routes::API::V1::Videos module Invidious::Routes::API::V1::Videos
def self.videos(env) def self.videos(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
@@ -118,7 +116,7 @@ module Invidious::Routes::API::V1::Videos
else else
caption_xml = XML.parse(caption_xml) caption_xml = XML.parse(caption_xml)
webvtt = WebVTT.build(settings_field) do |builder| webvtt = WebVTT.build(settings_field) do |webvtt|
caption_nodes = caption_xml.xpath_nodes("//transcript/text") caption_nodes = caption_xml.xpath_nodes("//transcript/text")
caption_nodes.each_with_index do |node, i| caption_nodes.each_with_index do |node, i|
start_time = node["start"].to_f.seconds start_time = node["start"].to_f.seconds
@@ -138,7 +136,7 @@ module Invidious::Routes::API::V1::Videos
text = "<v #{md["name"]}>#{md["text"]}</v>" text = "<v #{md["name"]}>#{md["text"]}</v>"
end end
builder.cue(start_time, end_time, text) webvtt.cue(start_time, end_time, text)
end end
end end
end end
@@ -189,14 +187,15 @@ module Invidious::Routes::API::V1::Videos
haltf env, 500 haltf env, 500
end end
width = env.params.query["width"]?.try &.to_i storyboards = video.storyboards
height = env.params.query["height"]?.try &.to_i width = env.params.query["width"]?
height = env.params.query["height"]?
if !width && !height if !width && !height
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
json.field "storyboards" do json.field "storyboards" do
Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards) Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
end end
end end
end end
@@ -206,48 +205,35 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "text/vtt" env.response.content_type = "text/vtt"
# Select a storyboard matching the user's provided width/height storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" }
storyboard = video.storyboards.select { |x| x.width == width || x.height == height }
haltf env, 404 if storyboard.empty?
# Alias variable, to make the code below esaier to read if storyboard.empty?
sb = storyboard[0] haltf env, 404
else
storyboard = storyboard[0]
end
# Some base URL segments that we'll use to craft the final URLs WebVTT.build do |vtt|
work_url = sb.proxied_url.dup start_time = 0.milliseconds
template_path = sb.proxied_url.path end_time = storyboard[:interval].milliseconds
# Initialize cue timing variables storyboard[:storyboard_count].times do |i|
# NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap url = storyboard[:url]
# (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000) authority = /(i\d?).ytimg.com/.match!(url)[1]?
time_delta = sb.interval.milliseconds url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
start_time = 0.milliseconds url = "#{HOST_URL}/sb/#{authority}/#{url}"
end_time = time_delta
# Build a VTT file for VideoJS-vtt plugin storyboard[:storyboard_height].times do |j|
vtt_file = WebVTT.build do |vtt| storyboard[:storyboard_width].times do |k|
sb.images_count.times do |i| current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}"
# Replace the variable component part of the path vtt.cue(start_time, end_time, current_cue_url)
work_url.path = template_path.sub("$M", i)
sb.rows.times do |j| start_time += storyboard[:interval].milliseconds
sb.columns.times do |k| end_time += storyboard[:interval].milliseconds
# The URL fragment represents the offset of the thumbnail inside the storyboard image
work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}"
vtt.cue(start_time, end_time, work_url.to_s)
start_time += time_delta
end_time += time_delta
end end
end end
end end
end end
# videojs-vtt-thumbnails is not compliant to the VTT specification, it
# doesn't unescape the HTML entities, so we have to do it here:
# TODO: remove this when we migrate to VideoJS 8
return HTML.unescape(vtt_file)
end end
def self.annotations(env) def self.annotations(env)

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|
@@ -37,26 +36,12 @@ module Invidious::Routes::Channels
items = items.select(SearchPlaylist) items = items.select(SearchPlaylist)
items.each(&.author = "") items.each(&.author = "")
else else
# Fetch items and continuation token sort_options = {"newest", "oldest", "popular"}
if channel.is_age_gated
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
sort_by ||= "newest"
sort_options = {"newest", "oldest", "popular"}
items, next_continuation = Channel::Tabs.get_60_videos( # Fetch items and continuation token
channel, continuation: continuation, sort_by: sort_by items, next_continuation = Channel::Tabs.get_videos(
) channel, continuation: continuation, sort_by: (sort_by || "newest")
end )
end end
selected_tab = Frontend::ChannelPage::TabsAvailable::Videos selected_tab = Frontend::ChannelPage::TabsAvailable::Videos
@@ -73,26 +58,14 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
if channel.is_age_gated # TODO: support sort option for shorts
sort_by = "" sort_by = ""
sort_options = [] of String sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
sort_options = {"newest", "oldest", "popular"}
# 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
selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts
templated "channel" templated "channel"
@@ -108,26 +81,13 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
if channel.is_age_gated sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
sort_by = "" sort_options = {"newest", "oldest", "popular"}
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
items = get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
end
next_continuation = nil
else
sort_by = env.params.query["sort_by"]?.try &.downcase || "newest"
sort_options = {"newest", "oldest", "popular"}
# Fetch items and continuation token # Fetch items and continuation token
items, next_continuation = Channel::Tabs.get_60_livestreams( items, next_continuation = Channel::Tabs.get_60_livestreams(
channel, continuation: continuation, sort_by: sort_by channel, continuation: continuation, sort_by: sort_by
) )
end
selected_tab = Frontend::ChannelPage::TabsAvailable::Streams selected_tab = Frontend::ChannelPage::TabsAvailable::Streams
templated "channel" templated "channel"

View File

@@ -192,9 +192,11 @@ module Invidious::Routes::Feeds
views: views, views: views,
description_html: description_html, description_html: description_html,
length_seconds: 0, length_seconds: 0,
live_now: false,
paid: false,
premium: false,
premiere_timestamp: nil, premiere_timestamp: nil,
author_verified: false, author_verified: false,
badges: VideoBadges::None,
}) })
end end

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

@@ -160,9 +160,6 @@ module Invidious::Routes::Login
Invidious::Database::Users.insert(user) Invidious::Database::Users.insert(user)
Invidious::Database::SessionIDs.insert(sid, email) Invidious::Database::SessionIDs.insert(sid, email)
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
if env.request.cookies["PREFS"]? if env.request.cookies["PREFS"]?

View File

@@ -40,16 +40,7 @@ module Invidious::Routes::Misc
def self.cross_instance_redirect(env) def self.cross_instance_redirect(env)
referer = get_referer(env) referer = get_referer(env)
instance_url = fetch_random_instance
instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"]
if instance_list.empty?
instance_url = "redirect.invidious.io"
else
# Sample returns an array
# Instances are packaged as {region, domain} in the instance list
instance_url = instance_list.sample(1)[0][1]
end
env.redirect "https://#{instance_url}#{referer}" env.redirect "https://#{instance_url}#{referer}"
end end
end end

View File

@@ -27,10 +27,6 @@ module Invidious::Routes::PreferencesRoute
annotations_subscribed ||= "off" annotations_subscribed ||= "off"
annotations_subscribed = annotations_subscribed == "on" annotations_subscribed = annotations_subscribed == "on"
preload = env.params.body["preload"]?.try &.as(String)
preload ||= "off"
preload = preload == "on"
autoplay = env.params.body["autoplay"]?.try &.as(String) autoplay = env.params.body["autoplay"]?.try &.as(String)
autoplay ||= "off" autoplay ||= "off"
autoplay = autoplay == "on" autoplay = autoplay == "on"
@@ -148,7 +144,6 @@ module Invidious::Routes::PreferencesRoute
preferences = Preferences.from_json({ preferences = Preferences.from_json({
annotations: annotations, annotations: annotations,
annotations_subscribed: annotations_subscribed, annotations_subscribed: annotations_subscribed,
preload: preload,
autoplay: autoplay, autoplay: autoplay,
captions: captions, captions: captions,
comments: comments, comments: comments,

View File

@@ -51,12 +51,6 @@ module Invidious::Routes::Search
else else
user = env.get? "user" user = env.get? "user"
# An URL was copy/pasted in the search box.
# Redirect the user to the appropriate page.
if query.url?
return env.redirect UrlSanitizer.process(query.text).to_s
end
begin begin
items = query.process items = query.process
rescue ex : ChannelSearchException rescue ex : ChannelSearchException

View File

@@ -42,7 +42,7 @@ 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) 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
@@ -57,7 +57,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}" : ""}"
@@ -71,7 +71,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
@@ -131,7 +131,7 @@ module Invidious::Routes::VideoPlayback
end end
# TODO: Record bytes written so we can restart after a chunk fails # TODO: Record bytes written so we can restart after a chunk fails
loop do while true
if !range_end && content_length if !range_end && content_length
range_end = content_length range_end = content_length
end end
@@ -196,7 +196,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

View File

@@ -243,16 +243,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 +271,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

@@ -37,18 +37,18 @@ module Invidious::Search
# Search inside of user subscriptions # Search inside of user subscriptions
def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo) def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo)
view_name = "subscriptions_#{sha256(user.email)}"
return PG_DB.query_all(" return PG_DB.query_all("
SELECT id,title,published,updated,ucid,author,length_seconds SELECT id,title,published,updated,ucid,author,length_seconds
FROM ( FROM (
SELECT *, SELECT cv.*,
to_tsvector(#{view_name}.title) || to_tsvector(cv.title) ||
to_tsvector(#{view_name}.author) to_tsvector(cv.author) AS document
as document FROM channel_videos cv
FROM #{view_name} JOIN users ON cv.ucid = any(users.subscriptions)
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", WHERE users.email = $1 AND published > now() - interval '1 month'
query.text, (query.page - 1) * 20, ORDER BY published
) v_search WHERE v_search.document @@ plainto_tsquery($2) LIMIT 20 OFFSET $3;",
user.email, query.text, (query.page - 1) * 20,
as: ChannelVideo as: ChannelVideo
) )
end end

View File

@@ -20,9 +20,6 @@ module Invidious::Search
property region : String? property region : String?
property channel : String = "" property channel : String = ""
# Flag that indicates if the smart search features have been disabled.
@inhibit_ssf : Bool = false
# Return true if @raw_query is either `nil` or empty # Return true if @raw_query is either `nil` or empty
private def empty_raw_query? private def empty_raw_query?
return @raw_query.empty? return @raw_query.empty?
@@ -51,18 +48,10 @@ module Invidious::Search
) )
# Get the raw search query string (common to all search types). In # Get the raw search query string (common to all search types). In
# Regular search mode, also look for the `search_query` URL parameter # Regular search mode, also look for the `search_query` URL parameter
_raw_query = params["q"]? if @type.regular?
_raw_query ||= params["search_query"]? if @type.regular? @raw_query = params["q"]? || params["search_query"]? || ""
_raw_query ||= "" else
@raw_query = params["q"]? || ""
# Remove surrounding whitespaces. Mostly useful for copy/pasted URLs.
@raw_query = _raw_query.strip
# Check for smart features (ex: URL search) inhibitor (backslash).
# If inhibitor is present, remove it.
if @raw_query.starts_with?('\\')
@inhibit_ssf = true
@raw_query = @raw_query[1..]
end end
# Get the page number (also common to all search types) # Get the page number (also common to all search types)
@@ -96,7 +85,7 @@ module Invidious::Search
@filters = Filters.from_iv_params(params) @filters = Filters.from_iv_params(params)
@channel = params["channel"]? || "" @channel = params["channel"]? || ""
if @filters.default? && @raw_query.index(/\w:\w/) if @filters.default? && @raw_query.includes?(':')
# Parse legacy filters from query # Parse legacy filters from query
@filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query) @filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query)
else else
@@ -147,22 +136,5 @@ module Invidious::Search
return params return params
end end
# Checks if the query is a standalone URL
def url? : Bool
# If the smart features have been inhibited, don't go further.
return false if @inhibit_ssf
# Only supported in regular search mode
return false if !@type.regular?
# If filters are present, that's a regular search
return false if !@filters.default?
# Simple heuristics: domain name
return @raw_query.starts_with?(
/(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\//
)
end
end end
end end

View File

@@ -115,7 +115,7 @@ struct Invidious::User
playlists.each do |item| playlists.each do |item|
title = item["title"]?.try &.as_s?.try &.delete("<>") title = item["title"]?.try &.as_s?.try &.delete("<>")
description = item["description"]?.try &.as_s?.try &.delete("\r") description = item["description"]?.try &.as_s?.try &.delete("\r")
privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state } privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
next if !title next if !title
next if !description next if !description
@@ -161,7 +161,7 @@ struct Invidious::User
# Youtube # Youtube
# ------------------- # -------------------
private def opml?(mimetype : String, extension : String) private def is_opml?(mimetype : String, extension : String)
opml_mimetypes = [ opml_mimetypes = [
"application/xml", "application/xml",
"text/xml", "text/xml",
@@ -179,7 +179,7 @@ struct Invidious::User
def from_youtube(user : User, body : String, filename : String, type : String) : Bool def from_youtube(user : User, body : String, filename : String, type : String) : Bool
extension = filename.split(".").last extension = filename.split(".").last
if opml?(type, extension) if is_opml?(type, extension)
subscriptions = XML.parse(body) subscriptions = XML.parse(body)
user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0] channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0]

View File

@@ -4,7 +4,6 @@ struct Preferences
property annotations : Bool = CONFIG.default_user_preferences.annotations property annotations : Bool = CONFIG.default_user_preferences.annotations
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
property preload : Bool = CONFIG.default_user_preferences.preload
property autoplay : Bool = CONFIG.default_user_preferences.autoplay property autoplay : Bool = CONFIG.default_user_preferences.autoplay
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect

View File

@@ -27,7 +27,6 @@ def get_subscription_feed(user, max_results = 40, page = 1)
offset = (page - 1) * limit offset = (page - 1) * limit
notifications = Invidious::Database::Users.select_notifications(user) notifications = Invidious::Database::Users.select_notifications(user)
view_name = "subscriptions_#{sha256(user.email)}"
if user.preferences.notifications_only && !notifications.empty? if user.preferences.notifications_only && !notifications.empty?
# Only show notifications # Only show notifications
@@ -53,33 +52,39 @@ def get_subscription_feed(user, max_results = 40, page = 1)
# Show latest video from a channel that a user hasn't watched # Show latest video from a channel that a user hasn't watched
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only" # "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
if user.watched.empty? # "SELECT cv.* FROM channel_videos cv JOIN users ON cv.ucid = any(users.subscriptions) WHERE users.email = $1 AND published > now() - interval '1 month' ORDER BY published DESC"
values = "'{}'" # "SELECT DISTINCT ON (cv.ucid) cv.* FROM channel_videos cv JOIN users ON cv.ucid = any(users.subscriptions) WHERE users.email = ? AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' ORDER BY ucid, published DESC"
else videos = PG_DB.query_all("SELECT DISTINCT ON (cv.ucid) cv.* " \
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}" "FROM channel_videos cv " \
end "JOIN users ON cv.ucid = any(users.subscriptions) " \
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo) "WHERE users.email = $1 AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' " \
"ORDER BY ucid, published DESC", user.email, as: ChannelVideo)
else else
# Show latest video from each channel # Show latest video from each channel
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo) videos = PG_DB.query_all("SELECT DISTINCT ON (cv.ucid) cv.* " \
"FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND published > now() - interval '1 month' " \
"ORDER BY ucid, published DESC", user.email, as: ChannelVideo)
end end
videos.sort_by!(&.published).reverse! videos.sort_by!(&.published).reverse!
else else
if user.preferences.unseen_only if user.preferences.unseen_only
# Only show unwatched # Only show unwatched
videos = PG_DB.query_all("SELECT cv.* " \
if user.watched.empty? "FROM channel_videos cv " \
values = "'{}'" "JOIN users ON cv.ucid = any(users.subscriptions) " \
else "WHERE users.email = $1 AND NOT cv.id = any(users.watched) AND published > now() - interval '1 month' " \
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}" "ORDER BY published DESC LIMIT $2 OFFSET $3", user.email, limit, offset, as: ChannelVideo)
end
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
else else
# Sort subscriptions as normal # Sort subscriptions as normal
videos = PG_DB.query_all("SELECT cv.* " \
videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo) "FROM channel_videos cv " \
"JOIN users ON cv.ucid = any(users.subscriptions) " \
"WHERE users.email = $1 AND published > now() - interval '1 month' " \
"ORDER BY published DESC LIMIT $2 OFFSET $3", user.email, limit, offset, as: ChannelVideo)
end end
end end

View File

@@ -26,6 +26,12 @@ struct Video
@[DB::Field(ignore: true)] @[DB::Field(ignore: true)]
@captions = [] of Invidious::Videos::Captions::Metadata @captions = [] of Invidious::Videos::Captions::Metadata
@[DB::Field(ignore: true)]
property adaptive_fmts : Array(Hash(String, JSON::Any))?
@[DB::Field(ignore: true)]
property fmt_stream : Array(Hash(String, JSON::Any))?
@[DB::Field(ignore: true)] @[DB::Field(ignore: true)]
property description : String? property description : String?
@@ -92,24 +98,72 @@ struct Video
# Methods for parsing streaming data # Methods for parsing streaming data
def fmt_stream : Array(Hash(String, JSON::Any)) def convert_url(fmt)
if formats = info.dig?("streamingData", "formats") if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
return formats sp = cfr["sp"]
.as_a.map(&.as_h) url = URI.parse(cfr["url"])
.sort_by! { |f| f["width"]?.try &.as_i || 0 } params = url.query_params
LOGGER.debug("Videos: Decoding '#{cfr}'")
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
params[sp] = unsig if unsig
else else
return [] of Hash(String, JSON::Any) url = URI.parse(fmt["url"].as_s)
params = url.query_params
end end
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n
if token = CONFIG.po_token
params["pot"] = token
end
params["host"] = url.host.not_nil!
if region = self.info["region"]?.try &.as_s
params["region"] = region
end
url.query_params = params
LOGGER.trace("Videos: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("Videos: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end end
def adaptive_fmts : Array(Hash(String, JSON::Any)) def fmt_stream
if formats = info.dig?("streamingData", "adaptiveFormats") return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
return formats
.as_a.map(&.as_h) fmt_stream = info.dig?("streamingData", "formats")
.sort_by! { |f| f["width"]?.try &.as_i || 0 } .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
else
return [] of Hash(String, JSON::Any) fmt_stream.each do |fmt|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
end end
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
@fmt_stream = fmt_stream
return @fmt_stream.as(Array(Hash(String, JSON::Any)))
end
def adaptive_fmts
return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts
fmt_stream = info.dig("streamingData", "adaptiveFormats")
.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
fmt_stream.each do |fmt|
fmt["url"] = JSON::Any.new(self.convert_url(fmt))
end
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
@adaptive_fmts = fmt_stream
return @adaptive_fmts.as(Array(Hash(String, JSON::Any)))
end end
def video_streams def video_streams
@@ -123,8 +177,65 @@ struct Video
# Misc. methods # Misc. methods
def storyboards def storyboards
container = info.dig?("storyboards") || JSON::Any.new("{}") storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds) .try &.as_s.split("|")
if !storyboards
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [{
url: storyboard.split("#")[0],
width: 106,
height: 60,
count: -1,
interval: 5000,
storyboard_width: 3,
storyboard_height: 3,
storyboard_count: -1,
}]
end
end
items = [] of NamedTuple(
url: String,
width: Int32,
height: Int32,
count: Int32,
interval: Int32,
storyboard_width: Int32,
storyboard_height: Int32,
storyboard_count: Int32)
return items if !storyboards
url = URI.parse(storyboards.shift)
params = HTTP::Params.parse(url.query || "")
storyboards.each_with_index do |sb, i|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
params["sigh"] = sigh
url.query = params.to_s
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
storyboard_width = storyboard_width.to_i
storyboard_height = storyboard_height.to_i
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
items << {
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
width: width,
height: height,
count: count,
interval: interval,
storyboard_width: storyboard_width,
storyboard_height: storyboard_height,
storyboard_count: storyboard_count,
}
end
items
end end
def paid def paid
@@ -169,7 +280,7 @@ struct Video
info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil
end end
def vr? : Bool? def is_vr : Bool?
return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type
end end
@@ -250,21 +361,6 @@ struct Video
{% if flag?(:debug_macros) %} {{debug}} {% end %} {% if flag?(:debug_macros) %} {{debug}} {% end %}
end end
# Macro to generate ? and = accessor methods for attributes in `info`
private macro predicate_bool(method_name, name)
# Return {{name.stringify}} from `info`
def {{method_name.id.underscore}}? : Bool
return info[{{name.stringify}}]?.try &.as_bool || false
end
# Update {{name.stringify}} into `info`
def {{method_name.id.underscore}}=(value : Bool)
info[{{name.stringify}}] = JSON::Any.new(value)
end
{% if flag?(:debug_macros) %} {{debug}} {% end %}
end
# Method definitions, using the macros above # Method definitions, using the macros above
getset_string author getset_string author
@@ -286,12 +382,11 @@ struct Video
getset_i64 likes getset_i64 likes
getset_i64 views getset_i64 views
# TODO: Make predicate_bool the default as to adhere to Crystal conventions
getset_bool allowRatings getset_bool allowRatings
getset_bool authorVerified getset_bool authorVerified
getset_bool isFamilyFriendly getset_bool isFamilyFriendly
getset_bool isListed getset_bool isListed
predicate_bool upcoming, isUpcoming getset_bool 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)

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

@@ -36,13 +36,7 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty? return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a commands = desc["commandRuns"]?.try &.as_a
if commands.nil? return content if commands.nil?
# Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library
return String.build do |str|
copy_string(str, content.each_codepoint, content.size)
end
end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are

View File

@@ -102,8 +102,8 @@ def extract_video_info(video_id : String)
new_player_response = nil new_player_response = nil
# Don't use Android test suite client if po_token is passed because po_token doesn't # Don't use Android client if po_token is passed because po_token doesn't
# work for Android test suite client. # work for Android client.
if reason.nil? && CONFIG.po_token.nil? if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the # Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the # decrypted URLs and maybe fix throttling issues (#2194). See the
@@ -113,6 +113,15 @@ def extract_video_info(video_id : String)
new_player_response = try_fetch_streaming_data(video_id, client_config) new_player_response = try_fetch_streaming_data(video_id, client_config)
end end
# Last hope
# Only trigger if reason found and po_token or didn't work wth Android client.
# TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required
# if the IP address is not blocked.
if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil?
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Replace player response and reset reason # Replace player response and reset reason
if !new_player_response.nil? if !new_player_response.nil?
# Preserve captions & storyboard data before replacement # Preserve captions & storyboard data before replacement
@@ -123,21 +132,10 @@ def extract_video_info(video_id : String)
params.delete("reason") params.delete("reason")
end end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f|
params[f] = player_response[f] if player_response[f]? params[f] = player_response[f] if player_response[f]?
end end
# Convert URLs, if those are present
if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format|
format.as_h["url"] = JSON::Any.new(convert_url(format))
end
end
params["streamingData"] = streaming_data
end
# Data structure version, for cache control # Data structure version, for cache control
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
@@ -187,11 +185,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
end end
video_details = player_response.dig?("videoDetails") video_details = player_response.dig?("videoDetails")
if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) microformat = player_response.dig?("microformat", "playerMicroformatRenderer")
microformat = {} of String => JSON::Any
end
raise BrokenTubeException.new("videoDetails") if !video_details raise BrokenTubeException.new("videoDetails") if !video_details
raise BrokenTubeException.new("microformat") if !microformat
# Basic video infos # Basic video infos
@@ -216,17 +213,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) } .try { |t| Time.parse_rfc3339(t.as_s) }
premiere_timestamp ||= player_response.dig?(
"playabilityStatus", "liveStreamability",
"liveStreamabilityRenderer", "offlineSlate",
"liveStreamOfflineSlateRenderer", "scheduledStartTime"
)
.try &.as_s.to_i64
.try { |t| Time.unix(t) }
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool .try &.as_bool || false
live_now ||= video_details.dig?("isLive").try &.as_bool || false
post_live_dvr = video_details.dig?("isPostLiveDvr") post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false .try &.as_bool || false
@@ -237,7 +225,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
.try &.as_a.map &.as_s || [] of String .try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool allow_ratings = video_details["allowRatings"]?.try &.as_bool
family_friendly = microformat["isFamilySafe"]?.try &.as_bool family_friendly = microformat["isFamilySafe"].try &.as_bool
is_listed = video_details["isCrawlable"]?.try &.as_bool is_listed = video_details["isCrawlable"]?.try &.as_bool
is_upcoming = video_details["isUpcoming"]?.try &.as_bool is_upcoming = video_details["isUpcoming"]?.try &.as_bool
@@ -455,35 +443,3 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
return params return params
end end
private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
sp = cfr["sp"]
url = URI.parse(cfr["url"])
params = url.query_params
LOGGER.debug("convert_url: Decoding '#{cfr}'")
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
params[sp] = unsig if unsig
else
url = URI.parse(fmt["url"].as_s)
params = url.query_params
end
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n
if token = CONFIG.po_token
params["pot"] = token
end
url.query_params = params
LOGGER.trace("convert_url: new url is '#{url}'")
return url.to_s
rescue ex
LOGGER.debug("convert_url: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end

View File

@@ -1,122 +0,0 @@
require "uri"
require "http/params"
module Invidious::Videos
struct Storyboard
# Template URL
getter url : URI
getter proxied_url : URI
# Thumbnail parameters
getter width : Int32
getter height : Int32
getter count : Int32
getter interval : Int32
# Image (storyboard) parameters
getter rows : Int32
getter columns : Int32
getter images_count : Int32
def initialize(
*, @url, @width, @height, @count, @interval,
@rows, @columns, @images_count
)
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
@proxied_url = URI.parse(HOST_URL)
@proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}"
@proxied_url.query = @url.query
end
# Parse the JSON structure from Youtube
def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard)
# Livestream storyboards are a bit different
# TODO: document exactly how
if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [Storyboard.new(
url: URI.parse(storyboard.split("#")[0]),
width: 106,
height: 60,
count: -1,
interval: 5000,
rows: 3,
columns: 3,
images_count: -1
)]
end
# Split the storyboard string into chunks
#
# General format (whitespaces added for legibility):
# https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0>
# | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1>
# | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2>
# | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3>
#
storyboards = container.dig?("playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
return [] of Storyboard if !storyboards
# The base URL is the first chunk
base_url = URI.parse(storyboards.shift)
return storyboards.map_with_index do |sb, i|
# Separate the different storyboard parameters:
# width/height: respective dimensions, in pixels, of a single thumbnail
# count: how many thumbnails are displayed across the full video
# columns/rows: maximum amount of thumbnails that can be stuffed in a
# single image, horizontally and vertically.
# interval: interval between two thumbnails, in milliseconds
# name: storyboard filename. Usually "M$M" or "default"
# sigh: URL cryptographic signature
width, height, count, columns, rows, interval, name, sigh = sb.split("#")
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
columns = columns.to_i
rows = rows.to_i
# Copy base URL object, so that we can modify it
url = base_url.dup
# Add the signature to the URL
params = url.query_params
params["sigh"] = sigh
url.query_params = params
# Replace the template parts with what we have
url.path = url.path.sub("$L", i).sub("$N", name)
# This value represents the maximum amount of thumbnails that can fit
# in a single image. The last image (or the only one for short videos)
# will contain less thumbnails than that.
thumbnails_per_image = columns * rows
# This value represents the total amount of storyboards required to
# hold all of the thumbnails. It can't be less than 1.
images_count = (count / thumbnails_per_image).ceil.to_i
# Compute the interval when needed (in general, that's only required
# for the first "default" storyboard).
if interval == 0
interval = ((length_seconds / count) * 1_000).to_i
end
Storyboard.new(
url: url,
width: width,
height: height,
count: count,
interval: interval,
rows: rows,
columns: columns,
images_count: images_count,
)
end
end
end
end

View File

@@ -110,13 +110,13 @@ module Invidious::Videos
"Language" => @language_code, "Language" => @language_code,
} }
vtt = WebVTT.build(settings_field) do |builder| vtt = WebVTT.build(settings_field) do |vtt|
@lines.each do |line| @lines.each do |line|
# Section headers are excluded from the VTT conversion as to # Section headers are excluded from the VTT conversion as to
# match the regular captions returned from YouTube as much as possible # match the regular captions returned from YouTube as much as possible
next if line.is_a? HeadingLine next if line.is_a? HeadingLine
builder.cue(line.start_ms, line.end_ms, line.line) vtt.cue(line.start_ms, line.end_ms, line.line)
end end
end end

View File

@@ -2,7 +2,6 @@ struct VideoPreferences
include JSON::Serializable include JSON::Serializable
property annotations : Bool property annotations : Bool
property preload : Bool
property autoplay : Bool property autoplay : Bool
property comments : Array(String) property comments : Array(String)
property continue : Bool property continue : Bool
@@ -29,7 +28,6 @@ end
def process_video_params(query, preferences) def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i? annotations = query["iv_load_policy"]?.try &.to_i?
preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
comments = query["comments"]?.try &.split(",").map(&.downcase) comments = query["comments"]?.try &.split(",").map(&.downcase)
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@@ -52,7 +50,6 @@ def process_video_params(query, preferences)
if preferences if preferences
# region ||= preferences.region # region ||= preferences.region
annotations ||= preferences.annotations.to_unsafe annotations ||= preferences.annotations.to_unsafe
preload ||= preferences.preload.to_unsafe
autoplay ||= preferences.autoplay.to_unsafe autoplay ||= preferences.autoplay.to_unsafe
comments ||= preferences.comments comments ||= preferences.comments
continue ||= preferences.continue.to_unsafe continue ||= preferences.continue.to_unsafe
@@ -73,7 +70,6 @@ def process_video_params(query, preferences)
end end
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
preload ||= CONFIG.default_user_preferences.preload.to_unsafe
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
comments ||= CONFIG.default_user_preferences.comments comments ||= CONFIG.default_user_preferences.comments
continue ||= CONFIG.default_user_preferences.continue.to_unsafe continue ||= CONFIG.default_user_preferences.continue.to_unsafe
@@ -93,7 +89,6 @@ def process_video_params(query, preferences)
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
annotations = annotations == 1 annotations = annotations == 1
preload = preload == 1
autoplay = autoplay == 1 autoplay = autoplay == 1
continue = continue == 1 continue = continue == 1
continue_autoplay = continue_autoplay == 1 continue_autoplay = continue_autoplay == 1
@@ -133,7 +128,6 @@ def process_video_params(query, preferences)
params = VideoPreferences.new({ params = VideoPreferences.new({
annotations: annotations, annotations: annotations,
preload: preload,
autoplay: autoplay, autoplay: autoplay,
comments: comments, comments: comments,
continue: continue, continue: continue,

View File

@@ -1,6 +1,5 @@
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>" <video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
id="player" class="on-video_player video-js player-style-<%= params.player_style %>" id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
preload="<% if params.preload %>auto<% else %>none<% end %>"
<% if params.autoplay %>autoplay<% end %> <% if params.autoplay %>autoplay<% end %>
<% if params.video_loop %>loop<% end %> <% if params.video_loop %>loop<% end %>
<% if params.controls %>controls<% end %>> <% if params.controls %>controls<% end %>>

View File

@@ -6,7 +6,4 @@
title="<%= translate(locale, "search") %>" title="<%= translate(locale, "search") %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset> </fieldset>
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>
</button>
</form> </form>

View File

@@ -12,11 +12,6 @@
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>> <input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
</div> </div>
<div class="pure-control-group">
<label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
<input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label> <label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>> <input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>

View File

@@ -62,7 +62,7 @@ we're going to need to do it here in order to allow for translations.
"params" => params, "params" => params,
"preferences" => preferences, "preferences" => preferences,
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix, "premiere_timestamp" => video.premiere_timestamp.try &.to_unix,
"vr" => video.vr?, "vr" => video.is_vr,
"projection_type" => video.projection_type, "projection_type" => video.projection_type,
"local_disabled" => CONFIG.disabled?("local"), "local_disabled" => CONFIG.disabled?("local"),
"support_reddit" => true "support_reddit" => true

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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.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
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,45 +44,25 @@ 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)
client = HTTP::Client.new(url) client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.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
@@ -79,38 +70,10 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, &) def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve: force_resolve) 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)
@@ -109,30 +108,21 @@ private module Parsers
length_seconds = 0 length_seconds = 0
end end
live_now = false
premium = false
premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) }
badges = VideoBadges::None
item_contents["badges"]?.try &.as_a.each do |badge| item_contents["badges"]?.try &.as_a.each do |badge|
b = badge["metadataBadgeRenderer"] b = badge["metadataBadgeRenderer"]
case b["label"].as_s case b["label"].as_s
when "LIVE" when "LIVE NOW"
badges |= VideoBadges::LiveNow live_now = true
when "New" when "New", "4K", "CC"
badges |= VideoBadges::New # TODO
when "4K"
badges |= VideoBadges::FourK
when "8K"
badges |= VideoBadges::EightK
when "VR180"
badges |= VideoBadges::VR180
when "360°"
badges |= VideoBadges::VR360
when "3D"
badges |= VideoBadges::ThreeD
when "CC"
badges |= VideoBadges::ClosedCaptions
when "Premium" when "Premium"
# TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"]
badges |= VideoBadges::Premium premium = true
else nil # Ignore else nil # Ignore
end end
end end
@@ -146,9 +136,10 @@ private module Parsers
views: view_count, views: view_count,
description_html: description_html, description_html: description_html,
length_seconds: length_seconds, length_seconds: length_seconds,
live_now: live_now,
premium: premium,
premiere_timestamp: premiere_timestamp, premiere_timestamp: premiere_timestamp,
author_verified: author_verified, author_verified: author_verified,
badges: badges,
}) })
end end
@@ -468,9 +459,9 @@ private module Parsers
# Parses an InnerTube richItemRenderer into a SearchVideo. # Parses an InnerTube richItemRenderer into a SearchVideo.
# Returns nil when the given object isn't a RichItemRenderer # Returns nil when the given object isn't a RichItemRenderer
# #
# A richItemRenderer seems to be a simple wrapper for a various other types, # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
# used on the hashtags result page and the channel podcast tab. It is located # by the result page for hashtags and for the podcast tab on channels.
# itself inside a richGridRenderer container. # It is located inside a continuationItems container for hashtags.
# #
module RichItemRendererParser module RichItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback) def self.process(item : JSON::Any, author_fallback : AuthorFallback)
@@ -483,8 +474,6 @@ private module Parsers
child = VideoRendererParser.process(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback)
child ||= ReelItemRendererParser.process(item_contents, author_fallback) child ||= ReelItemRendererParser.process(item_contents, author_fallback)
child ||= PlaylistRendererParser.process(item_contents, author_fallback) child ||= PlaylistRendererParser.process(item_contents, author_fallback)
child ||= LockupViewModelParser.process(item_contents, author_fallback)
child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback)
return child return child
end end
@@ -499,9 +488,6 @@ private module Parsers
# reelItemRenderer items are used in the new (2022) channel layout, # reelItemRenderer items are used in the new (2022) channel layout,
# in the "shorts" tab. # in the "shorts" tab.
# #
# NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel
# TODO: Confirm that hypothesis
#
module ReelItemRendererParser module ReelItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback) def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["reelItemRenderer"]? if item_contents = item["reelItemRenderer"]?
@@ -577,138 +563,10 @@ private module Parsers
views: view_count, views: view_count,
description_html: "", description_html: "",
length_seconds: duration, length_seconds: duration,
live_now: false,
premium: false,
premiere_timestamp: Time.unix(0), premiere_timestamp: Time.unix(0),
author_verified: false, author_verified: false,
badges: VideoBadges::None,
})
end
def self.parser_name
return {{@type.name}}
end
end
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
# Returns nil when the given object is not a lockupViewModel.
#
# This structure is present since November 2024 on the "podcasts" and
# "playlists" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer.
#
module LockupViewModelParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["lockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def self.parse(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s
thumbnail_view_model = item_contents.dig(
"contentImage", "collectionThumbnailViewModel",
"primaryThumbnail", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
# This complicated sequences tries to extract the following data structure:
# "overlays": [{
# "thumbnailOverlayBadgeViewModel": {
# "thumbnailBadges": [{
# "thumbnailBadgeViewModel": {
# "text": "430 episodes",
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
# }
# }]
# }
# }]
#
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
video_count = thumbnail_view_model.dig("overlays").as_a
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
})
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s
# TODO: Retrieve "updated" info from metadata parts
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
# One of these parts should contain a string like: "Updated 2 days ago"
# TODO: Maybe add a button to access the first video of the playlist?
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
# Available fields: "videoId", "playlistId", "params"
return SearchPlaylist.new({
title: title,
id: playlist_id,
author: author_fallback.name,
ucid: author_fallback.id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
end
def self.parser_name
return {{@type.name}}
end
end
# Parses an InnerTube shortsLockupViewModel into a SearchVideo.
# Returns nil when the given object is not a shortsLockupViewModel.
#
# This structure is present since around October 2024 on the "shorts" tab of
# the channel page and likely replaces the reelItemRenderer structure. It is
# usually (always?) encapsulated in a richItemRenderer.
#
module ShortsLockupViewModelParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["shortsLockupViewModel"]?
return self.parse(item_contents, author_fallback)
end
end
private def self.parse(item_contents, author_fallback)
# TODO: Maybe add support for "oardefault.jpg" thumbnails?
# thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s
# Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?...
video_id = item_contents.dig(
"onTap", "innertubeCommand", "reelWatchEndpoint", "videoId"
).as_s
title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s
view_count = short_text_to_number(
item_contents.dig("overlayMetadata", "secondaryText", "content").as_s
)
# Approximate to one minute, as "shorts" generally don't exceed that.
# NOTE: The actual duration is not provided by Youtube anymore.
# TODO: Maybe use -1 as an error value and handle that on the frontend?
duration = 60_i32
SearchVideo.new({
title: title,
id: video_id,
author: author_fallback.name,
ucid: author_fallback.id,
published: Time.unix(0),
views: view_count,
description_html: "",
length_seconds: duration,
premiere_timestamp: Time.unix(0),
author_verified: false,
badges: VideoBadges::None,
}) })
end end

View File

@@ -1,121 +0,0 @@
require "uri"
module UrlSanitizer
extend self
ALLOWED_QUERY_PARAMS = {
channel: ["u", "user", "lb"],
playlist: ["list"],
search: ["q", "search_query", "sp"],
watch: [
"v", # Video ID
"list", "index", # Playlist-related
"playlist", # Unnamed playlist (id,id,id,...) (embed-only?)
"t", "time_continue", "start", "end", # Timestamp
"lc", # Highlighted comment (watch page only)
],
}
# Returns whether the given string is an ASCII word. This is the same as
# running the following regex in US-ASCII locale: /^[\w-]+$/
private def ascii_word?(str : String) : Bool
return false if str.bytesize != str.size
str.each_byte do |byte|
next if 'a'.ord <= byte <= 'z'.ord
next if 'A'.ord <= byte <= 'Z'.ord
next if '0'.ord <= byte <= '9'.ord
next if byte == '-'.ord || byte == '_'.ord
return false
end
return true
end
# Return which kind of parameters are allowed based on the
# first path component (breadcrumb 0).
private def determine_allowed(path_root : String)
case path_root
when "watch", "w", "v", "embed", "e", "shorts", "clip"
return :watch
when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link"
return :channel
when "playlist", "mix"
return :playlist
when "results", "search"
return :search
else # hashtag, post, trending, brand URLs, etc..
return nil
end
end
# Create a new URI::Param containing only the allowed parameters
private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params
new_params = URI::Params.new
ALLOWED_QUERY_PARAMS[allowed_type].each do |name|
if unsafe_params[name]?
# Only copy the last parameter, in case there is more than one
new_params[name] = unsafe_params.fetch_all(name)[-1]
end
end
return new_params
end
# Transform any user-supplied youtube URL into something we can trust
# and use across the code.
def process(str : String) : URI
# Because URI follows RFC3986 specifications, URL without a scheme
# will be parsed as a relative path. So we have to add a scheme ourselves.
str = "https://#{str}" if !str.starts_with?(/https?:\/\//)
unsafe_uri = URI.parse(str)
unsafe_host = unsafe_uri.host
unsafe_path = unsafe_uri.path
new_uri = URI.new(path: "/")
# Redirect to homepage for bogus URLs
return new_uri if (unsafe_host.nil? || unsafe_path.nil?)
breadcrumbs = unsafe_path
.split('/', remove_empty: true)
.compact_map do |bc|
# Exclude attempts at path trasversal
next if bc == "." || bc == ".."
# Non-alnum characters are unlikely in a genuine URL
next if !ascii_word?(bc)
bc
end
# If nothing remains, it's either a legit URL to the homepage
# (who does that!?) or because we filtered some junk earlier.
return new_uri if breadcrumbs.empty?
# Replace the original query parameters with the sanitized ones
case unsafe_host
when .ends_with?("youtube.com")
# Use our sanitized path (not forgetting the leading '/')
new_uri.path = "/#{breadcrumbs.join('/')}"
# Then determine which params are allowed, and copy them over
if allowed = determine_allowed(breadcrumbs[0])
new_uri.query_params = copy_params(unsafe_uri.query_params, allowed)
end
when "youtu.be"
# Always redirect to the watch page
new_uri.path = "/watch"
new_params = copy_params(unsafe_uri.query_params, :watch)
new_params["v"] = breadcrumbs[0]
new_uri.query_params = new_params
end
return new_uri
end
end

View File

@@ -6,10 +6,10 @@ module YoutubeAPI
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
private ANDROID_APP_VERSION = "19.32.34" private ANDROID_APP_VERSION = "19.14.42"
private ANDROID_VERSION = "12" private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip"
private ANDROID_SDK_VERSION = 31_i64 private ANDROID_SDK_VERSION = 31_i64
private ANDROID_VERSION = "12"
private ANDROID_TS_APP_VERSION = "1.9" private ANDROID_TS_APP_VERSION = "1.9"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip" private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
@@ -17,9 +17,9 @@ module YoutubeAPI
# For Apple device names, see https://gist.github.com/adamawolf/3048717 # For Apple device names, see https://gist.github.com/adamawolf/3048717
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases, # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
# then go to the dedicated article of the major version you want. # then go to the dedicated article of the major version you want.
private IOS_APP_VERSION = "19.32.8" private IOS_APP_VERSION = "19.16.3"
private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)" private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)"
private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build
private WINDOWS_VERSION = "10.0" private WINDOWS_VERSION = "10.0"
@@ -29,7 +29,6 @@ module YoutubeAPI
WebEmbeddedPlayer WebEmbeddedPlayer
WebMobile WebMobile
WebScreenEmbed WebScreenEmbed
WebCreator
Android Android
AndroidEmbeddedPlayer AndroidEmbeddedPlayer
@@ -49,7 +48,7 @@ module YoutubeAPI
ClientType::Web => { ClientType::Web => {
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240814.00.00", version: "2.20240304.00.00",
screen: "WATCH_FULL_SCREEN", screen: "WATCH_FULL_SCREEN",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@@ -58,7 +57,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => { ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER", name: "WEB_EMBEDDED_PLAYER",
name_proto: "56", name_proto: "56",
version: "1.20240812.01.00", version: "1.20240303.00.00",
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@@ -67,7 +66,7 @@ module YoutubeAPI
ClientType::WebMobile => { ClientType::WebMobile => {
name: "MWEB", name: "MWEB",
name_proto: "2", name_proto: "2",
version: "2.20240813.02.00", version: "2.20240304.08.00",
os_name: "Android", os_name: "Android",
os_version: ANDROID_VERSION, os_version: ANDROID_VERSION,
platform: "MOBILE", platform: "MOBILE",
@@ -75,20 +74,12 @@ module YoutubeAPI
ClientType::WebScreenEmbed => { ClientType::WebScreenEmbed => {
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240814.00.00", version: "2.20240304.00.00",
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
platform: "DESKTOP", platform: "DESKTOP",
}, },
ClientType::WebCreator => {
name: "WEB_CREATOR",
name_proto: "62",
version: "1.20240918.03.00",
os_name: "Windows",
os_version: WINDOWS_VERSION,
platform: "DESKTOP",
},
# Android # Android
@@ -156,8 +147,8 @@ module YoutubeAPI
ClientType::IOSMusic => { ClientType::IOSMusic => {
name: "IOS_MUSIC", name: "IOS_MUSIC",
name_proto: "26", name_proto: "26",
version: "7.14", version: "6.42",
user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)", user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)",
device_make: "Apple", device_make: "Apple",
device_model: "iPhone14,5", device_model: "iPhone14,5",
os_name: "iPhone", os_name: "iPhone",
@@ -170,7 +161,7 @@ module YoutubeAPI
ClientType::TvHtml5 => { ClientType::TvHtml5 => {
name: "TVHTML5", name: "TVHTML5",
name_proto: "7", name_proto: "7",
version: "7.20240813.07.00", version: "7.20240304.10.00",
}, },
ClientType::TvHtml5ScreenEmbed => { ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
@@ -637,11 +628,6 @@ module YoutubeAPI
# Send the POST request # Send the POST request
body = YT_POOL.client() do |client| body = YT_POOL.client() do |client|
client.post(url, headers: headers, body: data.to_json) do |response| client.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?) self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end end
end end