Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
31e9773b0c | |||
1f94a0e1ec |
137 changed files with 1250 additions and 2767 deletions
|
@ -1,56 +1,52 @@
|
|||
name: "Invidious CI"
|
||||
name: 'Invidious CI'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# schedule:
|
||||
# - cron: '0 7 * * 0'
|
||||
# workflow_dispatch:
|
||||
# inputs: {}
|
||||
schedule:
|
||||
- cron: '0 7 * * 0'
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- LICENCE
|
||||
- TRANSLATION
|
||||
- invidious.service
|
||||
- .git*
|
||||
- .editorconfig
|
||||
- screenshots/*
|
||||
- .github/ISSUE_TEMPLATE/*
|
||||
- kubernetes/**
|
||||
- "experimental"
|
||||
- "experimental2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: runner
|
||||
|
||||
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v2
|
||||
|
||||
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
||||
name: Setup Docker BuildX system
|
||||
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
||||
name: Setup Docker BuildX system
|
||||
|
||||
- name: Login to Docker Container Registry
|
||||
uses: https://code.forgejo.org/docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: git.nadeko.net
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Login to Docker Container Registry
|
||||
uses: https://code.forgejo.org/docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: git.nadeko.net
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: git.nadeko.net/fijxu/invidious
|
||||
tags: |
|
||||
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: git.nadeko.net/fijxu/invidious
|
||||
tags: |
|
||||
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
|
||||
- uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||
name: Build images
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: linux/amd64
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
push: true
|
||||
build-args: |
|
||||
"release=1"
|
||||
|
||||
- uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||
name: Build images
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -10,10 +10,8 @@ assignees: ''
|
|||
<!--
|
||||
BEFORE TRYING TO REPORT A BUG:
|
||||
|
||||
* Read the FAQ: https://docs.invidious.io/faq/!
|
||||
* Use the search function to check if there is already an issue open for your problem: https://github.com/search?q=repo%3Aiv-org%2Finvidious+replace+me+with+your+bug&type=issues!
|
||||
|
||||
MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
|
||||
* Read the FAQ!
|
||||
* Use the search function to check if there is already an issue open for your problem!
|
||||
|
||||
If you want to suggest a new feature please use "Feature request" instead
|
||||
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead
|
||||
|
|
13
.github/workflows/build-nightly-container.yml
vendored
13
.github/workflows/build-nightly-container.yml
vendored
|
@ -23,6 +23,19 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.8.2
|
||||
with:
|
||||
crystal: 1.12.2
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
if ! crystal tool format --check; then
|
||||
crystal tool format
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
|
|
13
.github/workflows/build-stable-container.yml
vendored
13
.github/workflows/build-stable-container.yml
vendored
|
@ -14,6 +14,19 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.8.2
|
||||
with:
|
||||
crystal: 1.12.2
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
if ! crystal tool format --check; then
|
||||
crystal tool format
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
|
|
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
@ -38,10 +38,11 @@ jobs:
|
|||
matrix:
|
||||
stable: [true]
|
||||
crystal:
|
||||
- 1.10.1
|
||||
- 1.11.2
|
||||
- 1.12.1
|
||||
- 1.13.2
|
||||
- 1.14.0
|
||||
- 1.15.0
|
||||
include:
|
||||
- crystal: nightly
|
||||
stable: false
|
||||
|
@ -135,7 +136,6 @@ jobs:
|
|||
submodules: true
|
||||
|
||||
- name: Install Crystal
|
||||
id: lint_step_install_crystal
|
||||
uses: crystal-lang/install-crystal@v1.8.0
|
||||
with:
|
||||
crystal: latest
|
||||
|
@ -146,7 +146,7 @@ jobs:
|
|||
path: |
|
||||
./lib
|
||||
./bin
|
||||
key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }}
|
||||
key: shards-${{ hashFiles('shard.lock') }}
|
||||
|
||||
- name: Install Shards
|
||||
run: |
|
||||
|
|
95
CHANGELOG.md
95
CHANGELOG.md
|
@ -2,102 +2,7 @@
|
|||
|
||||
## vX.Y.0 (future)
|
||||
|
||||
## v2.20250314.0
|
||||
|
||||
### Wrap-up
|
||||
|
||||
This release brings the long awaited feature of supporting multiple audio tracks in a video, some bug fixes and UX improvements, and many other things primarily oriented to self-hosting instances, and developers using the API.
|
||||
|
||||
The `Community` channel tab has been replaced by `Posts` in light of YouTube changes, but the URL remains the same.
|
||||
|
||||
Tamil is now available as an interface language
|
||||
|
||||
Automatic instance redirects will no longer have the chance to annoyingly redirect to the same instance you're on.
|
||||
|
||||
Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
|
||||
|
||||
Invidious is now able to listen through a UNIX socket
|
||||
|
||||
User notifications are now batched for each channel
|
||||
|
||||
**The minimum Crystal version supported by Invidious now `1.12.0`**
|
||||
|
||||
### New features & important changes
|
||||
|
||||
#### For users
|
||||
|
||||
* Invidious now supports videos with multiple audio tracks allowing you to select which one you want to hear with!
|
||||
* Channel pages now have a proper previous page button
|
||||
* RSS feeds for channels will no longer contain the channel's profile picture
|
||||
* Support for channel `courses` page has been added
|
||||
* `Community` tabs has been replaced with `Posts` to comply with YouTube changes
|
||||
* Tamil is now an available interface language.
|
||||
|
||||
#### For instance owners
|
||||
* Invidious is now able to listen on a UNIX socket
|
||||
* User notifications are now batched by channels, significantly reducing database load.
|
||||
* **`1.12.0` is now the oldest Crystal version that Invidious supports**
|
||||
* The example config will no longer force an http proxy to be configured
|
||||
* Invidious will now warn when any top-level config option must be set to a custom value, instead of just `HMAC_KEY`
|
||||
* Due to their requirements for video playback, Invidious will log warning messages when either inv-sig-helper, `po_token` or `visitor_data` is not configured
|
||||
|
||||
#### For developers
|
||||
* Invidious is now compliant to Crystal 1.15 formatting rules, which are incompatible with earlier versions.
|
||||
* `/api/v1/transcripts/{id}` has been added to the API to allow for fetching the transcripts for a video. The arguments are the same as the captions endpoint.
|
||||
* `author_thumbnail` field has been added to videos in the various paged api endpoints
|
||||
* `published` field has been added to the API response for a video's related videos.
|
||||
* Docker builds now uses the Crystal compiler cache, reducing build times on repeated builds significantly.
|
||||
* Invidious ajax action handlers has undergone a clean up and may face compatibility issues with code that depends on these endpoints.
|
||||
* The versions of Crystal that we test in CI/CD are now: `1.12.1`, `1.13.2`, `1.14.0`, `1.15.0`
|
||||
|
||||
### Bugs fixed
|
||||
|
||||
#### User-side
|
||||
* Local video listen mode is now preserved when clicking on a video in the sidebar playlist widget
|
||||
* Automatic instance redirects will no longer redirect to the same instance the user is on
|
||||
* Fix some thumbnails responses returning 404
|
||||
* Videos: Fix missing host parameter on playback URLs when `local=true`
|
||||
* Fix HLS being used for non-livestream videos
|
||||
* Fix timeupdate event errors when required elements are missing
|
||||
* User: Ensure IO is properly closed when importing NewPipe subscriptions
|
||||
|
||||
#### For instance owners
|
||||
* Fix http proxy configuration being forced by the standard example config
|
||||
|
||||
#### API
|
||||
* `/api/v1/videos/{id}` will no longer return an occasional empty JSON response
|
||||
|
||||
### Full list of pull requests merged since the last release (newest first)
|
||||
* Make Invidious compliant to Crystal 1.15 formatting rules (https://github.com/iv-org/invidious/pull/5014, by @syeopite)
|
||||
* Remove formatter check on container workflows (https://github.com/iv-org/invidious/pull/5153, by @syeopite)
|
||||
* Videos: Fix missing host parameter on playback URLs when `local=true` (https://github.com/iv-org/invidious/pull/4992, by @SamantazFox)
|
||||
* Remove stdlib override for proxy initialization (https://github.com/iv-org/invidious/pull/5065, by @syeopite)
|
||||
* Add support for author thumbnails in search api for videos (https://github.com/iv-org/invidious/pull/5072, thanks @ChunkyProgrammer)
|
||||
* Skip route if resp got closed by before handlers (https://github.com/iv-org/invidious/pull/5073, by @syeopite)
|
||||
* Fix video thumbnails in mixes (https://github.com/iv-org/invidious/pull/5116, thanks @iBicha)
|
||||
* CI: Drop support for versions prior to 1.12 and add 1.15.0 (https://github.com/iv-org/invidious/pull/5148, by @syeopite)
|
||||
* [Continuing #5094] Set language info for dash audio streams and sort (https://github.com/iv-org/invidious/pull/5149, thanks @giuliano-macedo)
|
||||
* Warn when any top-level config is "CHANGE_ME!!" (https://github.com/iv-org/invidious/pull/5150, by @syeopite)
|
||||
* Comment out http_proxy in example config (https://github.com/iv-org/invidious/pull/5151, by @syeopite)
|
||||
* API: Add a 'published' video parameter for related videos (https://github.com/iv-org/invidious/pull/4149, thanks @RadoslavL)
|
||||
* Ensure IO is properly closed when importing NewPipe subscriptions (https://github.com/iv-org/invidious/pull/4346, thanks @ChunkyProgrammer)
|
||||
* Carry over audio-only mode in playlist links (https://github.com/iv-org/invidious/pull/4784, thanks @krystof1119)
|
||||
* Routes: Clean ajax actions handlers (https://github.com/iv-org/invidious/pull/5036, by @SamantazFox)
|
||||
* Frontend: Add a first page and previous page buttons for channel navigation (https://github.com/iv-org/invidious/pull/4123, thanks @RadoslavL)
|
||||
* RSS: Channel + Playlist improvements (https://github.com/iv-org/invidious/pull/4298, thanks @ChunkyProgrammer)
|
||||
* Batch user notifications together (https://github.com/iv-org/invidious/pull/4486, thanks @999eagle)
|
||||
* JS: Update timeupdate event making it more defensive to prevent errors (https://github.com/iv-org/invidious/pull/4782, thanks @PMK)
|
||||
* Add API endpoint for fetching transcripts from YouTube by (https://github.com/iv-org/invidious/pull/4788, by @syeopite)
|
||||
* Translations update from Hosted Weblate by (https://github.com/iv-org/invidious/pull/4989, thanks to our many translators)
|
||||
* Add the ability to listen on UNIX sockets (https://github.com/iv-org/invidious/pull/5112, thanks @Caian)
|
||||
* Pick a different instance upon redirect (https://github.com/iv-org/invidious/pull/5154, thanks @epicsam123)
|
||||
* Add Courses to channel page and channel API (https://github.com/iv-org/invidious/pull/5158, thanks @ChunkyProgrammer)
|
||||
* fix /api/v1/videos/:id returns 200 with no content (https://github.com/iv-org/invidious/pull/5162, thanks @Drikanis)
|
||||
* Use Crystal compiler cache in docker builds (https://github.com/iv-org/invidious/pull/5163, by @syeopite)
|
||||
* Channels: Fix community tab by (https://github.com/iv-org/invidious/pull/5183, thanks @Fijxu)
|
||||
* Fix typo in `src/invidious/routes/images.cr` (https://github.com/iv-org/invidious/pull/5184, by @syeopite)
|
||||
* Fix an issue with the HLS manifest check for livestream videos (https://github.com/iv-org/invidious/pull/5189, thanks @alexmaras)
|
||||
* Warn when `po_token`, `visitor_data` and/or `inv-sig-helper` is not configured (https://github.com/iv-org/invidious/pull/5202, by @syeopite)
|
||||
## v2.20241110.0
|
||||
|
||||
### Wrap-up
|
||||
|
|
224
README.md
224
README.md
|
@ -1,62 +1,170 @@
|
|||
# nadeko.net Invidious fork
|
||||
<div align="center">
|
||||
<img src="assets/invidious-colored-vector.svg" width="192" height="192" alt="Invidious logo">
|
||||
<h1>Invidious</h1>
|
||||
|
||||
This is a fork of Invidious with features that I have done for my own instance. If you want to maintain an instance, feel free to use this fork and it's container images (they are also compatible with Podman, not just docker!)
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
|
||||
<img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
|
||||
</a>
|
||||
<a href="https://github.com/iv-org/invidious/actions">
|
||||
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/iv-org/invidious/commits/master">
|
||||
<img alt="GitHub commits" src="https://img.shields.io/github/commit-activity/y/iv-org/invidious?color=red&label=commits">
|
||||
</a>
|
||||
<a href="https://github.com/iv-org/invidious/issues">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/iv-org/invidious?color=important">
|
||||
</a>
|
||||
<a href="https://github.com/iv-org/invidious/pulls">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/iv-org/invidious?color=blueviolet">
|
||||
</a>
|
||||
<a href="https://hosted.weblate.org/engage/invidious/">
|
||||
<img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg">
|
||||
</a>
|
||||
|
||||
https://git.nadeko.net/Fijxu/-/packages/container/invidious/latest
|
||||
<a href="https://github.com/humanetech-community/awesome-humane-tech">
|
||||
<img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true">
|
||||
</a>
|
||||
|
||||
> [!CAUTION]
|
||||
> If you already have an Invidious instance running the upstream code, moving it to this fork will not work for you!
|
||||
> This is due to the "Removal of materialized views on PostgreSQL" pull request that requires a migration of the database
|
||||
> using it.
|
||||
<h3>An open source alternative front-end to YouTube</h3>
|
||||
|
||||
<a href="https://invidious.io/">Website</a>
|
||||
•
|
||||
<a href="https://instances.invidious.io/">Instances list</a>
|
||||
•
|
||||
<a href="https://docs.invidious.io/faq/">FAQ</a>
|
||||
•
|
||||
<a href="https://docs.invidious.io/">Documentation</a>
|
||||
•
|
||||
<a href="#contribute">Contribute</a>
|
||||
•
|
||||
<a href="https://invidious.io/donate/">Donate</a>
|
||||
|
||||
<h5>Chat with us:</h5>
|
||||
<a href="https://matrix.to/#/#invidious:matrix.org">
|
||||
<img alt="Matrix" src="https://img.shields.io/matrix/invidious:matrix.org?label=Matrix&color=darkgreen">
|
||||
</a>
|
||||
<a href="https://web.libera.chat/?channel=#invidious">
|
||||
<img alt="Libera.chat (IRC)" src="https://img.shields.io/badge/IRC%20%28Libera.chat%29-%23invidious-darkgreen">
|
||||
</a>
|
||||
<br>
|
||||
<a rel="me" href="https://social.tchncs.de/@invidious">
|
||||
<img alt="Fediverse: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Fediverse-%40invidious%40social.tchncs.de-darkgreen">
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://invidious.io/contact/">
|
||||
<img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Player | Preferences | Subscriptions |
|
||||
|-------------------------------------|-------------------------------------|---------------------------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
**User features**
|
||||
- Lightweight
|
||||
- No ads
|
||||
- No tracking
|
||||
- No JavaScript required
|
||||
- Light/Dark themes
|
||||
- Customizable homepage
|
||||
- Subscriptions independent from Google
|
||||
- Notifications for all subscribed channels
|
||||
- Audio-only mode (with background play on mobile)
|
||||
- Support for Reddit comments
|
||||
- [Available in many languages](locales/), thanks to [our translators](#contribute)
|
||||
|
||||
**Data import/export**
|
||||
- Import subscriptions from YouTube, NewPipe and Freetube
|
||||
- Import watch history from YouTube and NewPipe
|
||||
- Export subscriptions to NewPipe and Freetube
|
||||
- Import/Export Invidious user data
|
||||
|
||||
**Technical features**
|
||||
- Embedded video support
|
||||
- [Developer API](https://docs.invidious.io/api/)
|
||||
- Does not use official YouTube APIs
|
||||
- No Contributor License Agreement (CLA)
|
||||
|
||||
|
||||
## Quick start
|
||||
|
||||
**Using invidious:**
|
||||
|
||||
- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now!
|
||||
|
||||
**Hosting invidious:**
|
||||
|
||||
- [Follow the installation instructions](https://docs.invidious.io/installation/)
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation can be accessed online at https://docs.invidious.io/
|
||||
|
||||
The documentation's source code is available in this repository:
|
||||
https://github.com/iv-org/documentation
|
||||
|
||||
### Extensions
|
||||
|
||||
We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get),
|
||||
a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
|
||||
embedded youtube videos on other websites with invidious.
|
||||
|
||||
The documentation contains a list of browser extensions that we recommended to use along with Invidious.
|
||||
|
||||
You can read more here: https://docs.invidious.io/applications/
|
||||
|
||||
|
||||
## Contribute
|
||||
|
||||
### Code
|
||||
|
||||
1. Fork it ( https://github.com/iv-org/invidious/fork ).
|
||||
1. Create your feature branch (`git checkout -b my-new-feature`).
|
||||
1. Stage your files (`git add .`).
|
||||
1. Commit your changes (`git commit -am 'Add some feature'`).
|
||||
1. Push to the branch (`git push origin my-new-feature`).
|
||||
1. Create a new pull request ( https://github.com/iv-org/invidious/compare ).
|
||||
|
||||
### Translations
|
||||
|
||||
We use [Weblate](https://weblate.org) to manage Invidious translations.
|
||||
|
||||
You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/.
|
||||
|
||||
Creating an account is not required, but recommended, especially if you want to contribute regularly.
|
||||
Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ...
|
||||
|
||||
|
||||
## Projects using Invidious
|
||||
|
||||
A list of projects and extensions for or utilizing Invidious can be found in the documentation: https://docs.invidious.io/applications/
|
||||
|
||||
## Liability
|
||||
|
||||
We take no responsibility for the use of our tool, or external instances
|
||||
provided by third parties. We strongly recommend you abide by the valid
|
||||
official regulations in your country. Furthermore, we refuse liability
|
||||
for any inappropriate use of Invidious, such as illegal downloading.
|
||||
This tool is provided to you in the spirit of free, open software.
|
||||
|
||||
You may view the LICENSE in which this software is provided to you [here](./LICENSE).
|
||||
|
||||
> 16. Limitation of Liability.
|
||||
>
|
||||
> If you don't have an instance already, you can use this fork safely, but you will not be able to switch to upstream Invidious.
|
||||
|
||||
## Features and changes of this fork:
|
||||
|
||||
- ~~[Use a Redis compatible DB for video cache instead of just PostgreSQL](https://git.nadeko.net/Fijxu/invidious/commit/bbc5913b8dacaed4d466bcc466a0782d5e3f5edc): Invidious by default caches the video information for some hours in PostgreSQL. Since the data is accessed a lot, it is better off using an in memory database instead, it's faster and it will not wear out your SSD (due to constant writes to the database).~~
|
||||
|
||||
~~It can be set using this on `config.yml`:~~
|
||||
```yaml
|
||||
redis_url: tcp://127.0.0.1:6379
|
||||
```
|
||||
|
||||
- [Ability to use different video caching backends](https://git.nadeko.net/Fijxu/invidious/commit/e76867aaba022d64ebab73648a37a0c63b788e0f): If you want, you can the PostgreSQL video cache the Redis one or the built-in in memory one that uses the LRU algorithm. Redis and LRU are recommended for public instances, but since Invidious has memory leaks, the LRU cache is lost if Invidious crashes or it's restarted, so because of this, redis is the default option.
|
||||
|
||||
```yaml
|
||||
video_cache:
|
||||
enabled: true
|
||||
backend: 1 # 0 is PSQL, 1 Redis, 2 Built-in LRU
|
||||
lru_max_size: 18000 # ~500MB (ignored if backend is 0 or 1)
|
||||
```
|
||||
|
||||
If you choose to use Redis, make sure to set the `redis_url` config property:
|
||||
|
||||
```yaml
|
||||
redis_url: tcp://127.0.0.1:6379
|
||||
```
|
||||
|
||||
- [Removal of materialized views on PostgreSQL](github.com/iv-org/invidious/pull/2469): If you don't have this on your Invidious public instance, your SSD will suffer and it will catch on fire https://github.com/iv-org/invidious/pull/2469#issuecomment-2012623454
|
||||
|
||||
- Limit the DASH resolution sent to the clients: It can be set using `max_dash_resolution` on the config. Example: `max_dash_resolution: 1080`
|
||||
|
||||
- [Limit requests made to Youtube API when pulling subscriptions (feeds)](https://git.nadeko.net/Fijxu/invidious/commit/df94f1c0b82d95846574487231ea251530838ef0): Due to the recent changes of Youtube ("This helps protect out community", "Sign in to confirm you are not a bot"), subscriptions now have limited information, this is because Invidious by default, makes a video request to youtube to be able to get more information about the video, like `length_seconds`, `live_now`, `premiere_timestamp`, and `views`. If you have a lot of users with a ton of subscriptions, Invidious will basically spam youtube API all the time, resulting in a block from youtube.
|
||||
|
||||
It can be set using this on `config.yml`:
|
||||
```yaml
|
||||
use_innertube_for_feeds: false
|
||||
```
|
||||
|
||||
|
||||
- Autoreload configuration: If you are hosting Invidious on Linux without docker, this may be useful for you if you want to change the banner without restarting Invidious.
|
||||
|
||||
```yaml
|
||||
reload_config_automatically: true
|
||||
```
|
||||
|
||||
## Development features
|
||||
|
||||
- Option to disable CSP: Useful for local development, set `csp: false` on the config and done
|
||||
|
||||
---
|
||||
|
||||
There is more things that I added to this fork, but those are the most important ones. I also regularly merge unmerged pull requests from https://github.com/iv-org/invidious and random fixes as well. Is not the most stable codebase, but you can't really make something stable when youtube is trying to destroy every third party client out there.
|
||||
> IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
|
1
assets/js/.gitignore
vendored
1
assets/js/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
minified
|
|
@ -91,7 +91,7 @@
|
|||
var count = document.getElementById('count');
|
||||
count.textContent--;
|
||||
|
||||
var url = '/token_ajax?action=revoke_token&redirect=false' +
|
||||
var url = '/token_ajax?action_revoke_token=1&redirect=false' +
|
||||
'&referer=' + encodeURIComponent(location.href) +
|
||||
'&session=' + target.getAttribute('data-session');
|
||||
|
||||
|
@ -111,7 +111,7 @@
|
|||
var count = document.getElementById('count');
|
||||
count.textContent--;
|
||||
|
||||
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
|
||||
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
|
||||
'&referer=' + encodeURIComponent(location.href) +
|
||||
'&c=' + target.getAttribute('data-ucid');
|
||||
|
||||
|
|
|
@ -1,93 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const CURRENT_CONTINUATION = (new URL(document.location)).searchParams.get("continuation");
|
||||
const CONT_CACHE_KEY = `continuation_cache_${encodeURIComponent(window.location.pathname)}`;
|
||||
|
||||
function get_data(){
|
||||
return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
|
||||
}
|
||||
|
||||
function save_data(){
|
||||
const prev_data = get_data();
|
||||
prev_data.push(CURRENT_CONTINUATION);
|
||||
|
||||
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
|
||||
}
|
||||
|
||||
function button_press(){
|
||||
let prev_data = get_data();
|
||||
if (!prev_data.length) return null;
|
||||
|
||||
// Sanity check. Nowhere should the current continuation token exist in the cache
|
||||
// but it can happen when using the browser's back feature. As such we'd need to travel
|
||||
// back to the point where the current continuation token first appears in order to
|
||||
// account for the rewind.
|
||||
const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
|
||||
if (conflict_at != -1) {
|
||||
prev_data.length = conflict_at;
|
||||
}
|
||||
|
||||
const prev_ctoken = prev_data.pop();
|
||||
|
||||
// On the first page, the stored continuation token is null.
|
||||
if (prev_ctoken === null) {
|
||||
sessionStorage.removeItem(CONT_CACHE_KEY);
|
||||
let url = set_continuation();
|
||||
window.location.href = url;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
|
||||
let url = set_continuation(prev_ctoken);
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Method to set the current page's continuation token
|
||||
// Removes the continuation parameter when a continuation token is not given
|
||||
function set_continuation(prev_ctoken = null){
|
||||
let url = window.location.href.split('?')[0];
|
||||
let params = window.location.href.split('?')[1];
|
||||
let url_params = new URLSearchParams(params);
|
||||
|
||||
if (prev_ctoken) {
|
||||
url_params.set("continuation", prev_ctoken);
|
||||
} else {
|
||||
url_params.delete('continuation');
|
||||
};
|
||||
|
||||
if(Array.from(url_params).length > 0){
|
||||
return `${url}?${url_params.toString()}`;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener('DOMContentLoaded', function(){
|
||||
const pagination_data = JSON.parse(document.getElementById('pagination-data').textContent);
|
||||
const next_page_containers = document.getElementsByClassName("page-next-container");
|
||||
|
||||
for (let container of next_page_containers){
|
||||
const next_page_button = container.getElementsByClassName("pure-button")
|
||||
|
||||
// exists?
|
||||
if (next_page_button.length > 0){
|
||||
next_page_button[0].addEventListener("click", save_data);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add previous page buttons when not on the first page
|
||||
if (CURRENT_CONTINUATION) {
|
||||
const prev_page_containers = document.getElementsByClassName("page-prev-container")
|
||||
|
||||
for (let container of prev_page_containers) {
|
||||
if (pagination_data.is_rtl) {
|
||||
container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page} <i class="icon ion-ios-arrow-forward"></i></button>`
|
||||
} else {
|
||||
container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i> ${pagination_data.prev_page}</button>`
|
||||
}
|
||||
container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -56,7 +56,6 @@ videojs.Vhs.MAX_GOAL_BUFFER_LENGTH = 80;
|
|||
var player = videojs('player', options);
|
||||
|
||||
player.on('error', function () {
|
||||
console.debug(`[VideoJS Debug] Playback cannot continue, error: ${player.error().code}`)
|
||||
if (video_data.params.quality === 'dash') return;
|
||||
|
||||
var localNotDisabled = (
|
||||
|
@ -138,32 +137,26 @@ player.on('timeupdate', function () {
|
|||
// YouTube links
|
||||
|
||||
let elem_yt_watch = document.getElementById('link-yt-watch');
|
||||
if (elem_yt_watch) {
|
||||
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
|
||||
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
|
||||
}
|
||||
|
||||
let elem_yt_embed = document.getElementById('link-yt-embed');
|
||||
if (elem_yt_embed) {
|
||||
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
|
||||
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
|
||||
}
|
||||
|
||||
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
|
||||
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url');
|
||||
|
||||
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
|
||||
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed);
|
||||
|
||||
// Invidious links
|
||||
|
||||
let domain = window.location.origin;
|
||||
|
||||
let elem_iv_embed = document.getElementById('link-iv-embed');
|
||||
if (elem_iv_embed) {
|
||||
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
|
||||
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
|
||||
}
|
||||
|
||||
let elem_iv_other = document.getElementById('link-iv-other');
|
||||
if (elem_iv_other) {
|
||||
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
|
||||
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
|
||||
}
|
||||
|
||||
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
|
||||
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url');
|
||||
|
||||
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
|
||||
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ function add_playlist_video(target) {
|
|||
var select = target.parentNode.children[0].children[1];
|
||||
var option = select.children[select.selectedIndex];
|
||||
|
||||
var url = '/playlist_ajax?action=add_video&redirect=false' +
|
||||
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
|
||||
'&video_id=' + target.getAttribute('data-id') +
|
||||
'&playlist_id=' + option.getAttribute('data-plid');
|
||||
|
||||
|
@ -21,7 +21,7 @@ function add_playlist_item(target) {
|
|||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||
tile.style.display = 'none';
|
||||
|
||||
var url = '/playlist_ajax?action=add_video&redirect=false' +
|
||||
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
|
||||
'&video_id=' + target.getAttribute('data-id') +
|
||||
'&playlist_id=' + target.getAttribute('data-plid');
|
||||
|
||||
|
@ -36,7 +36,7 @@ function remove_playlist_item(target) {
|
|||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||
tile.style.display = 'none';
|
||||
|
||||
var url = '/playlist_ajax?action=remove_video&redirect=false' +
|
||||
var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
|
||||
'&set_video_id=' + target.getAttribute('data-index') +
|
||||
'&playlist_id=' + target.getAttribute('data-plid');
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ function subscribe() {
|
|||
subscribe_button.onclick = unsubscribe;
|
||||
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
||||
|
||||
var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' +
|
||||
var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
|
||||
'&c=' + subscribe_data.ucid;
|
||||
|
||||
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
|
||||
|
@ -32,7 +32,7 @@ function unsubscribe() {
|
|||
subscribe_button.onclick = subscribe;
|
||||
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
|
||||
|
||||
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
|
||||
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
|
||||
'&c=' + subscribe_data.ucid;
|
||||
|
||||
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
|
||||
|
|
|
@ -67,10 +67,6 @@ function get_playlist(plid) {
|
|||
'&format=html&hl=' + video_data.preferences.locale;
|
||||
}
|
||||
|
||||
if (video_data.params.listen) {
|
||||
plid_url += '&listen=1'
|
||||
}
|
||||
|
||||
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
|
||||
on200: function (response) {
|
||||
playlist.innerHTML = response.playlistHtml;
|
||||
|
|
|
@ -6,7 +6,7 @@ function mark_watched(target) {
|
|||
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||
tile.style.display = 'none';
|
||||
|
||||
var url = '/watch_ajax?action=mark_watched&redirect=false' +
|
||||
var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
|
||||
'&id=' + target.getAttribute('data-id');
|
||||
|
||||
helpers.xhr('POST', url, {payload: payload}, {
|
||||
|
@ -22,7 +22,7 @@ function mark_unwatched(target) {
|
|||
var count = document.getElementById('count');
|
||||
count.textContent--;
|
||||
|
||||
var url = '/watch_ajax?action=mark_unwatched&redirect=false' +
|
||||
var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
|
||||
'&id=' + target.getAttribute('data-id');
|
||||
|
||||
helpers.xhr('POST', url, {payload: payload}, {
|
||||
|
|
|
@ -177,20 +177,6 @@ https_only: false
|
|||
##
|
||||
#hsts: true
|
||||
|
||||
##
|
||||
## Path and permissions of a UNIX socket to listen on for incoming connections.
|
||||
##
|
||||
## Note: Enabling socket will make invidious stop listening on the address
|
||||
## specified by 'host_binding' and 'port'.
|
||||
##
|
||||
## Accepted values: Any path to a new file (that doesn't exist yet) and its
|
||||
## permissions following the UNIX octal convention.
|
||||
## Default: <none>
|
||||
##
|
||||
#socket_binding:
|
||||
# path: /tmp/invidious.sock
|
||||
# permissions: 777
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Network (outbound)
|
||||
|
@ -239,11 +225,11 @@ https_only: false
|
|||
##
|
||||
## If unset, then no HTTP proxy will be used.
|
||||
##
|
||||
#http_proxy:
|
||||
# user:
|
||||
# password:
|
||||
# host:
|
||||
# port:
|
||||
http_proxy:
|
||||
user:
|
||||
password:
|
||||
host:
|
||||
port:
|
||||
|
||||
|
||||
##
|
||||
|
@ -1072,7 +1058,8 @@ default_user_preferences:
|
|||
##
|
||||
#extend_desc: false
|
||||
|
||||
# redis_url: redis://127.0.0.1:6379/0?initial_pool_size=1&max_pool_size=10&checkout_timeout=10&retry_attempts=2&retry_delay=0.5&max_idle_pool_size=50
|
||||
# redis_url: 127.0.0.1:6379
|
||||
# redis_socket: /var/run/valkey/valkey.sock
|
||||
# donation_url: "https://example.com/donate"
|
||||
# contact_url: "https://example.com/contact"
|
||||
# home_domain: "https://example.com/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM mirror.gcr.io/crystallang/crystal:1.16.0-alpine AS builder
|
||||
FROM mirror.gcr.io/crystallang/crystal:1.14.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache sqlite-static yaml-static
|
||||
|
||||
|
@ -7,7 +7,6 @@ ARG release
|
|||
WORKDIR /invidious
|
||||
COPY ./shard.yml ./shard.yml
|
||||
COPY ./shard.lock ./shard.lock
|
||||
|
||||
RUN shards install --production
|
||||
|
||||
COPY ./src/ ./src/
|
||||
|
@ -20,11 +19,18 @@ COPY ./scripts/ ./scripts/
|
|||
COPY ./assets/ ./assets/
|
||||
COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/crystal \
|
||||
RUN crystal spec --warnings all \
|
||||
--link-flags "-lxml2 -llzma"
|
||||
RUN if [[ "${release}" == 1 ]] ; then \
|
||||
crystal build ./src/invidious.cr \
|
||||
--release --mcpu=x86-64-v2 \
|
||||
--static --warnings all \
|
||||
--link-flags "-lxml2 -llzma";
|
||||
--link-flags "-lxml2 -llzma"; \
|
||||
else \
|
||||
crystal build ./src/invidious.cr \
|
||||
--static --warnings all \
|
||||
--link-flags "-lxml2 -llzma"; \
|
||||
fi
|
||||
|
||||
FROM mirror.gcr.io/alpine:3.20
|
||||
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
|
||||
|
|
|
@ -22,7 +22,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
|
|||
RUN crystal spec --warnings all \
|
||||
--link-flags "-lxml2 -llzma"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
|
||||
RUN if [[ "${release}" == 1 ]] ; then \
|
||||
crystal build ./src/invidious.cr \
|
||||
--release \
|
||||
--static --warnings all \
|
||||
|
|
|
@ -559,12 +559,10 @@
|
|||
"toggle_theme": "تبديل الموضوع",
|
||||
"Add to playlist": "أضف إلى قائمة التشغيل",
|
||||
"Add to playlist: ": "أضف إلى قائمة التشغيل: ",
|
||||
"Answer": "اجابة",
|
||||
"Answer": "الرد",
|
||||
"Search for videos": "ابحث عن مقاطع الفيديو",
|
||||
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
|
||||
"carousel_slide": "الشريحة {{current}} من {{total}}",
|
||||
"carousel_skip": "تخطي الكاروسيل",
|
||||
"carousel_go_to": "انتقل إلى الشريحة `x`",
|
||||
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
|
||||
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
|
||||
"carousel_go_to": "انتقل إلى الشريحة `x`"
|
||||
}
|
||||
|
|
|
@ -137,7 +137,7 @@
|
|||
"Family friendly? ": "Vhodné pro rodiny? ",
|
||||
"Engagement: ": "Zapojení: ",
|
||||
"English": "Angličtina",
|
||||
"English (auto-generated)": "Angličtina (vytvořeno automaticky)",
|
||||
"English (auto-generated)": "Angličtina (automaticky generováno)",
|
||||
"Afrikaans": "Afrikánština",
|
||||
"Albanian": "Albánština",
|
||||
"Amharic": "Amharština",
|
||||
|
@ -294,8 +294,8 @@
|
|||
"Chinese (China)": "Čínština (Čína)",
|
||||
"Chinese (Hong Kong)": "Čínština (Hong Kong)",
|
||||
"Chinese (Taiwan)": "Čínština (Taiwan)",
|
||||
"Portuguese (auto-generated)": "Portugalština (vytvořeno automaticky)",
|
||||
"Spanish (auto-generated)": "Španělština (vytvořeno automaticky)",
|
||||
"Portuguese (auto-generated)": "Portugalština (automaticky generováno)",
|
||||
"Spanish (auto-generated)": "Španělština (automaticky generováno)",
|
||||
"Spanish (Mexico)": "Španělština (Mexiko)",
|
||||
"Spanish (Spain)": "Španělština (Španělsko)",
|
||||
"generic_count_years_0": "{{count}} rokem",
|
||||
|
@ -352,13 +352,13 @@
|
|||
"comments_points_count_0": "{{count}} bod",
|
||||
"comments_points_count_1": "{{count}} body",
|
||||
"comments_points_count_2": "{{count}} bodů",
|
||||
"German (auto-generated)": "Němčina (vytvořeno automaticky)",
|
||||
"Indonesian (auto-generated)": "Indonéština (vytvořeno automaticky)",
|
||||
"German (auto-generated)": "Němčina (automaticky generováno)",
|
||||
"Indonesian (auto-generated)": "Indonéština (automaticky generováno)",
|
||||
"Interlingue": "Interlingue",
|
||||
"Italian (auto-generated)": "Italština (vytvořeno automaticky)",
|
||||
"Japanese (auto-generated)": "Japonština (vytvořeno automaticky)",
|
||||
"Korean (auto-generated)": "Korejština (vytvořeno automaticky)",
|
||||
"Russian (auto-generated)": "Ruština (vytvořeno automaticky)",
|
||||
"Italian (auto-generated)": "Italština (automaticky generováno)",
|
||||
"Japanese (auto-generated)": "Japonština (automaticky generováno)",
|
||||
"Korean (auto-generated)": "Korejština (automaticky generováno)",
|
||||
"Russian (auto-generated)": "Ruština (automaticky generováno)",
|
||||
"generic_count_months_0": "{{count}} měsícem",
|
||||
"generic_count_months_1": "{{count}} měsíci",
|
||||
"generic_count_months_2": "{{count}} měsíci",
|
||||
|
@ -371,7 +371,7 @@
|
|||
"footer_documentation": "Dokumentace",
|
||||
"next_steps_error_message_refresh": "Obnovit stránku",
|
||||
"Chinese": "Čínština",
|
||||
"Dutch (auto-generated)": "Nizozemština (vytvořeno automaticky)",
|
||||
"Dutch (auto-generated)": "Nizozemština (automaticky generováno)",
|
||||
"Erroneous token": "Chybný token",
|
||||
"tokens_count_0": "{{count}} token",
|
||||
"tokens_count_1": "{{count}} tokeny",
|
||||
|
@ -380,9 +380,9 @@
|
|||
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
|
||||
"English (United States)": "Angličtina (Spojené státy)",
|
||||
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
|
||||
"French (auto-generated)": "Francouzština (vytvořeno automaticky)",
|
||||
"Turkish (auto-generated)": "Turečtina (vytvořeno automaticky)",
|
||||
"Vietnamese (auto-generated)": "Vietnamština (vytvořeno automaticky)",
|
||||
"French (auto-generated)": "Francouzština (automaticky generováno)",
|
||||
"Turkish (auto-generated)": "Turečtina (automaticky generováno)",
|
||||
"Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)",
|
||||
"Current version: ": "Aktuální verze: ",
|
||||
"next_steps_error_message": "Měli byste zkusit: ",
|
||||
"footer_donate_page": "Přispět",
|
||||
|
@ -513,7 +513,5 @@
|
|||
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
|
||||
"carousel_slide": "Snímek {{current}} z {{total}}",
|
||||
"carousel_skip": "Přeskočit galerii",
|
||||
"carousel_go_to": "Přejít na snímek `x`",
|
||||
"preferences_preload_label": "Předem načíst data videa: ",
|
||||
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
|
||||
"carousel_go_to": "Přejít na snímek `x`"
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"last": "neueste",
|
||||
"Next page": "Nächste Seite",
|
||||
"Previous page": "Vorherige Seite",
|
||||
"First page": "Erste Seite",
|
||||
"Clear watch history?": "Verlauf löschen?",
|
||||
"New password": "Neues Passwort",
|
||||
"New passwords must match": "Neue Passwörter müssen übereinstimmen",
|
||||
|
@ -491,13 +490,12 @@
|
|||
"generic_channels_count_plural": "{{count}} Kanäle",
|
||||
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
|
||||
"Answer": "Antwort",
|
||||
"The Popular feed has been disabled by the administrator.": "Der Feed für beliebte Inhalte wurde vom Administrator deaktiviert.",
|
||||
"The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.",
|
||||
"Add to playlist": "Einer Wiedergabeliste hinzufügen",
|
||||
"Search for videos": "Nach Videos suchen",
|
||||
"toggle_theme": "Thema wechseln",
|
||||
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
|
||||
"carousel_go_to": "Zu Element `x` springen",
|
||||
"carousel_slide": "Seite {{current}} von {{total}}",
|
||||
"carousel_skip": "Galerie überspringen",
|
||||
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
|
||||
"carousel_go_to": "Zu Folie `x` gehen",
|
||||
"carousel_slide": "Folie {{current}} von {{total}}",
|
||||
"carousel_skip": "Karussell überspringen"
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
|
||||
"Import": "Εισαγωγή",
|
||||
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
|
||||
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube απο CVS/OPML",
|
||||
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
|
||||
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
|
||||
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
|
||||
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
|
||||
|
@ -455,7 +455,7 @@
|
|||
"channel_tab_streams_label": "Ζωντανή μετάδοση",
|
||||
"playlist_button_add_items": "Προσθήκη βίντεο",
|
||||
"Artist: ": "Καλλιτέχνης: ",
|
||||
"search_message_use_another_instance": "Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
|
||||
"search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
|
||||
"generic_button_save": "Αποθήκευση",
|
||||
"generic_button_cancel": "Ακύρωση",
|
||||
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
|
||||
|
@ -490,13 +490,9 @@
|
|||
"Search for videos": "Αναζήτηση βίντεο",
|
||||
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
|
||||
"Answer": "Απάντηση",
|
||||
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής",
|
||||
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ",
|
||||
"Add to playlist": "Λίιστα αναπαραγωγής",
|
||||
"Add to playlist: ": "Λίστα αναπαραγωγής: ",
|
||||
"carousel_slide": "Εικόνα {{current}}απο {{total}}",
|
||||
"carousel_go_to": "Πήγαινε στην εικόνα`x`",
|
||||
"toggle_theme": "Αλλαγή θέματος",
|
||||
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
|
||||
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
|
||||
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
|
||||
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
|
||||
"toggle_theme": "Αλλαγή θέματος"
|
||||
}
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
"last": "last",
|
||||
"Next page": "Next page",
|
||||
"Previous page": "Previous page",
|
||||
"First page": "First page",
|
||||
"Clear watch history?": "Clear watch history?",
|
||||
"New password": "New password",
|
||||
"New passwords must match": "New passwords must match",
|
||||
|
@ -513,21 +512,13 @@
|
|||
"channel_tab_streams_label": "Livestreams",
|
||||
"channel_tab_podcasts_label": "Podcasts",
|
||||
"channel_tab_releases_label": "Releases",
|
||||
"channel_tab_courses_label": "Courses",
|
||||
"channel_tab_playlists_label": "Playlists",
|
||||
"channel_tab_community_label": "Community",
|
||||
"channel_tab_posts_label": "Posts",
|
||||
"channel_tab_channels_label": "Channels",
|
||||
"toggle_theme": "Toggle Theme",
|
||||
"carousel_slide": "Slide {{current}} of {{total}}",
|
||||
"carousel_skip": "Skip the Carousel",
|
||||
"carousel_go_to": "Go to slide `x`",
|
||||
"footer_contact_url": "Contact the Administrator",
|
||||
"new_username": "New username",
|
||||
"change_username": "Change username",
|
||||
"username_required_field": "Username is a required field",
|
||||
"username_empty": "Username cannot be empty",
|
||||
"username_is_the_same": "This is your username, use another one",
|
||||
"username_taken": "Username is already taken, use another one",
|
||||
"backend_unavailable": "The backend you selected is unavailable. You have been redirected to the next one"
|
||||
"footer_contact_url": "Contact the Administrator"
|
||||
|
||||
}
|
||||
|
|
|
@ -516,14 +516,5 @@
|
|||
"carousel_slide": "Diapositiva {{current}} de {{total}}",
|
||||
"carousel_skip": "Saltar el carrusel",
|
||||
"carousel_go_to": "Ir a la diapositiva `x`",
|
||||
"footer_contact_url": "Contactar al Administrador",
|
||||
"preferences_preload_label": "Precargar datos del vídeo: ",
|
||||
"Filipino (auto-generated)": "Filipino (generado automáticamente)",
|
||||
"new_username": "Nuevo nombre de usuario",
|
||||
"change_username": "Cambiar nombre de usuario",
|
||||
"username_required_field": "El nombre de usuario es un campo obligatorio",
|
||||
"username_empty": "El nombre de usuario no puede estar vacío",
|
||||
"username_is_the_same": "Este es tu nombre de usuario, usa otro",
|
||||
"username_taken": "El nombre de usuario ya está en uso, usa otro",
|
||||
"backend_unavailable": "El backend seleccionado no está disponible. Has sido redireccionado al siguiente"
|
||||
"footer_contact_url": "Contactar al Administrador"
|
||||
}
|
||||
|
|
|
@ -496,6 +496,5 @@
|
|||
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
|
||||
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
|
||||
"channel_tab_releases_label": "آثار",
|
||||
"toggle_theme": "تغییر وضعیت تم",
|
||||
"preferences_preload_label": "پیش بار کردن دادههای ویدیو: "
|
||||
"toggle_theme": "تغییر وضعیت تم"
|
||||
}
|
||||
|
|
|
@ -460,7 +460,7 @@
|
|||
"search_filters_apply_button": "Ota valitut suodattimet käyttöön",
|
||||
"search_filters_date_label": "Latausaika",
|
||||
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
|
||||
"search_message_use_another_instance": "Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
|
||||
"search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
|
||||
"search_filters_date_option_none": "Milloin tahansa",
|
||||
"search_filters_type_option_all": "Mikä tahansa tyyppi",
|
||||
"Popular enabled: ": "Suosittu käytössä: ",
|
||||
|
@ -496,6 +496,5 @@
|
|||
"generic_channels_count_plural": "{{count}} kanavaa",
|
||||
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
|
||||
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
|
||||
"toggle_theme": "Vaihda teemaa",
|
||||
"preferences_preload_label": "Esilataa video data. "
|
||||
"toggle_theme": "Vaihda teemaa"
|
||||
}
|
||||
|
|
|
@ -505,7 +505,7 @@
|
|||
"channel_tab_releases_label": "Parutions",
|
||||
"channel_tab_podcasts_label": "Émissions audio",
|
||||
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
|
||||
"Add to playlist: ": "Ajouter à la playlist : ",
|
||||
"Add to playlist: ": "Ajouter à la playlist : ",
|
||||
"Add to playlist": "Ajouter à la playlist",
|
||||
"Answer": "Répondre",
|
||||
"Search for videos": "Rechercher des vidéos",
|
||||
|
@ -513,7 +513,5 @@
|
|||
"carousel_skip": "Passez le carrousel",
|
||||
"carousel_slide": "Diapositive {{current}} sur {{total}}",
|
||||
"carousel_go_to": "Aller à la diapositive `x`",
|
||||
"toggle_theme": "Changer le Thème",
|
||||
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
|
||||
"preferences_preload_label": "Précharger les données de la vidéo : "
|
||||
"toggle_theme": "Changer le Thème"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"toggle_theme": "Uklj./Isklj. temu",
|
||||
"carousel_slide": "Kadar {{current}} od {{total}}",
|
||||
"carousel_go_to": "Idi na kadar `x`",
|
||||
"carousel_skip": "Preskoči vrtuljak",
|
||||
"Filipino (auto-generated)": "Filipinski (automatski generirano)",
|
||||
"preferences_preload_label": "Unaprijed učitaj podatke videa: "
|
||||
"carousel_skip": "Preskoči vrtuljak"
|
||||
}
|
||||
|
|
|
@ -496,7 +496,5 @@
|
|||
"footer_documentation": "Leiðbeiningar",
|
||||
"channel_tab_channels_label": "Rásir",
|
||||
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
|
||||
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)",
|
||||
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
|
||||
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
|
||||
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
|
||||
}
|
||||
|
|
|
@ -469,8 +469,8 @@
|
|||
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
|
||||
"Spanish (Mexico)": "Spagnolo (Messico)",
|
||||
"Spanish (Spain)": "Spagnolo (Spagna)",
|
||||
"Turkish (auto-generated)": "Turco (generati automaticamente)",
|
||||
"Vietnamese (auto-generated)": "Vietnamita (generati automaticamente)",
|
||||
"Turkish (auto-generated)": "Turco (auto-generato)",
|
||||
"Vietnamese (auto-generated)": "Vietnamita (auto-generato)",
|
||||
"search_filters_date_label": "Data caricamento",
|
||||
"search_filters_date_option_none": "Qualunque data",
|
||||
"search_filters_type_option_all": "Qualunque tipo",
|
||||
|
@ -513,7 +513,5 @@
|
|||
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
|
||||
"carousel_slide": "Fotogramma {{current}} di {{total}}",
|
||||
"carousel_skip": "Salta la galleria",
|
||||
"carousel_go_to": "Vai al fotogramma `x`",
|
||||
"preferences_preload_label": "Precarica dati video: ",
|
||||
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
|
||||
"carousel_go_to": "Vai al fotogramma `x`"
|
||||
}
|
||||
|
|
|
@ -479,7 +479,5 @@
|
|||
"carousel_go_to": "スライド`x`を表示",
|
||||
"carousel_slide": "スライド{{current}} / 全{{total}}個中",
|
||||
"carousel_skip": "画像のスライド表示をスキップ",
|
||||
"toggle_theme": "テーマの切り替え",
|
||||
"preferences_preload_label": "動画データを事前に読み込む: ",
|
||||
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
|
||||
"toggle_theme": "テーマの切り替え"
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
"Next page": "다음 페이지",
|
||||
"last": "마지막",
|
||||
"Shared `x` ago": "`x` 전",
|
||||
"popular": "인기순",
|
||||
"popular": "인기",
|
||||
"oldest": "과거순",
|
||||
"newest": "최신순",
|
||||
"View playlist on YouTube": "유튜브에서 재생목록 보기",
|
||||
|
@ -479,6 +479,5 @@
|
|||
"carousel_go_to": "`x` 슬라이드로 이동",
|
||||
"Search for videos": "비디오 검색",
|
||||
"toggle_theme": "테마 전환",
|
||||
"carousel_slide": "{{total}}의 슬라이드 {{current}}",
|
||||
"preferences_preload_label": "비디오 데이터 사전 로드: "
|
||||
"carousel_slide": "{{total}}의 슬라이드 {{current}}"
|
||||
}
|
||||
|
|
|
@ -496,6 +496,5 @@
|
|||
"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",
|
||||
"preferences_preload_label": "Last videodata på forhånd: "
|
||||
"toggle_theme": "Endre utseende"
|
||||
}
|
||||
|
|
|
@ -496,7 +496,5 @@
|
|||
"Answer": "Antwoorden",
|
||||
"Search for videos": "Naar video's zoeken",
|
||||
"carousel_skip": "Carousel overslaan",
|
||||
"toggle_theme": "Thema omschakelen",
|
||||
"preferences_preload_label": "Videogegevens vooraf laden: ",
|
||||
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
|
||||
"toggle_theme": "Thema omschakelen"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"Add to playlist: ": "Dodaj do playlisty: ",
|
||||
"carousel_slide": "Slajd {{current}} z {{total}}",
|
||||
"carousel_skip": "Pomiń karuzelę",
|
||||
"carousel_go_to": "Przejdź do slajdu `x`",
|
||||
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
|
||||
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
|
||||
"carousel_go_to": "Przejdź do slajdu `x`"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"Answer": "Resposta",
|
||||
"carousel_slide": "Slide {{current}} de {{total}}",
|
||||
"carousel_skip": "Ignorar carrossel",
|
||||
"carousel_go_to": "Ir ao slide `x`",
|
||||
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||
"carousel_go_to": "Ir ao slide `x`"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"carousel_slide": "Diapositivo {{current}} de{{total}}",
|
||||
"carousel_skip": "Ignorar carrossel",
|
||||
"carousel_go_to": "Ir para o diapositivo`x`",
|
||||
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.",
|
||||
"preferences_preload_label": "Pré-carregamento dos dados: ",
|
||||
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
|
||||
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"last": "последние",
|
||||
"Next page": "Следующая страница",
|
||||
"Previous page": "Предыдущая страница",
|
||||
"First page": "Первая страница",
|
||||
"Clear watch history?": "Очистить историю просмотров?",
|
||||
"New password": "Новый пароль",
|
||||
"New passwords must match": "Новые пароли не совпадают",
|
||||
|
@ -49,8 +48,8 @@
|
|||
"preferences_category_player": "Настройки проигрывателя",
|
||||
"preferences_video_loop_label": "Всегда повторять: ",
|
||||
"preferences_autoplay_label": "Автовоспроизведение: ",
|
||||
"preferences_continue_label": "Воспроизводить следующее видео: ",
|
||||
"preferences_continue_autoplay_label": "Автовоспроизведение следующего видео: ",
|
||||
"preferences_continue_label": "Переходить к следующему видео? ",
|
||||
"preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
|
||||
"preferences_listen_label": "Режим «только аудио» по умолчанию: ",
|
||||
"preferences_local_label": "Проигрывать видео через прокси? ",
|
||||
"preferences_speed_label": "Скорость видео по умолчанию: ",
|
||||
|
@ -514,6 +513,5 @@
|
|||
"toggle_theme": "Переключатель тем",
|
||||
"carousel_slide": "Пролистано {{current}} из {{total}}",
|
||||
"carousel_skip": "Пропустить всё",
|
||||
"carousel_go_to": "Перейти к странице `x`",
|
||||
"preferences_preload_label": "Предзагрузка видеоданных: "
|
||||
"carousel_go_to": "Перейти к странице `x`"
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"Import and Export Data": "Uvoz in izvoz podatkov",
|
||||
"Import": "Uvozi",
|
||||
"Import Invidious data": "Uvozi Invidious JSON podatke",
|
||||
"Import YouTube subscriptions": "Uvozi YouTube CSV ali OPML naročnine",
|
||||
"Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine",
|
||||
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
|
||||
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
|
||||
"Export": "Izvozi",
|
||||
|
@ -105,7 +105,7 @@
|
|||
"Show more": "Pokaži več",
|
||||
"Switch Invidious Instance": "Preklopi Invidious instanco",
|
||||
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
|
||||
"search_message_use_another_instance": "Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
|
||||
"search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
|
||||
"Wilson score: ": "Wilsonov rezultat: ",
|
||||
"Engagement: ": "Sodelovanje: ",
|
||||
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
|
||||
|
@ -462,7 +462,7 @@
|
|||
"search_filters_features_option_four_k": "4K",
|
||||
"search_filters_features_option_hdr": "HDR",
|
||||
"next_steps_error_message_refresh": "Osveži",
|
||||
"search_filters_date_option_hour": "V zadnji uri",
|
||||
"search_filters_date_option_hour": "Zadnja ura",
|
||||
"search_filters_features_option_purchased": "Kupljeno",
|
||||
"search_filters_sort_label": "Razvrsti po",
|
||||
"search_filters_sort_option_views": "številu ogledov",
|
||||
|
@ -521,16 +521,5 @@
|
|||
"generic_channels_count_1": "{{count}} kanala",
|
||||
"generic_channels_count_2": "{{count}} kanali",
|
||||
"generic_channels_count_3": "{{count}} kanalov",
|
||||
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)",
|
||||
"Add to playlist": "Dodaj na seznam predvajanja",
|
||||
"Add to playlist: ": "Dodaj na seznam predvajanja: ",
|
||||
"Search for videos": "Iskanje videoposnetkov",
|
||||
"The Popular feed has been disabled by the administrator.": "Administrator je onemogočil priljubljeni vir.",
|
||||
"Answer": "Odgovor",
|
||||
"Filipino (auto-generated)": "filipinščina (samodejno ustvarjeno)",
|
||||
"toggle_theme": "Preklopi temo",
|
||||
"carousel_slide": "Diapozitiv {{current}} od {{total}}",
|
||||
"carousel_skip": "Preskoči galerijo",
|
||||
"carousel_go_to": "Pojdi na diapozitiv `x`",
|
||||
"preferences_preload_label": "Predhodno naloži video podatke: "
|
||||
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
|
||||
}
|
||||
|
|
|
@ -492,7 +492,5 @@
|
|||
"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`",
|
||||
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
|
||||
"preferences_preload_label": "Parangarko të dhëna videoje: "
|
||||
"carousel_go_to": "Kalo te diapozitivi `x`"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"Answer": "Odgovor",
|
||||
"Search for videos": "Pretražite video snimke",
|
||||
"carousel_skip": "Preskoči karusel",
|
||||
"toggle_theme": "Подеси тему",
|
||||
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
|
||||
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
|
||||
"toggle_theme": "Подеси тему"
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"Add to playlist: ": "Додајте на плејлисту: ",
|
||||
"carousel_skip": "Прескочи карусел",
|
||||
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
|
||||
"carousel_slide": "Слајд {{current}} од {{total}}",
|
||||
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
|
||||
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
|
||||
"carousel_slide": "Слајд {{current}} од {{total}}"
|
||||
}
|
||||
|
|
|
@ -496,7 +496,5 @@
|
|||
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
|
||||
"carousel_slide": "Bildspel {{current}} av {{total}}",
|
||||
"carousel_skip": "Hoppa över karusellen",
|
||||
"carousel_go_to": "Gå till bildspel `x`",
|
||||
"preferences_preload_label": "Förladda video data: ",
|
||||
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
|
||||
"carousel_go_to": "Gå till bildspel `x`"
|
||||
}
|
||||
|
|
502
locales/ta.json
502
locales/ta.json
|
@ -1,502 +0,0 @@
|
|||
{
|
||||
"Add to playlist": "பிளேலிச்ட்டில் சேர்க்கவும்",
|
||||
"generic_channels_count": "{{count}} சேனல்",
|
||||
"generic_channels_count_plural": "{{count}} சேனல்கள்",
|
||||
"generic_views_count": "{{count}} பார்வை",
|
||||
"generic_views_count_plural": "{{count}} காட்சிகள்",
|
||||
"generic_videos_count": "{{count}} வீடியோ",
|
||||
"generic_videos_count_plural": "{{count}} வீடியோக்கள்",
|
||||
"generic_playlists_count": "{{count}} பிளேலிச்ட்",
|
||||
"generic_playlists_count_plural": "{{count}} பிளேலிச்ட்கள்",
|
||||
"generic_subscribers_count": "{{count}} சந்தாதாரர்",
|
||||
"generic_subscribers_count_plural": "{{count}} சந்தாதாரர்கள்",
|
||||
"generic_button_delete": "நீக்கு",
|
||||
"generic_button_rss": "ஆர்.எச்.எச்",
|
||||
"LIVE": "வாழ",
|
||||
"Shared `x` ago": "`X` முன்பு பகிரப்பட்டது",
|
||||
"Unsubscribe": "குழுவிலகவும்",
|
||||
"View playlist on YouTube": "யூடியூப்பில் பிளேலிச்ட்டைக் காண்க",
|
||||
"newest": "புதியது",
|
||||
"oldest": "பழமையானது",
|
||||
"popular": "மக்கள்",
|
||||
"last": "கடைசி",
|
||||
"Next page": "அடுத்த பக்கம்",
|
||||
"Previous page": "முந்தைய பக்கம்",
|
||||
"Clear watch history?": "தெளிவான கண்காணிப்பு வரலாறு?",
|
||||
"New password": "புதிய கடவுச்சொல்",
|
||||
"New passwords must match": "புதிய கடவுச்சொற்கள் பொருந்த வேண்டும்",
|
||||
"Authorize token?": "கிள்ளாக்கை அங்கீகரிக்கவா?",
|
||||
"Yes": "ஆம்",
|
||||
"Import YouTube playlist (.csv)": "யூடியூப் பிளேலிச்ட்டை இறக்குமதி செய்க (.csv)",
|
||||
"Import YouTube watch history (.json)": "YouTube வாட்ச் வரலாற்றை இறக்குமதி செய்க (.json)",
|
||||
"Import Invidious data": "வன்கவர்வு சாதொபொகு தரவை இறக்குமதி செய்க",
|
||||
"Import YouTube subscriptions": "YouTube காபிம அல்லது OPML சந்தாக்களை இறக்குமதி செய்க",
|
||||
"Import FreeTube subscriptions (.db)": "ஃப்ரீட்யூப் சந்தாக்களை இறக்குமதி செய்க (.db)",
|
||||
"Import NewPipe data (.zip)": "நியூபைப் தரவை இறக்குமதி செய்க (.zip)",
|
||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள் (நியூபைப் & ஃப்ரீட்யூப்பிற்கு)",
|
||||
"Export subscriptions as OPML": "OPML ஆக சந்தாக்களை ஏற்றுமதி செய்யுங்கள்",
|
||||
"Export data as JSON": "சாதொபொகு ஆக வன்கவர்வு தரவை ஏற்றுமதி செய்யுங்கள்",
|
||||
"Delete account?": "கணக்கை நீக்கவா?",
|
||||
"History": "வரலாறு",
|
||||
"JavaScript license information": "சாவாச்கிரிப்ட் உரிம செய்தி",
|
||||
"source": "மூலம்",
|
||||
"An alternative front-end to YouTube": "YouTube க்கு ஒரு மாற்று முன் இறுதியில்",
|
||||
"Log in": "புகுபதிகை",
|
||||
"Log in/register": "உள்நுழைக/பதிவு செய்யுங்கள்",
|
||||
"User ID": "பயனர் ஐடி",
|
||||
"Password": "கடவுச்சொல்",
|
||||
"Time (h:mm:ss):": "நேரம் (h: மிமீ: எச்எச்):",
|
||||
"Sign In": "விடுபதிகை",
|
||||
"Register": "பதிவு செய்யுங்கள்",
|
||||
"E-mail": "மின்னஞ்சல்",
|
||||
"Preferences": "விருப்பத்தேர்வுகள்",
|
||||
"preferences_preload_label": "வீடியோ தரவை முன்பே ஏற்றவும்: ",
|
||||
"preferences_autoplay_label": "தன்னியக்க: ",
|
||||
"preferences_continue_label": "இயல்பாக அடுத்து விளையாடுங்கள்: ",
|
||||
"preferences_local_label": "பதிலாள் வீடியோக்கள்: ",
|
||||
"preferences_watch_history_label": "கண்காணிப்பு வரலாற்றை இயக்கு: ",
|
||||
"preferences_speed_label": "இயல்புநிலை வேகம்: ",
|
||||
"preferences_quality_label": "விருப்பமான வீடியோ தரம்: ",
|
||||
"preferences_quality_dash_label": "விருப்பமான கோடு வீடியோ தரம்: ",
|
||||
"preferences_quality_dash_option_auto": "தானி",
|
||||
"preferences_quality_dash_option_best": "சிறந்த",
|
||||
"preferences_quality_dash_option_worst": "மோசமான",
|
||||
"preferences_quality_dash_option_4320p": "4320 ப",
|
||||
"preferences_quality_dash_option_1080p": "1080 ப",
|
||||
"preferences_quality_dash_option_720p": "720 ஆ",
|
||||
"preferences_quality_dash_option_480p": "480 ப",
|
||||
"preferences_quality_dash_option_360p": "360 ப",
|
||||
"preferences_quality_dash_option_144p": "144 ப",
|
||||
"preferences_volume_label": "பிளேயர் தொகுதி: ",
|
||||
"preferences_comments_label": "இயல்புநிலை கருத்துகள்: ",
|
||||
"Fallback captions: ": "குறைவடையும் தலைப்புகள்: ",
|
||||
"preferences_captions_label": "இயல்புநிலை தலைப்புகள்: ",
|
||||
"preferences_related_videos_label": "தொடர்புடைய வீடியோக்களைக் காட்டு: ",
|
||||
"preferences_annotations_label": "முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டு: ",
|
||||
"preferences_vr_mode_label": "ஊடாடும் 360 டிகிரி வீடியோக்கள் (வெப்சிஎல் தேவை): ",
|
||||
"preferences_category_visual": "காட்சி விருப்பத்தேர்வுகள்",
|
||||
"light": "ஒளி",
|
||||
"preferences_thin_mode_label": "மெல்லிய பயன்முறை: ",
|
||||
"preferences_category_misc": "இதர விருப்பத்தேர்வுகள்",
|
||||
"preferences_category_subscription": "சந்தா விருப்பத்தேர்வுகள்",
|
||||
"preferences_annotations_subscribed_label": "சந்தா சேனல்களுக்கு முன்னிருப்பாக சிறுகுறிப்புகளைக் காட்டவா? ",
|
||||
"Redirect homepage to feed: ": "உணவளிக்க முகப்புப்பக்கத்தை திருப்பி விடுங்கள்: ",
|
||||
"preferences_sort_label": "வீடியோக்களை வரிசைப்படுத்துங்கள்: ",
|
||||
"published": "வெளியிடப்பட்டது",
|
||||
"published - reverse": "வெளியிடப்பட்டது - தலைகீழ்",
|
||||
"alphabetically": "அகரவரிசை",
|
||||
"preferences_unseen_only_label": "கவனக்குறைவாக மட்டுமே காட்டுங்கள்: ",
|
||||
"preferences_notifications_only_label": "அறிவிப்புகளைக் காட்டுங்கள் (ஏதேனும் இருந்தால்): ",
|
||||
"Enable web notifications": "வலை அறிவிப்புகளை இயக்கவும்",
|
||||
"`x` is live": "`x` நேரலையில்",
|
||||
"preferences_category_data": "தரவு விருப்பத்தேர்வுகள்",
|
||||
"Manage subscriptions": "சந்தாக்களை நிர்வகிக்கவும்",
|
||||
"Watch history": "வரலாற்றைப் பாருங்கள்",
|
||||
"Delete account": "கணக்கை நீக்கு",
|
||||
"preferences_category_admin": "நிர்வாகி விருப்பத்தேர்வுகள்",
|
||||
"preferences_default_home_label": "இயல்புநிலை முகப்புப்பக்கம்: ",
|
||||
"preferences_feed_menu_label": "ஊட்ட மெனு: ",
|
||||
"preferences_show_nick_label": "மேலே புனைப்பெயரைக் காட்டு: ",
|
||||
"Top enabled: ": "மேலே இயக்கப்பட்டது: ",
|
||||
"CAPTCHA enabled: ": "கேப்ட்சா இயக்கப்பட்டது: ",
|
||||
"Login enabled: ": "உள்நுழைவு இயக்கப்பட்டது: ",
|
||||
"Registration enabled: ": "பதிவு இயக்கப்பட்டது: ",
|
||||
"Report statistics: ": "அறிக்கை புள்ளிவிவரங்கள்: ",
|
||||
"Save preferences": "விருப்பங்களை சேமிக்கவும்",
|
||||
"Subscription manager": "சந்தா மேலாளர்",
|
||||
"Token manager": "கிள்ளாக்கு மேலாளர்",
|
||||
"Token": "கிள்ளாக்கு",
|
||||
"search": "தேடல்",
|
||||
"Released under the AGPLv3 on Github.": "கிட்அப்பில் AgPlv3 இன் கீழ் வெளியிடப்பட்டது.",
|
||||
"View JavaScript license information.": "சாவாச்கிரிப்ட் உரிமத் தகவலைக் காண்க.",
|
||||
"View privacy policy.": "தனியுரிமைக் கொள்கையைக் காண்க.",
|
||||
"Trending": "டிரெண்டிங்",
|
||||
"Public": "பொது",
|
||||
"Unlisted": "பட்டியலிடப்படாதது",
|
||||
"Private": "தனிப்பட்ட",
|
||||
"View all playlists": "அனைத்து பிளேலிச்ட்களையும் காண்க",
|
||||
"Updated `x` ago": "`X` முன்பு புதுப்பிக்கப்பட்டது",
|
||||
"Delete playlist `x`?": "பிளேலிச்ட்டை நீக்கவா?",
|
||||
"Playlist privacy": "பிளேலிச்ட் தனியுரிமை",
|
||||
"Watch on YouTube": "YouTube இல் பாருங்கள்",
|
||||
"Hide annotations": "சிறுகுறிப்புகளை மறைக்கவும்",
|
||||
"Show replies": "பதில்களைக் காட்டு",
|
||||
"Incorrect password": "தவறான கடவுச்சொல்",
|
||||
"Wrong answer": "தவறான பதில்",
|
||||
"Erroneous CAPTCHA": "தவறான கேப்ட்சா",
|
||||
"CAPTCHA is a required field": "கேப்ட்சா ஒரு தேவையான புலம்",
|
||||
"User ID is a required field": "பயனர் ஐடி தேவையான புலம்",
|
||||
"Password is a required field": "கடவுச்சொல் தேவையான புலம்",
|
||||
"Password cannot be empty": "கடவுச்சொல் காலியாக இருக்க முடியாது",
|
||||
"Please log in": "தயவுசெய்து உள்நுழைக",
|
||||
"This channel does not exist.": "இந்த சேனல் இல்லை.",
|
||||
"Could not get channel info.": "சேனல் தகவலைப் பெற முடியவில்லை.",
|
||||
"Could not fetch comments": "கருத்துகளைப் பெற முடியவில்லை",
|
||||
"comments_points_count": "{{count}} புள்ளி",
|
||||
"comments_points_count_plural": "{{count}} புள்ளிகள்",
|
||||
"Could not create mix.": "கலவையை உருவாக்க முடியவில்லை.",
|
||||
"Empty playlist": "வெற்று பிளேலிச்ட்",
|
||||
"Not a playlist.": "ஒரு பிளேலிச்ட் அல்ல.",
|
||||
"Playlist does not exist.": "பிளேலிச்ட் இல்லை.",
|
||||
"Could not pull trending pages.": "பிரபலமான பக்கங்களை இழுக்க முடியவில்லை.",
|
||||
"Erroneous challenge": "தவறான அறைகூவல்",
|
||||
"Erroneous token": "தவறான கிள்ளாக்கு",
|
||||
"No such user": "அத்தகைய பயனர் இல்லை",
|
||||
"Token is expired, please try again": "கிள்ளாக்கு காலாவதியானது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்",
|
||||
"English": "ஆங்கிலம்",
|
||||
"English (United States)": "ஆங்கிலம் (ஐக்கிய அமெரிக்க)",
|
||||
"English (United Kingdom)": "ஆங்கிலம் (ஐக்கிய முடியரசு)",
|
||||
"English (auto-generated)": "ஆங்கிலம் (தானாக உருவாக்கப்பட்ட)",
|
||||
"Afrikaans": "ஆப்பிரிக்கா",
|
||||
"Albanian": "அல்பேனிய",
|
||||
"Amharic": "அம்ஆரிக்",
|
||||
"Arabic": "அரபு",
|
||||
"Armenian": "ஆர்மீனியன்",
|
||||
"Azerbaijani": "அசர்பைசானி",
|
||||
"Bangla": "பாங்லா",
|
||||
"Basque": "பாச்க்",
|
||||
"Belarusian": "பெலாருசியன்",
|
||||
"Bosnian": "போச்னிய",
|
||||
"Bulgarian": "பல்கேரியன்",
|
||||
"Burmese": "பர்மீச்",
|
||||
"Cantonese (Hong Kong)": "கான்டோனீச் (ஆங்காங்)",
|
||||
"Catalan": "கற்றலான்",
|
||||
"Cebuano": "செபுவானோ",
|
||||
"Chinese": "சீன",
|
||||
"Chinese (China)": "சீன (சீனா)",
|
||||
"Chinese (Hong Kong)": "சீன (ஆங்காங்)",
|
||||
"Chinese (Simplified)": "சீன (எளிமைப்படுத்தப்பட்ட)",
|
||||
"Chinese (Taiwan)": "சீன (தைவான்)",
|
||||
"Chinese (Traditional)": "சீன (பாரம்பரிய)",
|
||||
"Dutch": "டச்சு",
|
||||
"Finnish": "பின்னிச்",
|
||||
"French": "பிரஞ்சு",
|
||||
"German (auto-generated)": "செர்மன் (தானாக உருவாக்கப்பட்ட)",
|
||||
"Greek": "கிரேக்கம்",
|
||||
"Gujarati": "குசராத்தி",
|
||||
"Haitian Creole": "ஐட்டிய கிரியோல்",
|
||||
"Hungarian": "அங்கேரியன்",
|
||||
"Icelandic": "ஐச்லாந்திய",
|
||||
"Igbo": "இக்போ",
|
||||
"Korean (auto-generated)": "கொரிய (தானாக உருவாக்கப்பட்ட)",
|
||||
"Macedonian": "மாசிடோனியன்",
|
||||
"Malagasy": "மலகாசி",
|
||||
"Maltese": "மால்டிச்",
|
||||
"Maori": "மௌரி",
|
||||
"Malayalam": "மலையாளம்",
|
||||
"Marathi": "மராத்தி",
|
||||
"Mongolian": "மங்கோலியன்",
|
||||
"Nepali": "நேபாளி",
|
||||
"Norwegian Bokmål": "நார்வேசியன் பொக்மால்",
|
||||
"Nyanja": "நயன்சா",
|
||||
"Russian": "ரச்ய",
|
||||
"Russian (auto-generated)": "ரச்ய (தானாக உருவாக்கப்பட்ட)",
|
||||
"Samoan": "சமோவான்",
|
||||
"Scottish Gaelic": "ச்கோட்டிச் கயாலிக்",
|
||||
"Serbian": "செர்பிய",
|
||||
"Shona": "சோனா",
|
||||
"Sindhi": "சிந்தி",
|
||||
"Somali": "சோமாலி",
|
||||
"Southern Sotho": "தெற்கத்திய சோதோ",
|
||||
"Spanish": "ச்பானிச்",
|
||||
"Spanish (auto-generated)": "ச்பானிச் (தானாக உருவாக்கப்பட்ட)",
|
||||
"Sundanese": "சுந்தானியர்கள்",
|
||||
"Swahili": "ச்வாஇலி",
|
||||
"Swedish": "ச்வீடிச்",
|
||||
"Tajik": "தசிக்",
|
||||
"Tamil": "தமிழ்",
|
||||
"Thai": "தாய்",
|
||||
"Turkish": "துருக்கிய",
|
||||
"Vietnamese": "வியட்நாமிய",
|
||||
"Welsh": "வேல்ச்",
|
||||
"Xhosa": "ஓசா",
|
||||
"Yiddish": "யெட்டிச்",
|
||||
"Yoruba": "யோருபா",
|
||||
"Top": "மேலே",
|
||||
"About": "பற்றி",
|
||||
"View as playlist": "பிளேலிச்ட்டாக காண்க",
|
||||
"Gaming": "கேமிங்",
|
||||
"News": "செய்தி",
|
||||
"Movies": "திரைப்படங்கள்",
|
||||
"Download as: ": "என பதிவிறக்கவும்: ",
|
||||
"Download is disabled": "பதிவிறக்கம் முடக்கப்பட்டுள்ளது",
|
||||
"(edited)": "(திருத்தப்பட்டது)",
|
||||
"YouTube comment permalink": "YouTube கருத்து பெர்மாலின்க்",
|
||||
"`x` marked it with a ❤": "`x` அதை a உடன் குறித்தது",
|
||||
"Video mode": "வீடியோ பயன்முறை",
|
||||
"Playlists": "பிளேலிச்ட்கள்",
|
||||
"search_filters_date_option_today": "இன்று",
|
||||
"search_filters_date_option_week": "இந்த வாரம்",
|
||||
"search_filters_date_option_month": "இந்த மாதம்",
|
||||
"search_filters_type_option_channel": "வாய்க்கால்",
|
||||
"search_filters_type_option_playlist": "பிளேலிச்ட்",
|
||||
"search_filters_duration_label": "காலம்",
|
||||
"search_filters_duration_option_none": "எந்த காலமும்",
|
||||
"search_filters_duration_option_medium": "நடுத்தர (4 - 20 நிமிடங்கள்)",
|
||||
"search_filters_duration_option_long": "நீண்ட (> 20 நிமிடங்கள்)",
|
||||
"search_filters_features_label": "நற்பொருத்தங்கள்",
|
||||
"search_filters_features_option_four_k": "எச்.சி.",
|
||||
"search_filters_features_option_live": "நேரடி",
|
||||
"search_filters_features_option_hd": "எச்டி",
|
||||
"search_filters_features_option_subtitles": "வசன வரிகள்/சிசி",
|
||||
"search_filters_features_option_c_commons": "கிரியேட்டிவ் காமன்ச்",
|
||||
"search_filters_features_option_three_sixty": "360 °",
|
||||
"search_filters_features_option_three_d": "ZD",
|
||||
"search_filters_features_option_hdr": "எச்.டி.ஆர்",
|
||||
"search_filters_features_option_location": "இடம்",
|
||||
"search_filters_sort_option_relevance": "பொருத்தமானது",
|
||||
"search_filters_sort_option_rating": "செயல்வரம்பு",
|
||||
"Current version: ": "தற்போதைய பதிப்பு: ",
|
||||
"next_steps_error_message": "அதன் பிறகு நீங்கள் முயற்சி செய்ய வேண்டும்: ",
|
||||
"next_steps_error_message_refresh": "புதுப்பிப்பு",
|
||||
"next_steps_error_message_go_to_youtube": "YouTube க்குச் செல்லுங்கள்",
|
||||
"footer_donate_page": "நன்கொடை",
|
||||
"footer_modfied_source_code": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு",
|
||||
"adminprefs_modified_source_code_url_label": "மாற்றியமைக்கப்பட்ட மூலக் குறியீடு களஞ்சியத்திற்கு முகவரி",
|
||||
"videoinfo_started_streaming_x_ago": "`X` முன்பு ச்ட்ரீமிங் செய்யத் தொடங்கியது",
|
||||
"videoinfo_watch_on_youTube": "YouTube இல் பாருங்கள்",
|
||||
"download_subtitles": "வசன வரிகள் - `x` (.vtt)",
|
||||
"user_created_playlists": "`x` உருவாக்கியது பிளேலிச்ட்கள்",
|
||||
"user_saved_playlists": "`x` சேமித்த பிளேலிச்ட்கள்",
|
||||
"crash_page_before_reporting": "ஒரு பிழையைப் புகாரளிப்பதற்கு முன், உங்களிடம் இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்:",
|
||||
"crash_page_switch_instance": "<a href = \"` x` \"> மற்றொரு நிகழ்வைப் பயன்படுத்த முயற்சித்தேன் </a>",
|
||||
"crash_page_search_issue": "அறிவிலிமையத்தில் உள்ள <a href=\"`x`\"> தற்போதைய சிக்கல்களைத் தேடியது</a>",
|
||||
"channel_tab_shorts_label": "குறுக்குகள்",
|
||||
"channel_tab_streams_label": "லைவ்ச்ட்ரீம்கள்",
|
||||
"carousel_go_to": "`X` ச்லைடு செல்லவும்",
|
||||
"Popular": "புகழ்பெற்ற",
|
||||
"Subscribe": "குழுசேர்",
|
||||
"View channel on YouTube": "YouTube இல் சேனலைக் காண்க",
|
||||
"Authorize token for `x`?": "`X` க்கு கிள்ளாக்கை அங்கீகரிக்கவா?",
|
||||
"No": "இல்லை",
|
||||
"Add to playlist: ": "பிளேலிச்ட்டில் சேர்க்கவும்: ",
|
||||
"Answer": "பதில்",
|
||||
"Search for videos": "வீடியோக்களைத் தேடுங்கள்",
|
||||
"The Popular feed has been disabled by the administrator.": "பிரபலமான ஊட்டத்தை நிர்வாகியால் முடக்கப்பட்டுள்ளது.",
|
||||
"generic_subscriptions_count": "{{count}} சந்தா",
|
||||
"generic_subscriptions_count_plural": "{{count}} சந்தாக்கள்",
|
||||
"generic_button_edit": "தொகு",
|
||||
"generic_button_save": "சேமி",
|
||||
"generic_button_cancel": "ரத்துசெய்",
|
||||
"Import and Export Data": "தரவை இறக்குமதி செய்து ஏற்றுமதி செய்யுங்கள்",
|
||||
"Import": "இறக்குமதி",
|
||||
"Import NewPipe subscriptions (.json)": "நியூபிப்பிப் சந்தாக்களை இறக்குமதி செய்யுங்கள் (.json)",
|
||||
"Export": "ஏற்றுமதி",
|
||||
"Text CAPTCHA": "உரை கேப்ட்சா",
|
||||
"Image CAPTCHA": "பட கேப்ட்சா",
|
||||
"preferences_category_player": "பிளேயர் விருப்பத்தேர்வுகள்",
|
||||
"preferences_video_loop_label": "எப்போதும் லூப்: ",
|
||||
"preferences_continue_autoplay_label": "தன்னியக்க அடுத்த வீடியோ: ",
|
||||
"preferences_listen_label": "இயல்பாக கேளுங்கள்: ",
|
||||
"preferences_quality_option_dash": "கோடு (தகவமைப்பு தரம்)",
|
||||
"preferences_quality_option_hd720": "HD720",
|
||||
"preferences_quality_option_medium": "சராசரி",
|
||||
"preferences_quality_option_small": "சிறிய",
|
||||
"preferences_quality_dash_option_2160p": "2160 ப",
|
||||
"preferences_quality_dash_option_1440p": "1440 ப",
|
||||
"preferences_quality_dash_option_240p": "240 ப",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "ரெடிட்",
|
||||
"invidious": "வெகுவாக",
|
||||
"preferences_extend_desc_label": "வீடியோ விளக்கத்தை தானாக நீட்டிக்கவும்: ",
|
||||
"preferences_region_label": "உள்ளடக்க நாடு: ",
|
||||
"preferences_player_style_label": "பிளேயர் ச்டைல்: ",
|
||||
"Dark mode: ": "இருண்ட முறை: ",
|
||||
"preferences_dark_mode_label": "தீம்: ",
|
||||
"dark": "இருண்ட",
|
||||
"preferences_automatic_instance_redirect_label": "தானியங்கி நிகழ்வு திசைதிருப்பல் (redirect.invidious.io க்கு குறைவடையும்): ",
|
||||
"preferences_max_results_label": "ஊட்டத்தில் காட்டப்பட்டுள்ள வீடியோக்களின் எண்ணிக்கை: ",
|
||||
"alphabetically - reverse": "அகரவரிசை - தலைகீழ்",
|
||||
"channel name": "சேனல் பெயர்",
|
||||
"channel name - reverse": "சேனல் பெயர் - தலைகீழ்",
|
||||
"Only show latest video from channel: ": "சேனலில் இருந்து அண்மைக் கால வீடியோவைக் காட்டுங்கள்: ",
|
||||
"Only show latest unwatched video from channel: ": "சேனலில் இருந்து அண்மைக் கால கவனிக்கப்படாத வீடியோவைக் காட்டுங்கள்: ",
|
||||
"`x` uploaded a video": "`x` ஒரு வீடியோவைப் பதிவேற்றியது",
|
||||
"Clear watch history": "தெளிவான கண்காணிப்பு வரலாறு",
|
||||
"Log out": "விடுபதிகை",
|
||||
"Source available here.": "சான்று இங்கே கிடைக்கிறது.",
|
||||
"Delete playlist": "பிளேலிச்ட்டை நீக்கு",
|
||||
"Create playlist": "பிளேலிச்ட்டை உருவாக்கவும்",
|
||||
"Title": "தலைப்பு",
|
||||
"Import/export data": "தரவு இறக்குமதி/ஏற்றுமதி",
|
||||
"Change password": "கடவுச்சொல்லை மாற்றவும்",
|
||||
"Manage tokens": "டோக்கன்களை நிர்வகிக்கவும்",
|
||||
"Popular enabled: ": "பிரபலமான இயக்கப்பட்டது: ",
|
||||
"tokens_count": "{{count}} கிள்ளாக்கு",
|
||||
"tokens_count_plural": "{{count}} டோக்கன்கள்",
|
||||
"Import/export": "இறக்குமதி/ஏற்றுமதி",
|
||||
"unsubscribe": "குழுவிலகவும்",
|
||||
"revoke": "ரத்து செய்யுங்கள்",
|
||||
"Subscriptions": "சந்தாக்கள்",
|
||||
"subscriptions_unseen_notifs_count": "{{count}} காணப்படாத அறிவிப்பு",
|
||||
"subscriptions_unseen_notifs_count_plural": "{{count}} காணப்படாத அறிவிப்புகள்",
|
||||
"Editing playlist `x`": "பிளேலிச்ட்டைத் திருத்துதல் `x`",
|
||||
"playlist_button_add_items": "வீடியோக்களைச் சேர்க்கவும்",
|
||||
"Show more": "மேலும் காட்டு",
|
||||
"Show less": "குறைவாகக் காட்டு",
|
||||
"Switch Invidious Instance": "அக்யோர்ட் உதாரணத்தை மாற்றவும்",
|
||||
"search_message_no_results": "முடிவுகள் எதுவும் கிடைக்கவில்லை.",
|
||||
"search_message_change_filters_or_query": "உங்கள் தேடல் வினவலை அகலப்படுத்த முயற்சிக்கவும்/அல்லது வடிப்பான்களை மாற்றவும்.",
|
||||
"search_message_use_another_instance": "நீங்கள் <a href = \"` x` \"> மற்றொரு நிகழ்வில் தேடலாம் </a>.",
|
||||
"Show annotations": "சிறுகுறிப்புகளைக் காட்டு",
|
||||
"Genre: ": "வகை: ",
|
||||
"License: ": "உரிமம்: ",
|
||||
"Standard YouTube license": "நிலையான YouTube உரிமம்",
|
||||
"Family friendly? ": "குடும்ப நட்பு? ",
|
||||
"Wilson score: ": "வில்சன் மதிப்பெண்: ",
|
||||
"Engagement: ": "நிச்சயதார்த்தம்: ",
|
||||
"Whitelisted regions: ": "அனுமதிப்பட்டிய பகுதிகள்: ",
|
||||
"Blacklisted regions: ": "தடுப்புப்பட்டியாக்கப்பட்ட பகுதிகள்: ",
|
||||
"Music in this video": "இந்த வீடியோவில் இசை",
|
||||
"Artist: ": "கலைஞர்: ",
|
||||
"Song: ": "பாடல்: ",
|
||||
"Album: ": "ஆல்பம்: ",
|
||||
"Shared `x`": "பகிரப்பட்டது `x`",
|
||||
"Premieres in `x`": "`X` இல் பிரீமியர்ச்",
|
||||
"Premieres `x`": "பிரீமியர்ச் `x`",
|
||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "ஆய்! நீங்கள் சாவாச்கிரிப்ட் முடக்கப்பட்டிருப்பது போல் தெரிகிறது. கருத்துகளைக் காண இங்கே சொடுக்கு செய்க, அவர்கள் ஏற்றுவதற்கு சிறிது நேரம் ஆகலாம் என்பதை நினைவில் கொள்ளுங்கள்.",
|
||||
"View YouTube comments": "YouTube கருத்துகளைக் காண்க",
|
||||
"View more comments on Reddit": "ரெடிட் குறித்த கூடுதல் கருத்துகளைக் காண்க",
|
||||
"View `x` comments": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "`X` கருத்தைக் காண்க",
|
||||
"": "`X` கருத்துகளைக் காண்க"
|
||||
},
|
||||
"View Reddit comments": "ரெடிட் கருத்துகளைக் காண்க",
|
||||
"Hide replies": "பதில்களை மறைக்கவும்",
|
||||
"Wrong username or password": "தவறான பயனர்பெயர் அல்லது கடவுச்சொல்",
|
||||
"Password cannot be longer than 55 characters": "கடவுச்சொல் 55 எழுத்துகளை விட நீளமாக இருக்க முடியாது",
|
||||
"Invidious Private Feed for `x`": "`X` க்கான மோசமான தனியார் ஊட்டம்",
|
||||
"channel:`x`": "சேனல்: `x`",
|
||||
"Deleted or invalid channel": "நீக்கப்பட்ட அல்லது தவறான சேனல்",
|
||||
"comments_view_x_replies": "{{count}} பதிலைக் காண்க",
|
||||
"comments_view_x_replies_plural": "{{count}} பதில்களைக் காண்க",
|
||||
"`x` ago": "`x` முன்பு",
|
||||
"Load more": "மேலும் ஏற்றவும்",
|
||||
"Hidden field \"challenge\" is a required field": "மறைக்கப்பட்ட புலம் \"அறைகூவல்\" என்பது தேவையான புலம்",
|
||||
"Hidden field \"token\" is a required field": "மறைக்கப்பட்ட புலம் \"கிள்ளாக்கு\" என்பது தேவையான புலம்",
|
||||
"Corsican": "கார்சிகன்",
|
||||
"Croatian": "குரோசியன்",
|
||||
"Czech": "செக்",
|
||||
"Danish": "டேனிச்",
|
||||
"Dutch (auto-generated)": "டச்சு (தானாக உருவாக்கப்பட்ட)",
|
||||
"Esperanto": "எச்பெராண்டோ",
|
||||
"Estonian": "எச்டோனிய",
|
||||
"Filipino": "ஃபிலிபினோ",
|
||||
"Filipino (auto-generated)": "பிலிப்பைன்ச் (தானாக உருவாக்கிய)",
|
||||
"French (auto-generated)": "பிரஞ்சு (தானாக உருவாக்கப்பட்ட)",
|
||||
"Galician": "காலிசியன்",
|
||||
"Georgian": "சார்சியன்",
|
||||
"German": "செர்மன்",
|
||||
"Hausa": "ஔசா",
|
||||
"Lao": "லாவோ",
|
||||
"Latin": "லத்தீன்",
|
||||
"Latvian": "லாட்வியன்",
|
||||
"Hawaiian": "அவாயியன்",
|
||||
"Hebrew": "எபிரேய",
|
||||
"Lithuanian": "லிதுவேனியன்",
|
||||
"Hindi": "இந்தி",
|
||||
"Hmong": "அமோங்",
|
||||
"Indonesian": "இந்தோனேசிய",
|
||||
"Indonesian (auto-generated)": "இந்தோனேசிய (தானாக உருவாக்கப்பட்ட)",
|
||||
"Interlingue": "இன்டர்லின்குய்",
|
||||
"Irish": "ஐரிச்",
|
||||
"Italian": "இத்தாலிய",
|
||||
"Italian (auto-generated)": "இத்தாலியன் (தானாக உருவாக்கப்பட்ட)",
|
||||
"Japanese": "சப்பானியர்கள்",
|
||||
"Japanese (auto-generated)": "சப்பானிய (தானாக உருவாக்கப்பட்ட)",
|
||||
"Javanese": "சாவானீச்",
|
||||
"Kannada": "கன்னடா",
|
||||
"Kazakh": "கசாக்",
|
||||
"Khmer": "கெமர்",
|
||||
"Korean": "கொரிய",
|
||||
"Kurdish": "குர்திச்",
|
||||
"Kyrgyz": "கிர்கிச்",
|
||||
"Luxembourgish": "லக்சம்போர்கிச்",
|
||||
"Malay": "மலாய்",
|
||||
"Pashto": "பச்தோ",
|
||||
"Persian": "பெர்சியன்",
|
||||
"Polish": "போலீச்",
|
||||
"Portuguese": "போர்த்துகீசியம்",
|
||||
"Portuguese (auto-generated)": "போர்த்துகீசியம் (தானாக உருவாக்கிய)",
|
||||
"generic_count_minutes": "{{count}} மணித்துளி",
|
||||
"generic_count_minutes_plural": "{{count}} நிமிடங்கள்",
|
||||
"generic_count_seconds": "{{count}} இரண்டாவது",
|
||||
"generic_count_seconds_plural": "{{count}} வினாடிகள்",
|
||||
"Fallback comments: ": "குறைவடையும் கருத்துரைகள்: ",
|
||||
"Portuguese (Brazil)": "போர்த்துகீசியம் (பிரேசில்)",
|
||||
"Punjabi": "பஞ்சாபி",
|
||||
"Romanian": "ருமேனிய",
|
||||
"Sinhala": "சிங்களம்",
|
||||
"Slovak": "ச்லோவாக்",
|
||||
"Slovenian": "ச்லோவேனியன்",
|
||||
"Spanish (Latin America)": "ச்பானிச் (லத்தீன் அமெரிக்கா)",
|
||||
"Spanish (Mexico)": "ச்பானிச் (மெக்சிகோ)",
|
||||
"Spanish (Spain)": "ச்பானிச் (ச்பெயின்)",
|
||||
"Telugu": "தெலுங்கு",
|
||||
"Turkish (auto-generated)": "துருக்கிய (தானாக உருவாக்கிய)",
|
||||
"Ukrainian": "உக்ரேனிய",
|
||||
"Urdu": "உருது",
|
||||
"Uzbek": "உச்பெக்",
|
||||
"Vietnamese (auto-generated)": "வியட்நாமிய (தானாக உருவாக்கப்பட்ட)",
|
||||
"Western Frisian": "மேற்கு ஃபிரிசியன்",
|
||||
"Zulu": "சுலு",
|
||||
"generic_count_years": "{{count}}} ஆண்டு",
|
||||
"generic_count_years_plural": "{{count}} ஆண்டுகள்",
|
||||
"generic_count_months": "{{count}} மாதம்",
|
||||
"generic_count_months_plural": "{{count}} மாதங்கள்",
|
||||
"generic_count_weeks": "{{count}}} வாரம்",
|
||||
"generic_count_weeks_plural": "{{count}} வாரங்கள்",
|
||||
"generic_count_days": "{{count}}} நாள்",
|
||||
"generic_count_days_plural": "{{count}} நாட்கள்",
|
||||
"generic_count_hours": "{{count}} மணிநேரம்",
|
||||
"generic_count_hours_plural": "{{count}} மணிநேரம்",
|
||||
"Search": "தேடல்",
|
||||
"Rating: ": "மதிப்பீடு: ",
|
||||
"preferences_locale_label": "மொழி: ",
|
||||
"Default": "இயல்புநிலை",
|
||||
"Music": "இசை",
|
||||
"Download": "பதிவிறக்கம்",
|
||||
"%A %B %-d, %Y": "%A %b %-d, %y",
|
||||
"permalink": "பெர்மாலின்க்",
|
||||
"Channel Sponsor": "சேனல் ஒப்புரவாளர்",
|
||||
"Audio mode": "ஆடியோ பயன்முறை",
|
||||
"search_filters_duration_option_short": "குறுகிய (<4 நிமிடங்கள்)",
|
||||
"search_filters_title": "வடிப்பான்கள்",
|
||||
"search_filters_date_label": "தேதி பதிவேற்றும் தேதி",
|
||||
"search_filters_date_option_none": "எந்த தேதி",
|
||||
"search_filters_date_option_hour": "கடைசி மணி",
|
||||
"search_filters_date_option_year": "இந்த ஆண்டு",
|
||||
"search_filters_type_label": "வகை",
|
||||
"search_filters_type_option_all": "எந்த வகை",
|
||||
"search_filters_type_option_video": "ஒளிதோற்றம்",
|
||||
"search_filters_type_option_movie": "படம்",
|
||||
"search_filters_type_option_show": "காட்டு",
|
||||
"search_filters_features_option_vr180": "VR180",
|
||||
"search_filters_features_option_purchased": "வாங்கப்பட்டது",
|
||||
"search_filters_sort_label": "வரிசைப்படுத்தவும்",
|
||||
"search_filters_sort_option_date": "பதிவேற்ற தேதி",
|
||||
"search_filters_sort_option_views": "எண்ணிக்கை காண்க",
|
||||
"search_filters_apply_button": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்களைப் பயன்படுத்துங்கள்",
|
||||
"footer_documentation": "ஆவணப்படுத்துதல்",
|
||||
"footer_source_code": "மூலக் குறியீடு",
|
||||
"footer_original_source_code": "அசல் மூலக் குறியீடு",
|
||||
"none": "எதுவுமில்லை",
|
||||
"videoinfo_youTube_embed_link": "உட்பொதிக்கப்பட்டது",
|
||||
"videoinfo_invidious_embed_link": "உட்பொதிப்பு இணைப்பு",
|
||||
"Video unavailable": "வீடியோ கிடைக்கவில்லை",
|
||||
"preferences_save_player_pos_label": "பிளேபேக் நிலையை சேமிக்கவும்: ",
|
||||
"crash_page_you_found_a_bug": "நீங்கள் ஒரு பிழையை கண்டுபிடித்ததாகத் தெரிகிறது!",
|
||||
"crash_page_refresh": "<a href = \"` x` \"> பக்கத்தை புதுப்பிக்க முயற்சித்தேன் </a>",
|
||||
"crash_page_read_the_faq": "<a href = \"` x` \"> அடிக்கடி கேட்கப்படும் கேள்விகள் (கேள்விகள்) </a> ஐப் படியுங்கள்",
|
||||
"crash_page_report_issue": "மேலே எதுவும் உதவவில்லை என்றால், தயவுசெய்து <a href = \"` x` \"> அறிவிலிமையம் </a> (முன்னுரிமை ஆங்கிலத்தில்) ஒரு புதிய சிக்கலைத் திறந்து உங்கள் செய்தியில் பின்வரும் உரையைச் சேர்க்கவும் (அந்த உரையை மொழிபெயர்க்க வேண்டாம்):",
|
||||
"error_video_not_in_playlist": "கோரப்பட்ட வீடியோ இந்த பிளேலிச்ட்டில் இல்லை. <a href = \"` x` \"> பிளேலிச்ட் முகப்பு பக்கத்திற்கு இங்கே சொடுக்கு செய்க. </a>",
|
||||
"channel_tab_videos_label": "வீடியோக்கள்",
|
||||
"channel_tab_podcasts_label": "பாட்காச்ட்கள்",
|
||||
"channel_tab_releases_label": "வெளியீடுகள்",
|
||||
"channel_tab_playlists_label": "பிளேலிச்ட்கள்",
|
||||
"channel_tab_community_label": "சமூகம்",
|
||||
"channel_tab_channels_label": "சேனல்கள்",
|
||||
"toggle_theme": "கருப்பொருளை மாற்றவும்",
|
||||
"carousel_slide": "{{total}} இன் ச்லைடு {{current}}",
|
||||
"carousel_skip": "கொணர்வி தவிர்க்கவும்"
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
{}
|
|
@ -496,6 +496,5 @@
|
|||
"carousel_slide": "Sunum {{current}} / {{total}}",
|
||||
"carousel_skip": "Kayar menüyü atla",
|
||||
"carousel_go_to": "`x` sunumuna git",
|
||||
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.",
|
||||
"preferences_preload_label": "Video verilerini önceden yükle: "
|
||||
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
|
||||
}
|
||||
|
|
|
@ -513,7 +513,5 @@
|
|||
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
|
||||
"carousel_slide": "Слайд {{current}} з {{total}}",
|
||||
"carousel_skip": "Пропустити карусель",
|
||||
"carousel_go_to": "Перейти до слайда `x`",
|
||||
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
|
||||
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
|
||||
"carousel_go_to": "Перейти до слайда `x`"
|
||||
}
|
||||
|
|
|
@ -479,7 +479,5 @@
|
|||
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
|
||||
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
|
||||
"carousel_skip": "跳过图集",
|
||||
"carousel_go_to": "转到图 `x`",
|
||||
"preferences_preload_label": "预加载视频数据: ",
|
||||
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
|
||||
"carousel_go_to": "转到图 `x`"
|
||||
}
|
||||
|
|
|
@ -479,7 +479,5 @@
|
|||
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
|
||||
"carousel_skip": "略過輪播",
|
||||
"carousel_go_to": "跳到投影片 `x`",
|
||||
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。",
|
||||
"preferences_preload_label": "預先載入影片資訊 ",
|
||||
"Filipino (auto-generated)": "菲律賓語(自動產生)"
|
||||
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
|
||||
}
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
require "http"
|
||||
require "colorize"
|
||||
|
||||
ESBUILD_VERSION = "0.25.0"
|
||||
|
||||
esbuild_os = ""
|
||||
esbuild_arch = ""
|
||||
|
||||
# https://esbuild.github.io/getting-started/#other-ways-to-install
|
||||
{% if flag?(:linux) %}
|
||||
esbuild_os = "linux"
|
||||
{% elsif flag?(:windows) %}
|
||||
esbuild_os = "win32"
|
||||
{% elsif flag?(:darwin) %}
|
||||
esbuild_os = "darwin"
|
||||
{% elsif flag?(:freebsd) %}
|
||||
esbuild_os = "freebsd"
|
||||
{% elsif flag?(:openbsd) %}
|
||||
esbuild_os = "openbsd"
|
||||
{% elsif flag?(:netbsd) %}
|
||||
esbuild_os = "netbsd"
|
||||
{% elsif flag?(:solaris) %}
|
||||
esbuild_os = "sunos"
|
||||
{% else %}
|
||||
esbuild_os = "linux"
|
||||
{% end %}
|
||||
|
||||
{% if flag?(:x86_64) %}
|
||||
esbuild_arch = "x64"
|
||||
{% elsif flag?(:arm64) %}
|
||||
esbuild_arch = "arm64"
|
||||
{% else %}
|
||||
esbuild_arch = "x64"
|
||||
{% end %}
|
||||
|
||||
tmp_dir_path = "#{Dir.tempdir}/invidious-esbuild-binary"
|
||||
esbuild_tar_location = "#{tmp_dir_path}/esbuild-#{esbuild_os}-#{esbuild_os}-#{ESBUILD_VERSION}.tgz"
|
||||
esbuild_binary_location = "#{tmp_dir_path}/package/bin/esbuild"
|
||||
Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path
|
||||
|
||||
esbuild_url = "https://registry.npmjs.org/@esbuild/#{esbuild_os}-#{esbuild_arch}/-/#{esbuild_os}-#{esbuild_arch}-#{ESBUILD_VERSION}.tgz"
|
||||
|
||||
# Download esbuild binary
|
||||
HTTP::Client.get(esbuild_url) do |response|
|
||||
puts "Downloading esbuild from #{esbuild_url}"
|
||||
data = response.body_io.gets_to_end
|
||||
File.write(esbuild_tar_location, data)
|
||||
|
||||
`tar -vzxf '#{esbuild_tar_location}' -C '#{tmp_dir_path}'`
|
||||
raise "Extraction for #{esbuild_tar_location} failed" if !$?.success?
|
||||
puts "esbuild downloaded successfully"
|
||||
end
|
||||
|
||||
files_to_minify = [
|
||||
"_helpers.js",
|
||||
"comments.js",
|
||||
"embed.js",
|
||||
"handlers.js",
|
||||
"notifications.js",
|
||||
"pagination.js",
|
||||
"playlist_widget.js",
|
||||
"post.js",
|
||||
"sse.js",
|
||||
"subscribe_widget.js",
|
||||
"themes.js",
|
||||
"watch.js",
|
||||
"player.js",
|
||||
"watched_indicator.js",
|
||||
"watched_widget.js",
|
||||
]
|
||||
|
||||
files_to_minify.each do |file|
|
||||
file_path = "assets/js/#{file}"
|
||||
outdir = "assets/js/minified"
|
||||
process_output = IO::Memory.new
|
||||
|
||||
process = Process.run("#{esbuild_binary_location}", error: process_output, args: [
|
||||
file_path,
|
||||
"--color=false",
|
||||
"--sourcemap",
|
||||
"--minify",
|
||||
"--outdir=#{outdir}",
|
||||
]
|
||||
)
|
||||
|
||||
if process.success?
|
||||
puts "Minified #{file}".colorize(:green)
|
||||
elsif !process.success?
|
||||
puts "Failed to minify #{file}, esbuild exited with exit code #{process.exit_code}: #{process_output.to_s.split("\n").first}".colorize(:red)
|
||||
raise Exception.new("All files have to be minified sucessfully in order to compile!")
|
||||
end
|
||||
end
|
||||
|
||||
puts "Minify done!"
|
||||
|
||||
# Cleanup
|
||||
`rm -rf #{tmp_dir_path}`
|
29
shard.lock
29
shard.lock
|
@ -2,15 +2,15 @@ version: 2.0
|
|||
shards:
|
||||
ameba:
|
||||
git: https://github.com/crystal-ameba/ameba.git
|
||||
version: 1.6.4
|
||||
version: 1.6.1
|
||||
|
||||
athena-negotiation:
|
||||
git: https://github.com/athena-framework/negotiation.git
|
||||
version: 0.1.5
|
||||
version: 0.1.1
|
||||
|
||||
backtracer:
|
||||
git: https://github.com/sija/backtracer.cr.git
|
||||
version: 1.2.4
|
||||
version: 1.2.2
|
||||
|
||||
db:
|
||||
git: https://github.com/crystal-lang/crystal-db.git
|
||||
|
@ -18,24 +18,31 @@ shards:
|
|||
|
||||
exception_page:
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.4.1
|
||||
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
version: 0.10.3
|
||||
version: 0.2.2
|
||||
|
||||
inotify:
|
||||
git: https://github.com/petoem/inotify.cr.git
|
||||
version: 1.0.3
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
version: 0.10.3
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 1.6.0
|
||||
version: 1.1.2
|
||||
|
||||
kilt:
|
||||
git: https://github.com/jeromegn/kilt.git
|
||||
version: 0.6.1
|
||||
|
||||
pg:
|
||||
git: https://github.com/will/crystal-pg.git
|
||||
version: 0.28.0
|
||||
|
||||
pool:
|
||||
git: https://github.com/ysbaddaden/pool.git
|
||||
version: 0.2.4
|
||||
|
||||
protodec:
|
||||
git: https://github.com/iv-org/protodec.git
|
||||
version: 0.1.5
|
||||
|
@ -45,8 +52,8 @@ shards:
|
|||
version: 0.4.1
|
||||
|
||||
redis:
|
||||
git: https://github.com/jgaskins/redis.git
|
||||
version: 0.12.0
|
||||
git: https://github.com/stefanwille/crystal-redis.git
|
||||
version: 2.9.1
|
||||
|
||||
spectator:
|
||||
git: https://github.com/icy-arctic-fox/spectator.git
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
name: invidious
|
||||
version: 2.20250314.0-dev
|
||||
version: 2.20241110.0-dev
|
||||
|
||||
authors:
|
||||
- Invidious team <contact@invidious.io>
|
||||
|
@ -17,7 +17,10 @@ dependencies:
|
|||
version: ~> 0.21.0
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
version: ~> 1.6.0
|
||||
version: ~> 1.1.2
|
||||
kilt:
|
||||
github: jeromegn/kilt
|
||||
version: ~> 0.6.1
|
||||
protodec:
|
||||
github: iv-org/protodec
|
||||
version: ~> 0.1.5
|
||||
|
@ -25,7 +28,7 @@ dependencies:
|
|||
github: athena-framework/negotiation
|
||||
version: ~> 0.1.1
|
||||
redis:
|
||||
github: jgaskins/redis
|
||||
github: stefanwille/crystal-redis
|
||||
inotify:
|
||||
github: petoem/inotify.cr
|
||||
version: 1.0.3
|
||||
|
|
16
src/ext/kemal_content_for.cr
Normal file
16
src/ext/kemal_content_for.cr
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Overrides for Kemal's `content_for` macro in order to keep using
|
||||
# kilt as it was before Kemal v1.1.1 (Kemal PR #618).
|
||||
|
||||
require "kemal"
|
||||
require "kilt"
|
||||
|
||||
macro content_for(key, file = __FILE__)
|
||||
%proc = ->() {
|
||||
__kilt_io__ = IO::Memory.new
|
||||
{{ yield }}
|
||||
__kilt_io__.to_s
|
||||
}
|
||||
|
||||
CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc
|
||||
nil
|
||||
end
|
|
@ -71,7 +71,7 @@ def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt
|
|||
filesize = data.bytesize
|
||||
attachment(env, filename, disposition)
|
||||
|
||||
Kemal.config.static_headers.try(&.call(env, file_path, filestat))
|
||||
Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
|
||||
|
||||
file = IO::Memory.new(data)
|
||||
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
||||
|
|
|
@ -17,8 +17,10 @@
|
|||
require "digest/md5"
|
||||
require "file_utils"
|
||||
|
||||
# Require kemal, then our own overrides
|
||||
# Require kemal, kilt, then our own overrides
|
||||
require "kemal"
|
||||
require "kilt"
|
||||
require "./ext/kemal_content_for.cr"
|
||||
require "./ext/kemal_static_file_handler.cr"
|
||||
|
||||
require "http_proxy"
|
||||
|
@ -49,8 +51,7 @@ require "./invidious/channels/*"
|
|||
require "./invidious/user/*"
|
||||
require "./invidious/search/*"
|
||||
require "./invidious/routes/**"
|
||||
require "./invidious/jobs/base_job"
|
||||
require "./invidious/jobs/*"
|
||||
require "./invidious/jobs/**"
|
||||
|
||||
# Declare the base namespace for invidious
|
||||
module Invidious
|
||||
|
@ -75,8 +76,12 @@ end
|
|||
|
||||
HMAC_KEY = CONFIG.hmac_key
|
||||
|
||||
PG_DB = DB.open CONFIG.database_url
|
||||
PG_DB = DB.open CONFIG.database_url
|
||||
REDIS_DB = Redis::PooledClient.new(unixsocket: CONFIG.redis_socket || nil, url: CONFIG.redis_url || nil)
|
||||
|
||||
if REDIS_DB.ping
|
||||
puts "Connected to redis"
|
||||
end
|
||||
ARCHIVE_URL = URI.parse("https://archive.org")
|
||||
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
|
||||
REDDIT_URL = URI.parse("https://www.reddit.com")
|
||||
|
@ -113,12 +118,6 @@ YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
|
|||
|
||||
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
|
||||
|
||||
COMPANION_POOL = [] of CompanionConnectionPool
|
||||
|
||||
CONFIG.invidious_companion.each do |companion|
|
||||
COMPANION_POOL << CompanionConnectionPool.new(companion, capacity: CONFIG.pool_size)
|
||||
end
|
||||
|
||||
# CLI
|
||||
Kemal.config.extra_options do |parser|
|
||||
parser.banner = "Usage: invidious [arguments]"
|
||||
|
@ -160,15 +159,6 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_log
|
|||
# Check table integrity
|
||||
Invidious::Database.check_integrity(CONFIG)
|
||||
|
||||
# Minifies Invidious Javascript
|
||||
{% if flag?(:minify_debug) || (flag?(:release) || flag?(:production)) && !flag?(:skip_minified_js) %}
|
||||
{% puts "\nMinifying Invidious JavaScript\n" %}
|
||||
{% puts run("../scripts/minify-js.cr").stringify %}
|
||||
JS_PATH="js/minified"
|
||||
{% else %}
|
||||
JS_PATH="js"
|
||||
{% end %}
|
||||
|
||||
{% if !flag?(:skip_videojs_download) %}
|
||||
# Resolve player dependencies. This is done at compile time.
|
||||
#
|
||||
|
@ -211,26 +201,27 @@ if CONFIG.popular_enabled
|
|||
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
|
||||
end
|
||||
|
||||
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32)
|
||||
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
|
||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
|
||||
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
|
||||
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url)
|
||||
|
||||
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
|
||||
|
||||
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
|
||||
|
||||
if !CONFIG.external_videoplayback_proxy.empty?
|
||||
Invidious::Jobs.register Invidious::Jobs::CheckExternalProxy.new
|
||||
else
|
||||
# Invidious will it's own videoplayback proxy unless the admin decides to rewrite
|
||||
# the /videoplayback location in the reverse proxy configuration (NGINX, Caddy, etc)
|
||||
LOGGER.info("jobs: Disabling CheckExternalProxy job. Invidious will it's own videoplayback proxy")
|
||||
end
|
||||
|
||||
if !CONFIG.tokens_server.empty?
|
||||
Invidious::Jobs.register Invidious::Jobs::RefreshSessionTokens.new
|
||||
else
|
||||
LOGGER.info("jobs: Disabling RefreshSessionTokens job. Invidious will use the tokens that are on the configuration file")
|
||||
end
|
||||
|
||||
if CONFIG.invidious_companion.present?
|
||||
Invidious::Jobs.register Invidious::Jobs::CheckBackend.new
|
||||
else
|
||||
LOGGER.info("jobs: Disabling CheckBackend job. invidious-companion and their respective external video playback proxies (if set on invidious-companion) will not be checked")
|
||||
end
|
||||
|
||||
Invidious::Jobs.start_all
|
||||
|
||||
def popular_videos
|
||||
|
@ -253,8 +244,8 @@ error 500 do |env, ex|
|
|||
error_template(500, ex)
|
||||
end
|
||||
|
||||
static_headers do |env|
|
||||
env.response.headers.add("Cache-Control", "max-age=2629800")
|
||||
static_headers do |response|
|
||||
response.headers.add("Cache-Control", "max-age=2629800")
|
||||
end
|
||||
|
||||
# Init Kemal
|
||||
|
@ -271,6 +262,8 @@ add_context_storage_type(Preferences)
|
|||
add_context_storage_type(Invidious::User)
|
||||
|
||||
Kemal.config.logger = LOGGER
|
||||
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
|
||||
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
|
||||
Kemal.config.app_name = "Invidious"
|
||||
|
||||
# Use in kemal's production mode.
|
||||
|
@ -279,16 +272,4 @@ Kemal.config.app_name = "Invidious"
|
|||
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
|
||||
{% end %}
|
||||
|
||||
Kemal.run do |config|
|
||||
if socket_binding = CONFIG.socket_binding
|
||||
File.delete?(socket_binding.path)
|
||||
# Create a socket and set its desired permissions
|
||||
server = UNIXServer.new(socket_binding.path)
|
||||
perms = socket_binding.permissions.to_i(base: 8)
|
||||
File.chmod(socket_binding.path, perms)
|
||||
config.server.not_nil!.bind server
|
||||
else
|
||||
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
|
||||
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
|
||||
end
|
||||
end
|
||||
Kemal.run
|
||||
|
|
|
@ -249,7 +249,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
|||
|
||||
if was_insert
|
||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
|
||||
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
|
||||
if CONFIG.enable_user_notifications
|
||||
Invidious::Database::Users.add_notification(video)
|
||||
# else
|
||||
# Invidious::Database::Users.feed_needs_update(video)
|
||||
end
|
||||
else
|
||||
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
|
||||
end
|
||||
|
@ -281,7 +285,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
|||
if Time.utc - video.published > 1.minute
|
||||
was_insert = Invidious::Database::ChannelVideos.insert(video)
|
||||
if was_insert
|
||||
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
|
||||
if CONFIG.enable_user_notifications
|
||||
Invidious::Database::Users.add_notification(video)
|
||||
# else
|
||||
# Invidious::Database::Users.feed_needs_update(video)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,12 +44,3 @@ def fetch_channel_releases(ucid, author, continuation)
|
|||
end
|
||||
return extract_items(initial_data, author, ucid)
|
||||
end
|
||||
|
||||
def fetch_channel_courses(ucid, author, continuation)
|
||||
if continuation
|
||||
initial_data = YoutubeAPI.browse(continuation)
|
||||
else
|
||||
initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
|
||||
end
|
||||
return extract_items(initial_data, author, ucid)
|
||||
end
|
||||
|
|
|
@ -8,13 +8,6 @@ struct DBConfig
|
|||
property dbname : String
|
||||
end
|
||||
|
||||
struct SocketBindingConfig
|
||||
include YAML::Serializable
|
||||
|
||||
property path : String
|
||||
property permissions : String
|
||||
end
|
||||
|
||||
struct ConfigPreferences
|
||||
include YAML::Serializable
|
||||
|
||||
|
@ -52,7 +45,6 @@ struct ConfigPreferences
|
|||
property vr_mode : Bool = true
|
||||
property show_nick : Bool = true
|
||||
property save_player_pos : Bool = false
|
||||
property enable_dearrow : Bool = false
|
||||
|
||||
def to_tuple
|
||||
{% begin %}
|
||||
|
@ -83,12 +75,6 @@ class Config
|
|||
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property public_url : URI = URI.parse("")
|
||||
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property i2p_public_url : URI = URI.parse("")
|
||||
|
||||
property note : String = ""
|
||||
property domain : Array(String) = [] of String
|
||||
end
|
||||
|
||||
# Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||
|
@ -108,8 +94,8 @@ class Config
|
|||
# Database configuration using 12-Factor "Database URL" syntax
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property database_url : URI = URI.parse("")
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property redis_url : URI = URI.parse("")
|
||||
property redis_url : String?
|
||||
property redis_socket : String?
|
||||
# Use polling to keep decryption function up to date
|
||||
property decrypt_polling : Bool = false
|
||||
# Used for crawling channels: threads should check all videos uploaded by a channel
|
||||
|
@ -128,6 +114,10 @@ class Config
|
|||
property domain : String?
|
||||
# Materialious redirects
|
||||
property materialious_domain : String?
|
||||
# Alternative domains. You can add other domains, like TOR and I2P addresses
|
||||
property alternative_domains : Array(String) = [] of String
|
||||
# Backend domains. Domains for numbered backends
|
||||
property backend_domains : Array(String) = [] of String
|
||||
|
||||
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||
property use_pubsub_feeds : Bool | Int32 = false
|
||||
|
@ -182,8 +172,6 @@ class Config
|
|||
property port : Int32 = 3000
|
||||
# Host to bind (overridden by command line argument)
|
||||
property host_binding : String = "0.0.0.0"
|
||||
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
|
||||
property socket_binding : SocketBindingConfig? = nil
|
||||
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||
property pool_size : Int32 = 100
|
||||
# HTTP Proxy configuration
|
||||
|
@ -219,31 +207,18 @@ class Config
|
|||
# of the backend
|
||||
property backends_delimiter : String = "|"
|
||||
|
||||
# External videoplayback proxies list. They should include `https://`
|
||||
# at the start of the URI
|
||||
property external_videoplayback_proxy : Array(String) = [] of String
|
||||
|
||||
property pubsub_domain : String = ""
|
||||
|
||||
property server_id_cookie_name : String = "COMPANION_ID"
|
||||
property ignore_user_tokens : Bool = false
|
||||
|
||||
property server_id_cookie_name : String = "INVIDIOUS_SERVER_ID"
|
||||
|
||||
property tokens_server : String = ""
|
||||
|
||||
property video_cache : VideoCacheConfig
|
||||
|
||||
class VideoCacheConfig
|
||||
include YAML::Serializable
|
||||
|
||||
property enabled : Bool = true
|
||||
property backend : Int32 = 1
|
||||
# Max quantity of keys that can be held on the LRU cache
|
||||
property lru_max_size : Int32 = 18432 # ~512MB
|
||||
end
|
||||
|
||||
property check_backends_interval : Int32 = 30
|
||||
|
||||
property force_local : Bool = true
|
||||
|
||||
property disable_livestreams : Bool = true
|
||||
|
||||
property max_popuplar_results : Int32 = 40
|
||||
|
||||
{% if flag?(:linux) %}
|
||||
property reload_config_automatically : Bool = true
|
||||
{% end %}
|
||||
|
@ -332,9 +307,6 @@ class Config
|
|||
config = Config.from_yaml(config_yaml)
|
||||
|
||||
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
|
||||
#
|
||||
# Also checks if any top-level config options are set to "CHANGE_ME!!"
|
||||
# TODO: Support non-top-level config options such as the ones in DBConfig
|
||||
{% for ivar in Config.instance_vars %}
|
||||
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
||||
|
||||
|
@ -371,12 +343,6 @@ class Config
|
|||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
# Warn when any config attribute is set to "CHANGE_ME!!"
|
||||
if config.{{ivar.id}} == "CHANGE_ME!!"
|
||||
puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
|
||||
exit(1)
|
||||
end
|
||||
{% end %}
|
||||
|
||||
if config.invidious_companion.present?
|
||||
|
@ -390,14 +356,10 @@ class Config
|
|||
elsif config.invidious_companion_key == "CHANGE_ME!!"
|
||||
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
|
||||
exit(1)
|
||||
elsif config.invidious_companion_key.size != 16
|
||||
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
|
||||
elsif config.invidious_companion_key.size < 16
|
||||
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 or more."
|
||||
exit(1)
|
||||
end
|
||||
elsif config.signature_server
|
||||
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
|
||||
else
|
||||
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/")
|
||||
end
|
||||
|
||||
# HMAC_key is mandatory
|
||||
|
@ -405,6 +367,9 @@ class Config
|
|||
if config.hmac_key.empty?
|
||||
puts "Config: 'hmac_key' is required/can't be empty"
|
||||
exit(1)
|
||||
elsif config.hmac_key == "CHANGE_ME!!"
|
||||
puts "Config: The value of 'hmac_key' needs to be changed!!"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
# Build database_url from db.* if it's not set directly
|
||||
|
@ -424,33 +389,6 @@ class Config
|
|||
end
|
||||
end
|
||||
|
||||
if config.video_cache.enabled
|
||||
if !config.video_cache.backend.in?(0, 1, 2)
|
||||
puts "Config: 'video_cache_storage', can only be:"
|
||||
puts "0 (PostgreSQL)"
|
||||
puts "1 (Redis compatible DB) (Default)"
|
||||
puts "2 (In memory LRU)"
|
||||
end
|
||||
end
|
||||
|
||||
# Check if the socket configuration is valid
|
||||
if sb = config.socket_binding
|
||||
if sb.path.ends_with?("/") || File.directory?(sb.path)
|
||||
puts "Config: The socket path " + sb.path + " must not be a directory!"
|
||||
exit(1)
|
||||
end
|
||||
d = File.dirname(sb.path)
|
||||
if !File.directory?(d)
|
||||
puts "Config: Socket directory " + sb.path + " does not exist or is not a directory!"
|
||||
exit(1)
|
||||
end
|
||||
p = sb.permissions.to_i?(base: 8)
|
||||
if !p || p < 0 || p > 0o777
|
||||
puts "Config: Socket permissions must be an octal between 0 and 777!"
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
return config
|
||||
end
|
||||
end
|
||||
|
|
|
@ -149,7 +149,7 @@ module Invidious::Database::ChannelVideos
|
|||
SELECT DISTINCT ON (ucid) *
|
||||
FROM channel_videos
|
||||
WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT #{CONFIG.max_popuplar_results})
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
|
||||
ORDER BY ucid, published DESC
|
||||
SQL
|
||||
|
||||
|
|
|
@ -49,14 +49,14 @@ module Invidious::Database::Users
|
|||
PG_DB.exec(request, user.watched, user.email)
|
||||
end
|
||||
|
||||
def mark_watched(user : User, vid : String)
|
||||
def mark_watched(user : User, history_details : JSON::Any)
|
||||
request = <<-SQL
|
||||
UPDATE users
|
||||
SET watched = array_append(array_remove(watched, $1), $1)
|
||||
SET watched = array_append(array_remove(watched, $1::jsonb), $1::jsonb)
|
||||
WHERE email = $2
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, vid, user.email)
|
||||
PG_DB.exec(request, history_details, user.email)
|
||||
end
|
||||
|
||||
def mark_unwatched(user : User, vid : String)
|
||||
|
@ -79,6 +79,16 @@ module Invidious::Database::Users
|
|||
PG_DB.exec(request, user.email)
|
||||
end
|
||||
|
||||
def get_watched(user : User)
|
||||
request = <<-SQL
|
||||
SELECT watched
|
||||
from users
|
||||
where email = $1
|
||||
SQL
|
||||
|
||||
PG_DB.query_one?(request, user.email, &.read(Array(JSON::Any)))
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Update (channels)
|
||||
# -------------------
|
||||
|
@ -119,15 +129,15 @@ module Invidious::Database::Users
|
|||
# Update (notifs)
|
||||
# -------------------
|
||||
|
||||
def add_multiple_notifications(channel_id : String, video_ids : Array(String))
|
||||
def add_notification(video : ChannelVideo)
|
||||
request = <<-SQL
|
||||
UPDATE users
|
||||
SET notifications = array_cat(notifications, $1),
|
||||
SET notifications = array_append(notifications, $1),
|
||||
feed_needs_update = true
|
||||
WHERE $2 = ANY(subscriptions)
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, video_ids, channel_id)
|
||||
PG_DB.exec(request, video.id, video.ucid)
|
||||
end
|
||||
|
||||
def remove_notification(user : User, vid : String)
|
||||
|
@ -154,15 +164,17 @@ module Invidious::Database::Users
|
|||
# Update (misc)
|
||||
# -------------------
|
||||
|
||||
def feed_needs_update(channel_id : String)
|
||||
request = <<-SQL
|
||||
UPDATE users
|
||||
SET feed_needs_update = true
|
||||
WHERE $1 = ANY(subscriptions)
|
||||
SQL
|
||||
# Feeds never need update. PubSubHubBub is the one that sends videos to
|
||||
# invidious.
|
||||
# def feed_needs_update(video : ChannelVideo)
|
||||
# request = <<-SQL
|
||||
# UPDATE users
|
||||
# SET feed_needs_update = true
|
||||
# WHERE $1 = ANY(subscriptions)
|
||||
# SQL
|
||||
|
||||
PG_DB.exec(request, channel_id)
|
||||
end
|
||||
# PG_DB.exec(request, video.ucid)
|
||||
# end
|
||||
|
||||
def update_preferences(user : User)
|
||||
request = <<-SQL
|
||||
|
@ -184,36 +196,6 @@ module Invidious::Database::Users
|
|||
PG_DB.exec(request, pass, user.email)
|
||||
end
|
||||
|
||||
def update_username(user : User, username : String)
|
||||
request = <<-SQL
|
||||
UPDATE users
|
||||
SET email = $1
|
||||
WHERE email = $2
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, username, user.email)
|
||||
end
|
||||
|
||||
def update_user_session_id(user : User, username : String)
|
||||
request = <<-SQL
|
||||
UPDATE session_ids
|
||||
SET email = $1
|
||||
WHERE email = $2
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, username, user.email)
|
||||
end
|
||||
|
||||
def update_user_playlists_author(user : User, username : String)
|
||||
request = <<-SQL
|
||||
UPDATE playlists
|
||||
SET author = $1
|
||||
WHERE author = $2
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, username, user.email)
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Select
|
||||
# -------------------
|
||||
|
|
|
@ -1,181 +1,27 @@
|
|||
require "./base.cr"
|
||||
require "redis"
|
||||
|
||||
VideoCache = Invidious::Database::Videos::Cache.new
|
||||
|
||||
module Invidious::Database::Videos
|
||||
class Cache
|
||||
def initialize
|
||||
case CONFIG.video_cache.backend
|
||||
when 0
|
||||
@cache = CacheMethods::PostgresSQL.new
|
||||
when 1
|
||||
@cache = CacheMethods::Redis_.new
|
||||
when 2
|
||||
@cache = CacheMethods::LRU.new
|
||||
else
|
||||
LOGGER.debug "Video Cache: Using default cache method to store video cache (PostgreSQL)"
|
||||
@cache = CacheMethods::PostgresSQL.new
|
||||
end
|
||||
end
|
||||
|
||||
def set(video : Video, expire_time)
|
||||
@cache.set(video, expire_time)
|
||||
end
|
||||
|
||||
def del(id : String)
|
||||
@cache.del(id)
|
||||
end
|
||||
|
||||
def get(id : String)
|
||||
return @cache.get(id)
|
||||
end
|
||||
end
|
||||
|
||||
module CacheMethods
|
||||
# TODO: Save the cache on a file with a Job
|
||||
class LRU
|
||||
@max_size : Int32
|
||||
@lru = {} of String => String
|
||||
@access = [] of String
|
||||
|
||||
def initialize(@max_size = CONFIG.video_cache.lru_max_size)
|
||||
LOGGER.info "Video Cache: Using in memory LRU to store video cache"
|
||||
LOGGER.info "Video Cache, LRU: LRU cache max size set to #{@max_size}"
|
||||
end
|
||||
|
||||
# TODO: Handle expire_time with a Job
|
||||
def set(video : Video, expire_time)
|
||||
self[video.id] = video.info.to_json
|
||||
self[video.id + ":time"] = "#{video.updated}"
|
||||
end
|
||||
|
||||
def del(id : String)
|
||||
self.delete(id)
|
||||
self.delete(id + ":time")
|
||||
end
|
||||
|
||||
def get(id : String)
|
||||
info = self[id]
|
||||
time = self[id + ":time"]
|
||||
if info && time
|
||||
return Video.new({
|
||||
id: id,
|
||||
info: JSON.parse(info).as_h,
|
||||
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
||||
})
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
private def [](key)
|
||||
if @lru[key]?
|
||||
@access.delete(key)
|
||||
@access.push(key)
|
||||
@lru[key]
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private def []=(key, value)
|
||||
if @lru.size >= @max_size
|
||||
lru_key = @access.shift
|
||||
@lru.delete(lru_key)
|
||||
end
|
||||
@lru[key] = value
|
||||
@access.push(key)
|
||||
end
|
||||
|
||||
private def delete(key)
|
||||
if @lru[key]?
|
||||
@lru.delete(key)
|
||||
@access.delete(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Redis_
|
||||
@redis : Redis::Client
|
||||
|
||||
def initialize
|
||||
@redis = Redis::Client.new(CONFIG.redis_url)
|
||||
LOGGER.info "Video Cache: Using Redis compatible DB to store video cache"
|
||||
LOGGER.info "Connecting to Redis compatible DB"
|
||||
if @redis.ping
|
||||
LOGGER.info "Connected to Redis compatible DB at '#{CONFIG.redis_url}'" if CONFIG.redis_url
|
||||
end
|
||||
end
|
||||
|
||||
def set(video : Video, expire_time)
|
||||
@redis.set(video.id, video.info.to_json, ex: expire_time)
|
||||
@redis.set(video.id + ":time", video.updated.to_s, ex: expire_time)
|
||||
end
|
||||
|
||||
def del(id : String)
|
||||
@redis.del(id)
|
||||
@redis.del(id + ":time")
|
||||
end
|
||||
|
||||
def get(id : String)
|
||||
info = @redis.get(id)
|
||||
time = @redis.get(id + ":time")
|
||||
if info && time
|
||||
return Video.new({
|
||||
id: id,
|
||||
info: JSON.parse(info).as_h,
|
||||
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
||||
})
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class PostgresSQL
|
||||
def initialize
|
||||
LOGGER.info "Video Cache: Using PostgreSQL to store video cache"
|
||||
end
|
||||
|
||||
def set(video : Video, expire_time)
|
||||
request = <<-SQL
|
||||
INSERT INTO videos
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, video.id, video.info.to_json, video.updated)
|
||||
end
|
||||
|
||||
def del(id)
|
||||
request = <<-SQL
|
||||
DELETE FROM videos *
|
||||
WHERE id = $1
|
||||
SQL
|
||||
|
||||
PG_DB.exec(request, id)
|
||||
end
|
||||
|
||||
def get(id : String) : Video?
|
||||
request = <<-SQL
|
||||
SELECT * FROM videos
|
||||
WHERE id = $1
|
||||
SQL
|
||||
|
||||
return PG_DB.query_one?(request, id, as: Video)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
extend self
|
||||
|
||||
def insert(video : Video)
|
||||
VideoCache.set(video: video, expire_time: 14400) if CONFIG.video_cache.enabled
|
||||
request = <<-SQL
|
||||
INSERT INTO videos
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
SQL
|
||||
|
||||
REDIS_DB.set(video.id, video.info.to_json, ex: 14400)
|
||||
REDIS_DB.set(video.id + ":time", video.updated, ex: 14400)
|
||||
end
|
||||
|
||||
def delete(id)
|
||||
VideoCache.del(id)
|
||||
request = <<-SQL
|
||||
DELETE FROM videos *
|
||||
WHERE id = $1
|
||||
SQL
|
||||
|
||||
REDIS_DB.del(id)
|
||||
REDIS_DB.del(id + ":time")
|
||||
end
|
||||
|
||||
def delete_expired
|
||||
|
@ -198,6 +44,19 @@ module Invidious::Database::Videos
|
|||
end
|
||||
|
||||
def select(id : String) : Video?
|
||||
return VideoCache.get(id)
|
||||
request = <<-SQL
|
||||
SELECT * FROM videos
|
||||
WHERE id = $1
|
||||
SQL
|
||||
|
||||
if ((info = REDIS_DB.get(id)) && (time = REDIS_DB.get(id + ":time")))
|
||||
return Video.new({
|
||||
id: id,
|
||||
info: JSON.parse(info).as_h,
|
||||
updated: Time.parse(time, "%Y-%m-%d %H:%M:%S %z", Time::Location::UTC),
|
||||
})
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,9 +7,8 @@ module Invidious::Frontend::ChannelPage
|
|||
Streams
|
||||
Podcasts
|
||||
Releases
|
||||
Courses
|
||||
Playlists
|
||||
Posts
|
||||
Community
|
||||
Channels
|
||||
end
|
||||
|
||||
|
|
|
@ -3,24 +3,6 @@ require "uri"
|
|||
module Invidious::Frontend::Pagination
|
||||
extend self
|
||||
|
||||
private def first_page(str : String::Builder, locale : String?, url : String)
|
||||
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
|
||||
|
||||
if locale_is_rtl?(locale)
|
||||
# Inverted arrow ("first" points to the right)
|
||||
str << translate(locale, "First page")
|
||||
str << " "
|
||||
str << %(<i class="icon ion-ios-arrow-forward"></i>)
|
||||
else
|
||||
# Regular arrow ("first" points to the left)
|
||||
str << %(<i class="icon ion-ios-arrow-back"></i>)
|
||||
str << " "
|
||||
str << translate(locale, "First page")
|
||||
end
|
||||
|
||||
str << "</a>"
|
||||
end
|
||||
|
||||
private def previous_page(str : String::Builder, locale : String?, url : String)
|
||||
# Link
|
||||
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
|
||||
|
@ -90,24 +72,18 @@ module Invidious::Frontend::Pagination
|
|||
end
|
||||
end
|
||||
|
||||
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?, first_page : Bool, params : URI::Params)
|
||||
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
|
||||
return String.build do |str|
|
||||
str << %(<div class="h-box">\n)
|
||||
str << %(<div class="page-nav-container flexible">\n)
|
||||
|
||||
str << %(<div class="page-prev-container flex-left">)
|
||||
|
||||
if !first_page
|
||||
self.first_page(str, locale, base_url.to_s)
|
||||
end
|
||||
|
||||
str << %(</div>\n)
|
||||
str << %(<div class="page-prev-container flex-left"></div>\n)
|
||||
|
||||
str << %(<div class="page-next-container flex-right">)
|
||||
|
||||
if !ctoken.nil?
|
||||
params["continuation"] = ctoken
|
||||
url_next = HttpServer::Utils.add_params_to_url(base_url, params)
|
||||
params_next = URI::Params{"continuation" => ctoken}
|
||||
url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
|
||||
|
||||
self.next_page(str, locale, url_next.to_s)
|
||||
end
|
||||
|
|
|
@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
|
|||
@full_videos,
|
||||
@video_streams,
|
||||
@audio_streams,
|
||||
@captions,
|
||||
@captions
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
module BackendInfo
|
||||
extend self
|
||||
@@exvpp_url : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
|
||||
@@status : Array(Int32) = Array.new(CONFIG.invidious_companion.size, 0)
|
||||
@@csp : Array(String) = Array.new(CONFIG.invidious_companion.size, "")
|
||||
@@mutex : Mutex = Mutex.new
|
||||
|
||||
def check_backends
|
||||
check_companion()
|
||||
end
|
||||
|
||||
private def check_companion
|
||||
CONFIG.invidious_companion.each_with_index do |companion, index|
|
||||
spawn do
|
||||
begin
|
||||
response = HTTP::Client.get "#{companion.private_url}/healthz"
|
||||
if response.status_code == 200
|
||||
check_videoplayback_proxy(companion, index)
|
||||
generate_csp([companion.public_url.to_s, companion.i2p_public_url.to_s], @@exvpp_url[index], index)
|
||||
else
|
||||
@@status[index] = 0
|
||||
end
|
||||
rescue
|
||||
@@status[index] = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def check_videoplayback_proxy(companion : Config::CompanionConfig, index : Int32)
|
||||
begin
|
||||
info = HTTP::Client.get "#{companion.private_url}/info"
|
||||
exvpp_url = JSON.parse(info.body)["external_videoplayback_proxy"]?.try &.to_s
|
||||
rescue JSON::ParseException
|
||||
@@status[index] = 2
|
||||
return
|
||||
end
|
||||
|
||||
exvpp_url = "" if exvpp_url.nil?
|
||||
@@exvpp_url[index] = exvpp_url
|
||||
if exvpp_url.empty?
|
||||
@@status[index] = 2
|
||||
return
|
||||
else
|
||||
begin
|
||||
exvpp_health = HTTP::Client.get "#{exvpp_url}/health"
|
||||
if exvpp_health.status_code == 200
|
||||
@@status[index] = 2
|
||||
return exvpp_url
|
||||
else
|
||||
@@status[index] = 1
|
||||
end
|
||||
rescue
|
||||
@@status[index] = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def generate_csp(companion_url : Array(String), exvpp_url : String? = nil, index : Int32? = nil)
|
||||
@@mutex.synchronize do
|
||||
@@csp[index] = ""
|
||||
companion_url.each do |url|
|
||||
@@csp[index] += " #{url}"
|
||||
end
|
||||
@@csp[index] += " #{exvpp_url}"
|
||||
end
|
||||
end
|
||||
|
||||
def get_status
|
||||
return @@status
|
||||
end
|
||||
|
||||
def get_exvpp
|
||||
return @@exvpp_url
|
||||
end
|
||||
|
||||
def get_csp(index : Int32)
|
||||
# A little mutex to prevent sending a partial CSP header
|
||||
# Not sure if this is necessary. But if the @@csp[index] is being assigned
|
||||
# at the same time when it's being accessed, a data race will appear
|
||||
@@mutex.synchronize do
|
||||
return @@csp[index], @@csp[index]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -18,6 +18,40 @@ end
|
|||
class HTTP::Client
|
||||
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
|
||||
io = @io
|
||||
return io if io
|
||||
|
|
|
@ -130,7 +130,7 @@ def error_json_helper(
|
|||
env : HTTP::Server::Context,
|
||||
status_code : Int32,
|
||||
exception : Exception,
|
||||
additional_fields : Hash(String, Object) | Nil = nil,
|
||||
additional_fields : Hash(String, Object) | Nil = nil
|
||||
)
|
||||
if exception.is_a?(InfoException)
|
||||
return error_json_helper(env, status_code, exception.message || "", additional_fields)
|
||||
|
@ -152,7 +152,7 @@ def error_json_helper(
|
|||
env : HTTP::Server::Context,
|
||||
status_code : Int32,
|
||||
message : String,
|
||||
additional_fields : Hash(String, Object) | Nil = nil,
|
||||
additional_fields : Hash(String, Object) | Nil = nil
|
||||
)
|
||||
env.response.content_type = "application/json"
|
||||
env.response.status_code = status_code
|
||||
|
@ -180,11 +180,8 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
|||
next_steps_text = translate(locale, "next_steps_error_message")
|
||||
refresh = translate(locale, "next_steps_error_message_refresh")
|
||||
go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube")
|
||||
go_to_youtube_embed = translate(locale, "videoinfo_youTube_embed_link")
|
||||
switch_instance = translate(locale, "Switch Invidious Instance")
|
||||
|
||||
show_embed_link = "(<a rel=\"noreferrer noopener\" href=\"https://youtube.com/embed/#{env.params.query["v"]}\">#{go_to_youtube_embed}</a>)" if env.params.query["v"]?
|
||||
|
||||
return <<-END_HTML
|
||||
<p style="margin-bottom: 4px;">#{next_steps_text}</p>
|
||||
<ul>
|
||||
|
@ -196,7 +193,6 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
|||
</li>
|
||||
<li>
|
||||
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
|
||||
#{show_embed_link}
|
||||
</li>
|
||||
</ul>
|
||||
END_HTML
|
||||
|
|
|
@ -27,7 +27,6 @@ class Kemal::RouteHandler
|
|||
# Processes the route if it's a match. Otherwise renders 404.
|
||||
private def process_request(context)
|
||||
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
|
||||
return if context.response.closed?
|
||||
content = context.route.handler.call(context)
|
||||
|
||||
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
|
||||
|
|
|
@ -54,7 +54,6 @@ LOCALES_LIST = {
|
|||
"sr" => "Srpski (latinica)", # Serbian (Latin)
|
||||
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
|
||||
"sv-SE" => "Svenska", # Swedish
|
||||
"ta" => "தமிழ்", # Tamil
|
||||
"tr" => "Türkçe", # Turkish
|
||||
"uk" => "Українська", # Ukrainian
|
||||
"vi" => "Tiếng Việt", # Vietnamese
|
||||
|
|
|
@ -56,11 +56,12 @@ macro templated(_filename, template = "template", navbar_search = true, buffer_f
|
|||
{{ layout = "src/invidious/views/" + template + ".ecr" }}
|
||||
|
||||
__content_filename__ = {{filename}}
|
||||
render {{filename}}, {{layout}}
|
||||
content = Kilt.render({{filename}})
|
||||
Kilt.render({{layout}})
|
||||
end
|
||||
|
||||
macro rendered(filename)
|
||||
render("src/invidious/views/#{{{filename}}}.ecr")
|
||||
Kilt.render("src/invidious/views/#{{{filename}}}.ecr")
|
||||
end
|
||||
|
||||
# Similar to Kemals halt method but works in a
|
||||
|
|
|
@ -24,7 +24,6 @@ struct SearchVideo
|
|||
property length_seconds : Int32
|
||||
property premiere_timestamp : Time?
|
||||
property author_verified : Bool
|
||||
property author_thumbnail : String?
|
||||
property badges : VideoBadges
|
||||
|
||||
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
||||
|
@ -89,24 +88,6 @@ struct SearchVideo
|
|||
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||
json.field "authorVerified", self.author_verified
|
||||
|
||||
author_thumbnail = self.author_thumbnail
|
||||
|
||||
if author_thumbnail
|
||||
json.field "authorThumbnails" do
|
||||
json.array do
|
||||
qualities = {32, 48, 76, 100, 176, 512}
|
||||
|
||||
qualities.each do |quality|
|
||||
json.object do
|
||||
json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
||||
json.field "width", quality
|
||||
json.field "height", quality
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json.field "videoThumbnails" do
|
||||
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
||||
end
|
||||
|
@ -242,7 +223,7 @@ struct SearchChannel
|
|||
|
||||
qualities.each do |quality|
|
||||
json.object do
|
||||
json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
||||
json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
|
||||
json.field "width", quality
|
||||
json.field "height", quality
|
||||
end
|
||||
|
|
|
@ -10,7 +10,7 @@ module SessionTokens
|
|||
LOGGER.error("RefreshSessionTokens: Expected response to have status code 200 but got #{response.status_code} from #{CONFIG.tokens_server}")
|
||||
end
|
||||
json = JSON.parse(response.body)
|
||||
@@po_token = json.try &.["potoken"].as_s || nil
|
||||
@@po_token = json.try &.["potoken"].as_s || nil
|
||||
@@visitor_data = json.try &.["visitorData"].as_s || nil
|
||||
rescue ex
|
||||
LOGGER.error("RefreshSessionTokens: Failed to fetch tokens from #{CONFIG.tokens_server}: #{ex.message}")
|
||||
|
|
|
@ -384,27 +384,18 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
|
|||
return text
|
||||
end
|
||||
|
||||
def decrypt_ecb_without_salt(data, key)
|
||||
cipher = OpenSSL::Cipher.new("aes-128-ecb")
|
||||
cipher.decrypt
|
||||
cipher.key = key
|
||||
cipher.padding = false
|
||||
|
||||
io = IO::Memory.new
|
||||
io.write(cipher.update(data))
|
||||
io.write(cipher.final)
|
||||
io.rewind
|
||||
|
||||
data_ = io.to_s
|
||||
padding = data_[-1].ord
|
||||
|
||||
return data_[0...(data_.bytesize - padding)]
|
||||
end
|
||||
|
||||
def video_playback_decrypt(data)
|
||||
data = Base64.decode(data)
|
||||
decrypted_query = decrypt_ecb_without_salt(data, CONFIG.invidious_companion_key)
|
||||
return decrypted_query
|
||||
# Generates a list of external videoplayback proxies for
|
||||
# CSP
|
||||
def gen_videoplayback_proxy_list
|
||||
if !CONFIG.external_videoplayback_proxy.empty?
|
||||
external_videoplayback_proxy = ""
|
||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
||||
external_videoplayback_proxy += " #{proxy}"
|
||||
end
|
||||
else
|
||||
external_videoplayback_proxy = ""
|
||||
end
|
||||
return external_videoplayback_proxy
|
||||
end
|
||||
|
||||
def encrypt_ecb_without_salt(data, key)
|
||||
|
|
|
@ -4,6 +4,30 @@ module Invidious::HttpServer
|
|||
module Utils
|
||||
extend self
|
||||
|
||||
@@proxy_alive : String = ""
|
||||
|
||||
def check_external_proxy
|
||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
||||
begin
|
||||
response = HTTP::Client.get("#{proxy}/health")
|
||||
if response.status_code == 200
|
||||
@@proxy_alive = proxy
|
||||
LOGGER.debug("CheckExternalProxy: Proxy set to: '#{proxy}'")
|
||||
break
|
||||
end
|
||||
rescue
|
||||
LOGGER.debug("CheckExternalProxy: Proxy '#{proxy}' is not available")
|
||||
end
|
||||
end
|
||||
if @@proxy_alive.empty?
|
||||
LOGGER.warn("CheckExternalProxy: No proxies alive! Using own server proxy")
|
||||
end
|
||||
end
|
||||
|
||||
def get_external_proxy
|
||||
return @@proxy_alive
|
||||
end
|
||||
|
||||
def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false)
|
||||
url = URI.parse(raw_url)
|
||||
|
||||
|
@ -14,7 +38,11 @@ module Invidious::HttpServer
|
|||
url.query_params = params
|
||||
|
||||
if absolute
|
||||
return "#{HOST_URL}#{url.request_target}"
|
||||
if !@@proxy_alive.empty?
|
||||
return "#{@@proxy_alive}#{url.request_target}"
|
||||
else
|
||||
return "#{HOST_URL}#{url.request_target}"
|
||||
end
|
||||
else
|
||||
return url.request_target
|
||||
end
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
class Invidious::Jobs::CheckBackend < Invidious::Jobs::BaseJob
|
||||
def initialize
|
||||
end
|
||||
|
||||
def begin
|
||||
loop do
|
||||
BackendInfo.check_backends
|
||||
LOGGER.info("Backend Checker: Done, sleeping for #{CONFIG.check_backends_interval} seconds")
|
||||
sleep CONFIG.check_backends_interval.seconds
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
13
src/invidious/jobs/check_external_proxy.cr
Normal file
13
src/invidious/jobs/check_external_proxy.cr
Normal file
|
@ -0,0 +1,13 @@
|
|||
class Invidious::Jobs::CheckExternalProxy < Invidious::Jobs::BaseJob
|
||||
def initialize
|
||||
end
|
||||
|
||||
def begin
|
||||
loop do
|
||||
HttpServer::Utils.check_external_proxy
|
||||
LOGGER.info("CheckExternalProxy: Done, sleeping for 10 seconds")
|
||||
sleep 10.seconds
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,32 +1,8 @@
|
|||
struct VideoNotification
|
||||
getter video_id : String
|
||||
getter channel_id : String
|
||||
getter published : Time
|
||||
|
||||
def_hash @channel_id, @video_id
|
||||
|
||||
def ==(other)
|
||||
video_id == other.video_id
|
||||
end
|
||||
|
||||
def self.from_video(video : ChannelVideo) : self
|
||||
VideoNotification.new(video.id, video.ucid, video.published)
|
||||
end
|
||||
|
||||
def initialize(@video_id, @channel_id, @published)
|
||||
end
|
||||
|
||||
def clone : VideoNotification
|
||||
VideoNotification.new(video_id.clone, channel_id.clone, published.clone)
|
||||
end
|
||||
end
|
||||
|
||||
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
||||
private getter notification_channel : ::Channel(VideoNotification)
|
||||
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
|
||||
private getter pg_url : URI
|
||||
|
||||
def initialize(@notification_channel, @connection_channel, @pg_url)
|
||||
def initialize(@connection_channel, @pg_url)
|
||||
end
|
||||
|
||||
def begin
|
||||
|
@ -34,70 +10,6 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
|||
|
||||
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
|
||||
|
||||
# hash of channels to their videos (id+published) that need notifying
|
||||
to_notify = Hash(String, Set(VideoNotification)).new(
|
||||
->(hash : Hash(String, Set(VideoNotification)), key : String) {
|
||||
hash[key] = Set(VideoNotification).new
|
||||
}
|
||||
)
|
||||
notify_mutex = Mutex.new
|
||||
|
||||
# fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job)
|
||||
spawn do
|
||||
begin
|
||||
loop do
|
||||
notification = notification_channel.receive
|
||||
notify_mutex.synchronize do
|
||||
to_notify[notification.channel_id] << notification
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# fiber to regularly persist all cached notifications
|
||||
spawn do
|
||||
loop do
|
||||
begin
|
||||
LOGGER.debug("NotificationJob: waking up")
|
||||
cloned = {} of String => Set(VideoNotification)
|
||||
notify_mutex.synchronize do
|
||||
cloned = to_notify.clone
|
||||
to_notify.clear
|
||||
end
|
||||
|
||||
cloned.each do |channel_id, notifications|
|
||||
if notifications.empty?
|
||||
next
|
||||
end
|
||||
|
||||
LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications")
|
||||
if CONFIG.enable_user_notifications
|
||||
video_ids = notifications.map(&.video_id)
|
||||
Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids)
|
||||
PG_DB.using_connection do |conn|
|
||||
notifications.each do |n|
|
||||
# Deliver notifications to `/api/v1/auth/notifications`
|
||||
payload = {
|
||||
"topic" => n.channel_id,
|
||||
"videoId" => n.video_id,
|
||||
"published" => n.published.to_unix,
|
||||
}.to_json
|
||||
conn.exec("NOTIFY notifications, E'#{payload}'")
|
||||
end
|
||||
end
|
||||
else
|
||||
Invidious::Database::Users.feed_needs_update(channel_id)
|
||||
end
|
||||
end
|
||||
|
||||
LOGGER.trace("NotificationJob: Done, sleeping")
|
||||
rescue ex
|
||||
LOGGER.error("NotificationJob: #{ex.message}")
|
||||
end
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
loop do
|
||||
action, connection = connection_channel.receive
|
||||
|
||||
|
|
|
@ -267,12 +267,6 @@ module Invidious::JSONify::APIv1
|
|||
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
|
||||
json.field "viewCountText", rv["short_view_count"]?
|
||||
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
|
||||
json.field "published", rv["published"]?
|
||||
if rv["published"]?.try &.presence
|
||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
|
||||
else
|
||||
json.field "publishedText", ""
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
|||
})
|
||||
end
|
||||
|
||||
def template_mix(mix, listen)
|
||||
def template_mix(mix)
|
||||
html = <<-END_HTML
|
||||
<h3>
|
||||
<a href="/mix?list=#{mix["mixId"]}">
|
||||
|
@ -95,7 +95,7 @@ def template_mix(mix, listen)
|
|||
mix["videos"].as_a.each do |video|
|
||||
html += <<-END_HTML
|
||||
<li class="pure-menu-item">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
|
||||
<div class="thumbnail">
|
||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||
|
|
|
@ -505,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
|
|||
return videos
|
||||
end
|
||||
|
||||
def template_playlist(playlist, listen)
|
||||
def template_playlist(playlist)
|
||||
html = <<-END_HTML
|
||||
<h3>
|
||||
<a href="/playlist?list=#{playlist["playlistId"]}">
|
||||
|
@ -519,7 +519,7 @@ def template_playlist(playlist, listen)
|
|||
playlist["videos"].as_a.each do |video|
|
||||
html += <<-END_HTML
|
||||
<li class="pure-menu-item" id="#{video["videoId"]}">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
|
||||
<div class="thumbnail">
|
||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||
|
|
|
@ -78,75 +78,6 @@ module Invidious::Routes::Account
|
|||
env.redirect referer
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Username update
|
||||
# -------------------
|
||||
|
||||
# Show the username change interface (GET request)
|
||||
def get_change_username(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
user = env.get? "user"
|
||||
sid = env.get? "sid"
|
||||
referer = get_referer(env)
|
||||
|
||||
if !user
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
user = user.as(User)
|
||||
sid = sid.as(String)
|
||||
csrf_token = generate_response(sid, {":change_username"}, HMAC_KEY)
|
||||
|
||||
templated "user/change_username"
|
||||
end
|
||||
|
||||
# Handle the username change (POST request)
|
||||
def post_change_username(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
user = env.get? "user"
|
||||
sid = env.get? "sid"
|
||||
referer = get_referer(env)
|
||||
|
||||
if !user
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
user = user.as(User)
|
||||
sid = sid.as(String)
|
||||
token = env.params.body["csrf_token"]?
|
||||
|
||||
begin
|
||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||
rescue ex
|
||||
return error_template(400, ex)
|
||||
end
|
||||
|
||||
new_username = env.params.body["new_username"]?.try &.downcase.byte_slice(0, 254)
|
||||
if new_username.nil?
|
||||
return error_template(401, "username_required_field")
|
||||
end
|
||||
|
||||
if new_username.empty?
|
||||
return error_template(401, "username_empty")
|
||||
end
|
||||
|
||||
if new_username == user.email
|
||||
return error_template(401, "username_is_the_same")
|
||||
end
|
||||
|
||||
if Invidious::Database::Users.select(email: new_username)
|
||||
return error_template(401, "username_taken")
|
||||
end
|
||||
|
||||
Invidious::Database::Users.update_username(user, new_username.to_s)
|
||||
Invidious::Database::Users.update_user_session_id(user, new_username.to_s)
|
||||
Invidious::Database::Users.update_user_playlists_author(user, new_username.to_s)
|
||||
|
||||
env.redirect referer
|
||||
end
|
||||
|
||||
# -------------------
|
||||
# Account deletion
|
||||
# -------------------
|
||||
|
@ -395,9 +326,17 @@ module Invidious::Routes::Account
|
|||
end
|
||||
end
|
||||
|
||||
case action = env.params.query["action"]?
|
||||
when "revoke_token"
|
||||
session = env.params.query["session"]
|
||||
if env.params.query["action_revoke_token"]?
|
||||
action = "action_revoke_token"
|
||||
else
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
session = env.params.query["session"]?
|
||||
session ||= ""
|
||||
|
||||
case action
|
||||
when .starts_with? "action_revoke_token"
|
||||
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
|
||||
else
|
||||
return error_json(400, "Unsupported action #{action}")
|
||||
|
|
|
@ -9,8 +9,8 @@ module Invidious::Routes::API::Manifest
|
|||
region = env.params.query["region"]?
|
||||
|
||||
if CONFIG.invidious_companion.present?
|
||||
companion_public_url = env.get("companion_public_url").as(String)
|
||||
return env.redirect "#{companion_public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
|
||||
invidious_companion = CONFIG.invidious_companion.sample
|
||||
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
|
||||
end
|
||||
|
||||
# Since some implementations create playlists based on resolution regardless of different codecs,
|
||||
|
@ -60,6 +60,10 @@ module Invidious::Routes::API::Manifest
|
|||
end
|
||||
end
|
||||
|
||||
audio_streams.reject! do |z|
|
||||
z if z.dig?("audioTrack", "audioIsDefault") == false
|
||||
end
|
||||
|
||||
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
||||
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
|
||||
"profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static",
|
||||
|
@ -75,23 +79,17 @@ module Invidious::Routes::API::Manifest
|
|||
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
|
||||
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
|
||||
|
||||
audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any
|
||||
lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und"
|
||||
is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0
|
||||
displayname = audio_track["displayName"]?.try &.as_s || "Unknown"
|
||||
bitrate = fmt["bitrate"]
|
||||
|
||||
# Different representations of the same audio should be groupped into one AdaptationSet.
|
||||
# However, most players don't support auto quality switching, so we have to trick them
|
||||
# into providing a quality selector.
|
||||
# See https://github.com/iv-org/invidious/issues/3074 for more details.
|
||||
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do
|
||||
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
|
||||
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
|
||||
bandwidth = fmt["bitrate"].as_i
|
||||
itag = fmt["itag"].as_i
|
||||
url = fmt["url"].as_s
|
||||
|
||||
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate")
|
||||
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
|
||||
|
||||
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
|
||||
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
|
||||
|
@ -188,9 +186,8 @@ module Invidious::Routes::API::Manifest
|
|||
manifest = response.body
|
||||
|
||||
if local
|
||||
manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match|
|
||||
uri = URI.parse(match)
|
||||
path = uri.path
|
||||
manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
|
||||
path = URI.parse(match).path
|
||||
|
||||
path = path.lchop("/videoplayback/")
|
||||
path = path.rchop("/")
|
||||
|
@ -219,7 +216,9 @@ module Invidious::Routes::API::Manifest
|
|||
raw_params["fvip"] = fvip["fvip"]
|
||||
end
|
||||
|
||||
raw_params["host"] = uri.host.not_nil!
|
||||
raw_params["local"] = "true"
|
||||
|
||||
proxy = Invidious::HttpServer::Utils.get_external_proxy
|
||||
|
||||
if CONFIG.https_only
|
||||
scheme = "https://"
|
||||
|
@ -227,7 +226,11 @@ module Invidious::Routes::API::Manifest
|
|||
scheme = "http://"
|
||||
end
|
||||
|
||||
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}"
|
||||
if !proxy.empty?
|
||||
"#{proxy}/videoplayback?#{raw_params}"
|
||||
else
|
||||
"#{scheme}#{env.request.headers["Host"]}/videoplayback?#{raw_params}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -86,7 +86,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||
return error_json(400, "Invalid video id.")
|
||||
end
|
||||
|
||||
Invidious::Database::Users.mark_watched(user, id)
|
||||
# Invidious::Database::Users.mark_watched(user, id)
|
||||
env.response.status_code = 204
|
||||
end
|
||||
|
||||
|
@ -482,7 +482,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||
env.response.content_type = "text/event-stream"
|
||||
|
||||
raw_topics = env.params.body["topics"]? || env.params.query["topics"]?
|
||||
topics = raw_topics.try &.split(",").uniq!.first(1000)
|
||||
topics = raw_topics.try &.split(",").uniq.first(1000)
|
||||
topics ||= [] of String
|
||||
|
||||
create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||
|
|
|
@ -368,35 +368,6 @@ module Invidious::Routes::API::V1::Channels
|
|||
end
|
||||
end
|
||||
|
||||
def self.courses(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
env.response.content_type = "application/json"
|
||||
|
||||
ucid = env.params.url["ucid"]
|
||||
continuation = env.params.query["continuation"]?
|
||||
|
||||
# Use the macro defined above
|
||||
channel = nil # Make the compiler happy
|
||||
get_channel()
|
||||
|
||||
items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
|
||||
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
json.field "playlists" do
|
||||
json.array do
|
||||
items.each do |item|
|
||||
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json.field "continuation", next_continuation if next_continuation
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.community(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
|
|
|
@ -42,9 +42,6 @@ module Invidious::Routes::API::V1::Misc
|
|||
format = env.params.query["format"]?
|
||||
format ||= "json"
|
||||
|
||||
listen_param = env.params.query["listen"]?
|
||||
listen = (listen_param == "true" || listen_param == "1")
|
||||
|
||||
if plid.starts_with? "RD"
|
||||
return env.redirect "/api/v1/mixes/#{plid}"
|
||||
end
|
||||
|
@ -88,7 +85,7 @@ module Invidious::Routes::API::V1::Misc
|
|||
end
|
||||
|
||||
if format == "html"
|
||||
playlist_html = template_playlist(json_response, listen)
|
||||
playlist_html = template_playlist(json_response)
|
||||
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
|
||||
|
||||
response = {
|
||||
|
@ -114,9 +111,6 @@ module Invidious::Routes::API::V1::Misc
|
|||
format = env.params.query["format"]?
|
||||
format ||= "json"
|
||||
|
||||
listen_param = env.params.query["listen"]?
|
||||
listen = (listen_param == "true" || listen_param == "1")
|
||||
|
||||
begin
|
||||
mix = fetch_mix(rdid, continuation, locale: locale)
|
||||
|
||||
|
@ -147,7 +141,9 @@ module Invidious::Routes::API::V1::Misc
|
|||
json.field "authorUrl", "/channel/#{video.ucid}"
|
||||
|
||||
json.field "videoThumbnails" do
|
||||
Invidious::JSONify::APIv1.thumbnails(json, video.id)
|
||||
json.array do
|
||||
Invidious::JSONify::APIv1.thumbnails(json, video.id)
|
||||
end
|
||||
end
|
||||
|
||||
json.field "index", video.index
|
||||
|
@ -161,7 +157,7 @@ module Invidious::Routes::API::V1::Misc
|
|||
|
||||
if format == "html"
|
||||
response = JSON.parse(response)
|
||||
playlist_html = template_mix(response, listen)
|
||||
playlist_html = template_mix(response)
|
||||
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
|
||||
|
||||
response = {
|
||||
|
|
|
@ -263,59 +263,60 @@ module Invidious::Routes::API::V1::Videos
|
|||
|
||||
annotations = ""
|
||||
|
||||
case source
|
||||
when "archive"
|
||||
if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id))
|
||||
annotations = cached_annotation.annotations
|
||||
else
|
||||
index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0')
|
||||
# case source
|
||||
# when "archive"
|
||||
# if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id))
|
||||
# annotations = cached_annotation.annotations
|
||||
# else
|
||||
# index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0')
|
||||
|
||||
# IA doesn't handle leading hyphens,
|
||||
# so we use https://archive.org/details/youtubeannotations_64
|
||||
if index == "62"
|
||||
index = "64"
|
||||
id = id.sub(/^-/, 'A')
|
||||
end
|
||||
# # IA doesn't handle leading hyphens,
|
||||
# # so we use https://archive.org/details/youtubeannotations_64
|
||||
# if index == "62"
|
||||
# index = "64"
|
||||
# id = id.sub(/^-/, 'A')
|
||||
# end
|
||||
|
||||
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
|
||||
# file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
|
||||
|
||||
location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
|
||||
# location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
|
||||
|
||||
if !location.headers["Location"]?
|
||||
env.response.status_code = location.status_code
|
||||
end
|
||||
# if !location.headers["Location"]?
|
||||
# env.response.status_code = location.status_code
|
||||
# end
|
||||
|
||||
response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
|
||||
# response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
|
||||
|
||||
if response.body.empty?
|
||||
haltf env, 404
|
||||
end
|
||||
# if response.body.empty?
|
||||
# haltf env, 404
|
||||
# end
|
||||
|
||||
if response.status_code != 200
|
||||
haltf env, response.status_code
|
||||
end
|
||||
# if response.status_code != 200
|
||||
# haltf env, response.status_code
|
||||
# end
|
||||
|
||||
annotations = response.body
|
||||
# annotations = response.body
|
||||
|
||||
cache_annotation(id, annotations)
|
||||
end
|
||||
else # "youtube"
|
||||
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
|
||||
# cache_annotation(id, annotations)
|
||||
# end
|
||||
# else # "youtube"
|
||||
# response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
|
||||
|
||||
if response.status_code != 200
|
||||
haltf env, response.status_code
|
||||
end
|
||||
# if response.status_code != 200
|
||||
# haltf env, response.status_code
|
||||
# end
|
||||
|
||||
annotations = response.body
|
||||
end
|
||||
# annotations = response.body
|
||||
# end
|
||||
|
||||
etag = sha256(annotations)[0, 16]
|
||||
if env.request.headers["If-None-Match"]?.try &.== etag
|
||||
haltf env, 304
|
||||
else
|
||||
env.response.headers["ETag"] = etag
|
||||
annotations
|
||||
end
|
||||
# etag = sha256(annotations)[0, 16]
|
||||
# if env.request.headers["If-None-Match"]?.try &.== etag
|
||||
# haltf env, 304
|
||||
# else
|
||||
# env.response.headers["ETag"] = etag
|
||||
# annotations
|
||||
# end
|
||||
annotations
|
||||
end
|
||||
|
||||
def self.comments(env)
|
||||
|
@ -429,90 +430,4 @@ module Invidious::Routes::API::V1::Videos
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches transcripts from YouTube
|
||||
#
|
||||
# Use the `lang` and `autogen` query parameter to select which transcript to fetch
|
||||
# Request without any URL parameters to see all the available transcripts.
|
||||
def self.transcripts(env)
|
||||
env.response.content_type = "application/json"
|
||||
|
||||
id = env.params.url["id"]
|
||||
lang = env.params.query["lang"]?
|
||||
label = env.params.query["label"]?
|
||||
auto_generated = env.params.query["autogen"]? ? true : false
|
||||
|
||||
# Return all available transcript options when none is given
|
||||
if !label && !lang
|
||||
begin
|
||||
video = get_video(id)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
||||
response = JSON.build do |json|
|
||||
# The amount of transcripts available to fetch is the
|
||||
# same as the amount of captions available.
|
||||
available_transcripts = video.captions
|
||||
|
||||
json.object do
|
||||
json.field "transcripts" do
|
||||
json.array do
|
||||
available_transcripts.each do |transcript|
|
||||
json.object do
|
||||
json.field "label", transcript.name
|
||||
json.field "languageCode", transcript.language_code
|
||||
json.field "autoGenerated", transcript.auto_generated
|
||||
|
||||
if transcript.auto_generated
|
||||
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen"
|
||||
else
|
||||
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return response
|
||||
end
|
||||
|
||||
# If lang is not given then we attempt to fetch
|
||||
# the transcript through the given label
|
||||
if lang.nil?
|
||||
begin
|
||||
video = get_video(id)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
||||
target_transcript = video.captions.select(&.name.== label)
|
||||
if target_transcript.empty?
|
||||
return error_json(404, NotFoundException.new("Requested transcript does not exist"))
|
||||
else
|
||||
target_transcript = target_transcript[0]
|
||||
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
|
||||
end
|
||||
end
|
||||
|
||||
params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated)
|
||||
|
||||
begin
|
||||
transcript = Invidious::Videos::Transcript.from_raw(
|
||||
YoutubeAPI.get_transcript(params), lang, auto_generated
|
||||
)
|
||||
rescue ex : NotFoundException
|
||||
return error_json(404, ex)
|
||||
rescue ex
|
||||
return error_json(500, ex)
|
||||
end
|
||||
|
||||
return transcript.to_json
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
{% skip_file if flag?(:api_only) %}
|
||||
|
||||
module Invidious::Routes::BackendSwitcher
|
||||
def self.switch(env)
|
||||
referer = get_referer(env)
|
||||
backend_id = env.params.query["backend_id"]?.try &.to_i
|
||||
|
||||
if backend_id.nil?
|
||||
return error_template(400, "Backend ID is required")
|
||||
end
|
||||
|
||||
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(env.request.headers["Host"], backend_id)
|
||||
|
||||
env.redirect referer
|
||||
end
|
||||
end
|
|
@ -1,7 +1,6 @@
|
|||
module Invidious::Routes::BeforeAll
|
||||
def self.handle(env)
|
||||
preferences = Preferences.from_json("{}")
|
||||
host = env.request.headers["Host"]
|
||||
|
||||
begin
|
||||
if prefs_cookie = env.request.cookies["PREFS"]?
|
||||
|
@ -25,50 +24,15 @@ module Invidious::Routes::BeforeAll
|
|||
extra_connect_csp = ""
|
||||
|
||||
if CONFIG.invidious_companion.present?
|
||||
current_companion_d = host.split(".")[0].scan(/(\d+)$/).last?.try &.[0].to_i
|
||||
extra_media_csp = " #{CONFIG.invidious_companion.sample.public_url}"
|
||||
extra_connect_csp = " #{CONFIG.invidious_companion.sample.public_url}"
|
||||
end
|
||||
|
||||
if current_companion_d
|
||||
current_companion_d = current_companion_d - 1
|
||||
env.set "using_domain", true
|
||||
env.set "current_companion", current_companion_d
|
||||
env.set "companion_public_url", CONFIG.invidious_companion[current_companion_d].public_url.to_s
|
||||
else
|
||||
if !env.request.cookies[CONFIG.server_id_cookie_name]?
|
||||
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host)
|
||||
end
|
||||
|
||||
begin
|
||||
current_companion = env.request.cookies[CONFIG.server_id_cookie_name].value.try &.to_i
|
||||
rescue
|
||||
current_companion = rand(CONFIG.invidious_companion.size)
|
||||
end
|
||||
|
||||
if current_companion > CONFIG.invidious_companion.size
|
||||
current_companion = current_companion % CONFIG.invidious_companion.size
|
||||
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
|
||||
end
|
||||
|
||||
companion_status = BackendInfo.get_status
|
||||
|
||||
if companion_status[current_companion] != 2
|
||||
alive_companion = companion_status.index(2)
|
||||
if alive_companion
|
||||
env.set "companion_switched", true
|
||||
current_companion = alive_companion
|
||||
env.response.cookies[CONFIG.server_id_cookie_name] = Invidious::User::Cookies.server_id(host, current_companion)
|
||||
end
|
||||
end
|
||||
|
||||
env.set "current_companion", current_companion
|
||||
|
||||
if host.split(".").last == "i2p"
|
||||
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].i2p_public_url.to_s
|
||||
else
|
||||
env.set "companion_public_url", CONFIG.invidious_companion[current_companion].public_url.to_s
|
||||
end
|
||||
if !CONFIG.external_videoplayback_proxy.empty?
|
||||
CONFIG.external_videoplayback_proxy.each do |proxy|
|
||||
extra_media_csp += " #{proxy}"
|
||||
extra_connect_csp += " #{proxy}"
|
||||
end
|
||||
|
||||
extra_media_csp, extra_connect_csp = BackendInfo.get_csp(env.get("current_companion").as(Int32))
|
||||
end
|
||||
|
||||
# Allow media resources to be loaded from google servers
|
||||
|
@ -84,16 +48,13 @@ module Invidious::Routes::BeforeAll
|
|||
frame_ancestors = "'none'"
|
||||
end
|
||||
|
||||
scheme = env.request.headers["X-Forwarded-Proto"]? || ("https" if CONFIG.https_only) || "http"
|
||||
env.set "scheme", scheme
|
||||
|
||||
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
|
||||
# inline styles (<style> [..] </style>, style=" [..] ")
|
||||
env.response.headers["Content-Security-Policy"] = {
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: " + "#{scheme}://#{env.request.headers["Host"]?}",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'" + extra_connect_csp,
|
||||
"manifest-src 'self'",
|
||||
|
@ -101,7 +62,7 @@ module Invidious::Routes::BeforeAll
|
|||
"child-src 'self' blob:",
|
||||
"frame-src 'self'",
|
||||
"frame-ancestors " + frame_ancestors,
|
||||
}.join("; ") if CONFIG.csp
|
||||
}.join("; ")
|
||||
|
||||
env.response.headers["Referrer-Policy"] = "same-origin"
|
||||
|
||||
|
|
|
@ -197,29 +197,7 @@ module Invidious::Routes::Channels
|
|||
templated "channel"
|
||||
end
|
||||
|
||||
def self.courses(env)
|
||||
data = self.fetch_basic_information(env)
|
||||
return data if !data.is_a?(Tuple)
|
||||
|
||||
locale, user, subscriptions, continuation, ucid, channel = data
|
||||
|
||||
sort_by = ""
|
||||
sort_options = [] of String
|
||||
|
||||
items, next_continuation = fetch_channel_courses(
|
||||
channel.ucid, channel.author, continuation
|
||||
)
|
||||
|
||||
items = items.select(SearchPlaylist)
|
||||
items.each(&.author = "")
|
||||
|
||||
selected_tab = Frontend::ChannelPage::TabsAvailable::Courses
|
||||
templated "channel"
|
||||
end
|
||||
|
||||
def self.community(env)
|
||||
return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts"
|
||||
|
||||
data = self.fetch_basic_information(env)
|
||||
if !data.is_a?(Tuple)
|
||||
return data
|
||||
|
@ -236,7 +214,7 @@ module Invidious::Routes::Channels
|
|||
|
||||
continuation = env.params.query["continuation"]?
|
||||
|
||||
if !channel.tabs.includes? "community" && "posts"
|
||||
if !channel.tabs.includes? "community"
|
||||
return env.redirect "/channel/#{channel.ucid}"
|
||||
end
|
||||
|
||||
|
@ -329,8 +307,7 @@ module Invidious::Routes::Channels
|
|||
|
||||
private KNOWN_TABS = {
|
||||
"home", "videos", "shorts", "streams", "podcasts",
|
||||
"releases", "courses", "playlists", "community", "channels", "about",
|
||||
"posts",
|
||||
"releases", "playlists", "community", "channels", "about",
|
||||
}
|
||||
|
||||
# Redirects brand url channels to a normal /channel/:ucid route
|
||||
|
|
|
@ -124,7 +124,7 @@ module Invidious::Routes::Embed
|
|||
user = env.get?("user").try &.as(User)
|
||||
if user
|
||||
subscriptions = user.subscriptions
|
||||
watched = user.watched
|
||||
# watched = user.watched
|
||||
notifications = user.notifications
|
||||
end
|
||||
subscriptions ||= [] of String
|
||||
|
@ -203,11 +203,6 @@ module Invidious::Routes::Embed
|
|||
return env.redirect url
|
||||
end
|
||||
|
||||
if CONFIG.invidious_companion.present?
|
||||
current_companion = env.get("current_companion").as(Int32)
|
||||
invidious_companion = CONFIG.invidious_companion[current_companion]
|
||||
end
|
||||
|
||||
rendered "embed"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -123,15 +123,17 @@ module Invidious::Routes::Feeds
|
|||
end
|
||||
|
||||
user = user.as(User)
|
||||
watched = Invidious::Database::Users.get_watched(user)
|
||||
|
||||
max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||
max_results ||= user.preferences.max_results
|
||||
max_results ||= CONFIG.default_user_preferences.max_results
|
||||
|
||||
if user.watched[(page - 1) * max_results]?
|
||||
watched = user.watched.reverse[(page - 1) * max_results, max_results]
|
||||
end
|
||||
watched ||= [] of String
|
||||
# TODO: History!!
|
||||
# if user.watched[(page - 1) * max_results]?
|
||||
# watched = user.watched.reverse[(page - 1) * max_results, max_results]
|
||||
# end
|
||||
# watched ||= [] of String
|
||||
|
||||
# Used for pagination links
|
||||
base_url = "/feed/history"
|
||||
|
@ -143,25 +145,32 @@ module Invidious::Routes::Feeds
|
|||
# RSS feeds
|
||||
|
||||
def self.rss_channel(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
|
||||
env.response.headers["Content-Type"] = "application/atom+xml"
|
||||
env.response.content_type = "application/atom+xml"
|
||||
|
||||
if env.params.url["ucid"].matches?(/^[\w-]+$/)
|
||||
ucid = env.params.url["ucid"]
|
||||
else
|
||||
return error_atom(400, InfoException.new("Invalid channel ucid provided."))
|
||||
end
|
||||
ucid = env.params.url["ucid"]
|
||||
|
||||
params = HTTP::Params.parse(env.params.query["params"]? || "")
|
||||
|
||||
begin
|
||||
channel = get_about_info(ucid, locale)
|
||||
rescue ex : ChannelRedirect
|
||||
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
|
||||
rescue ex : NotFoundException
|
||||
return error_atom(404, ex)
|
||||
rescue ex
|
||||
return error_atom(500, ex)
|
||||
end
|
||||
|
||||
namespaces = {
|
||||
"yt" => "http://www.youtube.com/xml/schemas/2015",
|
||||
"media" => "http://search.yahoo.com/mrss/",
|
||||
"default" => "http://www.w3.org/2005/Atom",
|
||||
}
|
||||
|
||||
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
|
||||
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
|
||||
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
|
||||
rss = XML.parse(response.body)
|
||||
|
||||
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
|
||||
|
@ -172,7 +181,7 @@ module Invidious::Routes::Feeds
|
|||
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
|
||||
|
||||
author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content
|
||||
video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
|
||||
ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content
|
||||
description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s
|
||||
views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64
|
||||
|
||||
|
@ -180,44 +189,41 @@ module Invidious::Routes::Feeds
|
|||
title: title,
|
||||
id: video_id,
|
||||
author: author,
|
||||
ucid: video_ucid,
|
||||
ucid: ucid,
|
||||
published: published,
|
||||
views: views,
|
||||
description_html: description_html,
|
||||
length_seconds: 0,
|
||||
premiere_timestamp: nil,
|
||||
author_verified: false,
|
||||
author_thumbnail: nil,
|
||||
badges: VideoBadges::None,
|
||||
})
|
||||
end
|
||||
|
||||
author = ""
|
||||
author = videos[0].author if videos.size > 0
|
||||
|
||||
XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
||||
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
|
||||
"xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
|
||||
"xml:lang": "en-US") do
|
||||
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
|
||||
xml.element("id") { xml.text "yt:channel:#{ucid}" }
|
||||
xml.element("yt:channelId") { xml.text ucid }
|
||||
xml.element("title") { author }
|
||||
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}")
|
||||
xml.element("id") { xml.text "yt:channel:#{channel.ucid}" }
|
||||
xml.element("yt:channelId") { xml.text channel.ucid }
|
||||
xml.element("icon") { xml.text channel.author_thumbnail }
|
||||
xml.element("title") { xml.text channel.author }
|
||||
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
|
||||
|
||||
xml.element("author") do
|
||||
xml.element("name") { xml.text author }
|
||||
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
|
||||
xml.element("name") { xml.text channel.author }
|
||||
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
|
||||
end
|
||||
|
||||
xml.element("image") do
|
||||
xml.element("url") { xml.text "" }
|
||||
xml.element("title") { xml.text author }
|
||||
xml.element("url") { xml.text channel.author_thumbnail }
|
||||
xml.element("title") { xml.text channel.author }
|
||||
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
|
||||
end
|
||||
|
||||
videos.each do |video|
|
||||
video.to_xml(false, params, xml)
|
||||
video.to_xml(channel.auto_generated, params, xml)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -305,9 +311,8 @@ module Invidious::Routes::Feeds
|
|||
end
|
||||
|
||||
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
|
||||
return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
|
||||
|
||||
document = XML.parse(response.body)
|
||||
|
||||
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
|
||||
node.attributes.each do |attribute|
|
||||
case attribute.name
|
||||
|
@ -424,6 +429,16 @@ module Invidious::Routes::Feeds
|
|||
end
|
||||
end
|
||||
|
||||
if CONFIG.enable_user_notifications
|
||||
# Deliver notifications to `/api/v1/auth/notifications`
|
||||
payload = {
|
||||
"topic" => ucid,
|
||||
"videoId" => id,
|
||||
"published" => published.to_unix,
|
||||
}.to_json
|
||||
PG_DB.exec("NOTIFY notifications, E'#{payload}'")
|
||||
end
|
||||
|
||||
video = ChannelVideo.new({
|
||||
id: id,
|
||||
title: title,
|
||||
|
@ -439,7 +454,11 @@ module Invidious::Routes::Feeds
|
|||
|
||||
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
|
||||
if was_insert
|
||||
NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video))
|
||||
if CONFIG.enable_user_notifications
|
||||
Invidious::Database::Users.add_notification(video)
|
||||
# else
|
||||
# Invidious::Database::Users.feed_needs_update(video)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -111,7 +111,7 @@ module Invidious::Routes::Images
|
|||
if name == "maxres.jpg"
|
||||
build_thumbnails(id).each do |thumb|
|
||||
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
|
||||
if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200
|
||||
if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200
|
||||
name = thumb[:url] + ".jpg"
|
||||
break
|
||||
end
|
||||
|
|
|
@ -60,7 +60,15 @@ module Invidious::Routes::Login
|
|||
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||
Invidious::Database::SessionIDs.insert(sid, email)
|
||||
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(env.request.headers["Host"], sid)
|
||||
# Checks if there is any alternative domain, like a second domain name,
|
||||
# TOR or I2P address
|
||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
|
||||
else
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||
end
|
||||
else
|
||||
return error_template(401, "Wrong username or password")
|
||||
end
|
||||
|
@ -160,7 +168,15 @@ module Invidious::Routes::Login
|
|||
Invidious::Database::Users.insert(user)
|
||||
Invidious::Database::SessionIDs.insert(sid, email)
|
||||
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(env.request.headers["Host"], sid)
|
||||
# Checks if there is any alternative domain, like a second domain name,
|
||||
# TOR or I2P address
|
||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.backend_domains[alt], sid)
|
||||
else
|
||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||
end
|
||||
|
||||
if env.request.cookies["PREFS"]?
|
||||
user.preferences = env.get("preferences").as(Preferences)
|
||||
|
|
|
@ -48,17 +48,12 @@ module Invidious::Routes::Misc
|
|||
referer = get_referer(env)
|
||||
|
||||
instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"]
|
||||
# Filter out the current instance
|
||||
other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain }
|
||||
|
||||
if other_available_instances.empty?
|
||||
# If the current instance is the only one, use the redirect URL as fallback
|
||||
if instance_list.empty?
|
||||
instance_url = "redirect.invidious.io"
|
||||
else
|
||||
# Select other random instance
|
||||
# Sample returns an array
|
||||
# Instances are packaged as {region, domain} in the instance list
|
||||
instance_url = other_available_instances.sample(1)[0][1]
|
||||
instance_url = instance_list.sample(1)[0][1]
|
||||
end
|
||||
|
||||
env.redirect "https://#{instance_url}#{referer}"
|
||||
|
|
|
@ -304,6 +304,23 @@ module Invidious::Routes::Playlists
|
|||
end
|
||||
end
|
||||
|
||||
if env.params.query["action_create_playlist"]?
|
||||
action = "action_create_playlist"
|
||||
elsif env.params.query["action_delete_playlist"]?
|
||||
action = "action_delete_playlist"
|
||||
elsif env.params.query["action_edit_playlist"]?
|
||||
action = "action_edit_playlist"
|
||||
elsif env.params.query["action_add_video"]?
|
||||
action = "action_add_video"
|
||||
video_id = env.params.query["video_id"]
|
||||
elsif env.params.query["action_remove_video"]?
|
||||
action = "action_remove_video"
|
||||
elsif env.params.query["action_move_video_before"]?
|
||||
action = "action_move_video_before"
|
||||
else
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
begin
|
||||
playlist_id = env.params.query["playlist_id"]
|
||||
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
|
||||
|
@ -318,8 +335,12 @@ module Invidious::Routes::Playlists
|
|||
end
|
||||
end
|
||||
|
||||
case action = env.params.query["action"]?
|
||||
when "add_video"
|
||||
email = user.email
|
||||
|
||||
case action
|
||||
when "action_edit_playlist"
|
||||
# TODO: Playlist stub
|
||||
when "action_add_video"
|
||||
if playlist.index.size >= CONFIG.playlist_length_limit
|
||||
if redirect
|
||||
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
||||
|
@ -356,14 +377,12 @@ module Invidious::Routes::Playlists
|
|||
|
||||
Invidious::Database::PlaylistVideos.insert(playlist_video)
|
||||
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
|
||||
when "remove_video"
|
||||
when "action_remove_video"
|
||||
index = env.params.query["set_video_id"]
|
||||
Invidious::Database::PlaylistVideos.delete(index)
|
||||
Invidious::Database::Playlists.update_video_removed(playlist_id, index)
|
||||
when "move_video_before"
|
||||
when "action_move_video_before"
|
||||
# TODO: Playlist stub
|
||||
when nil
|
||||
return error_json(400, "Missing action")
|
||||
else
|
||||
return error_json(400, "Unsupported action #{action}")
|
||||
end
|
||||
|
|
|
@ -224,7 +224,15 @@ module Invidious::Routes::PreferencesRoute
|
|||
File.write("config/config.yml", CONFIG.to_yaml)
|
||||
end
|
||||
else
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(env.request.headers["Host"], preferences)
|
||||
# Checks if there is any alternative domain, like a second domain name,
|
||||
# TOR or I2P address
|
||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
|
||||
else
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||
end
|
||||
end
|
||||
|
||||
env.redirect referer
|
||||
|
@ -259,7 +267,15 @@ module Invidious::Routes::PreferencesRoute
|
|||
preferences.dark_mode = "dark"
|
||||
end
|
||||
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(env.request.headers["Host"], preferences)
|
||||
# Checks if there is any alternative domain, like a second domain name,
|
||||
# TOR or I2P address
|
||||
if alt = CONFIG.alternative_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
|
||||
elsif alt = CONFIG.backend_domains.index(env.request.headers["Host"])
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.backend_domains[alt], preferences)
|
||||
else
|
||||
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
|
||||
end
|
||||
end
|
||||
|
||||
if redirect
|
||||
|
|
|
@ -32,16 +32,24 @@ module Invidious::Routes::Subscriptions
|
|||
end
|
||||
end
|
||||
|
||||
if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
|
||||
action = "action_create_subscription_to_channel"
|
||||
elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
|
||||
action = "action_remove_subscriptions"
|
||||
else
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
channel_id = env.params.query["c"]?
|
||||
channel_id ||= ""
|
||||
|
||||
case action = env.params.query["action"]?
|
||||
when "create_subscription_to_channel"
|
||||
case action
|
||||
when "action_create_subscription_to_channel"
|
||||
if !user.subscriptions.includes? channel_id
|
||||
get_channel(channel_id)
|
||||
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
||||
end
|
||||
when "remove_subscriptions"
|
||||
when "action_remove_subscriptions"
|
||||
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
||||
else
|
||||
return error_json(400, "Unsupported action #{action}")
|
||||
|
|
|
@ -3,11 +3,6 @@ module Invidious::Routes::VideoPlayback
|
|||
def self.get_video_playback(env)
|
||||
locale = env.get("preferences").as(Preferences).locale
|
||||
query_params = env.params.query
|
||||
|
||||
if query_params["enc"]? == "yes"
|
||||
query_params = URI::Params.parse(video_playback_decrypt(query_params["data"]))
|
||||
end
|
||||
|
||||
array = UInt8[0x78, 0]
|
||||
protobuf = Bytes.new(array.size)
|
||||
array.each_with_index do |byte, index|
|
||||
|
@ -31,7 +26,7 @@ module Invidious::Routes::VideoPlayback
|
|||
end
|
||||
|
||||
# Sanity check, to avoid being used as an open proxy
|
||||
if !host.matches?(/[\w-]+.googlevideo.com/) && !host.matches?(/[\w-]+.c.youtube.com/)
|
||||
if !host.matches?(/[\w-]+.googlevideo.com/)
|
||||
return error_template(400, "Invalid \"host\" parameter.")
|
||||
end
|
||||
|
||||
|
@ -174,13 +169,10 @@ module Invidious::Routes::VideoPlayback
|
|||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if location = resp.headers["Location"]?
|
||||
url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region)
|
||||
location = URI.parse(location)
|
||||
location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
|
||||
|
||||
if title = query_params["title"]?
|
||||
url = "#{url}&title=#{URI.encode_www_form(title)}"
|
||||
end
|
||||
|
||||
env.redirect url
|
||||
env.redirect location
|
||||
break
|
||||
end
|
||||
|
||||
|
@ -267,8 +259,8 @@ module Invidious::Routes::VideoPlayback
|
|||
# so we have a mechanism here to redirect to the latest version
|
||||
def self.latest_version(env)
|
||||
if CONFIG.invidious_companion.present?
|
||||
companion_public_url = env.get("companion_public_url").as(String)
|
||||
return env.redirect "#{companion_public_url}/latest_version?#{env.params.query}"
|
||||
invidious_companion = CONFIG.invidious_companion.sample
|
||||
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
|
||||
end
|
||||
|
||||
id = env.params.query["id"]?
|
||||
|
@ -312,7 +304,16 @@ module Invidious::Routes::VideoPlayback
|
|||
end
|
||||
|
||||
if local
|
||||
url = URI.parse(url).request_target.not_nil!
|
||||
external_proxy = Invidious::HttpServer::Utils.get_external_proxy
|
||||
if !external_proxy.empty?
|
||||
url = URI.parse(url)
|
||||
external_proxy = URI.parse(external_proxy)
|
||||
url.host = external_proxy.host
|
||||
url.port = external_proxy.port
|
||||
url = url.to_s
|
||||
else
|
||||
url = URI.parse(url).request_target.not_nil!
|
||||
end
|
||||
url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title
|
||||
end
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ module Invidious::Routes::Watch
|
|||
user = env.get?("user").try &.as(User)
|
||||
if user
|
||||
subscriptions = user.subscriptions
|
||||
watched = user.watched
|
||||
# watched = user.watched
|
||||
notifications = user.notifications
|
||||
end
|
||||
subscriptions ||= [] of String
|
||||
|
@ -52,7 +52,7 @@ module Invidious::Routes::Watch
|
|||
env.params.query.delete_all("listen")
|
||||
|
||||
begin
|
||||
video = get_video(id, region: params.region, env: env)
|
||||
video = get_video(id, region: params.region)
|
||||
rescue ex : NotFoundException
|
||||
LOGGER.error("get_video not found: #{id} : #{ex.message}")
|
||||
return error_template(404, ex)
|
||||
|
@ -61,10 +61,6 @@ module Invidious::Routes::Watch
|
|||
return error_template(500, ex)
|
||||
end
|
||||
|
||||
if video.live_now && CONFIG.disable_livestreams
|
||||
return error_template(403, "Livestreams are disabled as they are not working with invidious-companion right now. Please wait until an update comes out!")
|
||||
end
|
||||
|
||||
if preferences.annotations_subscribed &&
|
||||
subscriptions.includes?(video.ucid) &&
|
||||
(env.params.query["iv_load_policy"]? || "1") == "1"
|
||||
|
@ -72,8 +68,14 @@ module Invidious::Routes::Watch
|
|||
end
|
||||
env.params.query.delete_all("iv_load_policy")
|
||||
|
||||
if watched && preferences.watch_history
|
||||
Invidious::Database::Users.mark_watched(user.as(User), id)
|
||||
history_details = JSON::Any.new({
|
||||
"id" => JSON::Any.new(id),
|
||||
"title" => JSON::Any.new(video.title),
|
||||
"author" => JSON::Any.new(video.author)
|
||||
})
|
||||
|
||||
if preferences.watch_history
|
||||
Invidious::Database::Users.mark_watched(user.as(User), history_details)
|
||||
end
|
||||
|
||||
if CONFIG.enable_user_notifications && notifications && notifications.includes? id
|
||||
|
@ -135,6 +137,9 @@ module Invidious::Routes::Watch
|
|||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
||||
end
|
||||
|
||||
# Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
|
||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
|
||||
|
||||
video_streams = video.video_streams
|
||||
audio_streams = video.audio_streams
|
||||
|
||||
|
@ -145,6 +150,11 @@ module Invidious::Routes::Watch
|
|||
end
|
||||
end
|
||||
|
||||
# Removes non default audio tracks
|
||||
audio_streams.reject! do |z|
|
||||
z if z.dig?("audioTrack", "audioIsDefault") == false
|
||||
end
|
||||
|
||||
# Older videos may not have audio sources available.
|
||||
# We redirect here so they're not unplayable
|
||||
if audio_streams.empty? && !video.live_now
|
||||
|
@ -213,11 +223,6 @@ module Invidious::Routes::Watch
|
|||
video_url = nil
|
||||
end
|
||||
|
||||
if CONFIG.invidious_companion.present?
|
||||
current_companion = env.get("current_companion").as(Int32)
|
||||
invidious_companion = CONFIG.invidious_companion[current_companion]
|
||||
end
|
||||
|
||||
templated "watch"
|
||||
end
|
||||
|
||||
|
@ -269,11 +274,19 @@ module Invidious::Routes::Watch
|
|||
end
|
||||
end
|
||||
|
||||
case action = env.params.query["action"]?
|
||||
when "mark_watched"
|
||||
Invidious::Database::Users.mark_watched(user, id)
|
||||
when "mark_unwatched"
|
||||
Invidious::Database::Users.mark_unwatched(user, id)
|
||||
if env.params.query["action_mark_watched"]?
|
||||
action = "action_mark_watched"
|
||||
elsif env.params.query["action_mark_unwatched"]?
|
||||
action = "action_mark_unwatched"
|
||||
else
|
||||
return env.redirect referer
|
||||
end
|
||||
|
||||
case action
|
||||
when "action_mark_watched"
|
||||
# Invidious::Database::Users.mark_watched(user, history_details)
|
||||
when "action_mark_unwatched"
|
||||
# Invidious::Database::Users.mark_unwatched(user, id)
|
||||
else
|
||||
return error_json(400, "Unsupported action #{action}")
|
||||
end
|
||||
|
@ -347,9 +360,8 @@ module Invidious::Routes::Watch
|
|||
env.params.query["local"] = "true"
|
||||
|
||||
if (CONFIG.invidious_companion.present?)
|
||||
video = get_video(video_id, env: env)
|
||||
companion_public_url = env.get("companion_public_url").as(String)
|
||||
return env.redirect "#{companion_public_url}/latest_version?#{env.params.query}"
|
||||
video = get_video(video_id)
|
||||
return env.redirect "#{video.invidious_companion["baseUrl"].as_s}/latest_version?#{env.params.query}"
|
||||
else
|
||||
return Invidious::Routes::VideoPlayback.latest_version(env)
|
||||
end
|
||||
|
|
22
src/invidious/routes/xd.xml
Normal file
22
src/invidious/routes/xd.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed xmlns:yt= \x22http:// www.youtube.com/ xml/ schemas/ 2015\x22 xmlns= \x22http:// www.w3.org/ 2005/ Atom\x22>
|
||||
<link rel= \x22hub\x22 href= \x22https:// pubsubhubbub.appspot.com\x22 />
|
||||
<link rel= \x22self\x22 href= \x22https:// www.youtube.com/ xml/ feeds/ videos.xml?channel_id=UCn8n_wDeUDrdDMQfoElZlfw\x22/>
|
||||
<title>YouTube video feed</title>
|
||||
<updated>2024-12-18T23:19:42.357314923+00:00</updated>
|
||||
<entry>
|
||||
<id>yt:video:YYBivfsnwIU</id>
|
||||
<yt:videoId>YYBivfsnwIU</yt:videoId>
|
||||
<yt:channelId>
|
||||
UCn8n_wDeUDrdDMQfoElZlfw</yt:channelId>
|
||||
<title>How To Make Silicone Tray Liners For the HARVESTRIGHT FREEZE DRYER #siliconetrayliners</title>
|
||||
<link rel= \x22alternate\x22 href= \x22https:// www.youtube.com/ watch?v=YYBivfsnwIU\x22/>\x0A
|
||||
<author>\x0A
|
||||
<name>TheFreeze Drying Community</name>
|
||||
<uri>https://www.youtube.com/channel/UCn8n_wDeUDrdDMQfoElZlfw</uri>
|
||||
</author>
|
||||
<published>
|
||||
2021-02-07T14:00:17+00:00</published>\x0A <updated>2024-12-18T23:19:42.357314923+00:00</updated>
|
||||
\x0A
|
||||
</entry>
|
||||
</feed>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue