Compare commits

..

No commits in common. "master" and "v2.20241110.0" have entirely different histories.

100 changed files with 414 additions and 1714 deletions

View File

@ -10,10 +10,8 @@ assignees: ''
<!-- <!--
BEFORE TRYING TO REPORT A BUG: BEFORE TRYING TO REPORT A BUG:
* Read the FAQ: https://docs.invidious.io/faq/! * Read the 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! * Use the search function to check if there is already an issue open for your problem!
MAKE SURE TO FOLLOW THE TWO STEPS ABOVE BEFORE REPORTING A BUG. A BUG THAT ALREADY EXIST WILL IMMEDIATELY CLOSED.
If you want to suggest a new feature please use "Feature request" instead If you want to suggest a new feature please use "Feature request" instead
If you want to suggest an enhancement to an existing feature please use "Enhancement" instead If you want to suggest an enhancement to an existing feature please use "Enhancement" instead

View File

@ -23,6 +23,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.2
with:
crystal: 1.12.2
- name: Run lint
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
with: with:

View File

@ -14,6 +14,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.2
with:
crystal: 1.12.2
- name: Run lint
run: |
if ! crystal tool format --check; then
crystal tool format
git diff
exit 1
fi
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
with: with:

View File

@ -38,10 +38,11 @@ jobs:
matrix: matrix:
stable: [true] stable: [true]
crystal: crystal:
- 1.10.1
- 1.11.2
- 1.12.1 - 1.12.1
- 1.13.2 - 1.13.2
- 1.14.0 - 1.14.0
- 1.15.0
include: include:
- crystal: nightly - crystal: nightly
stable: false stable: false
@ -135,7 +136,6 @@ jobs:
submodules: true submodules: true
- name: Install Crystal - name: Install Crystal
id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.8.0 uses: crystal-lang/install-crystal@v1.8.0
with: with:
crystal: latest crystal: latest
@ -146,7 +146,7 @@ jobs:
path: | path: |
./lib ./lib
./bin ./bin
key: shards-${{ hashFiles('shard.lock') }}-${{ steps.lint_step_install_crystal.outputs.crystal }} key: shards-${{ hashFiles('shard.lock') }}
- name: Install Shards - name: Install Shards
run: | run: |

View File

@ -1,103 +1,5 @@
# CHANGELOG # CHANGELOG
## 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 ## v2.20241110.0
### Wrap-up ### Wrap-up

View File

@ -91,7 +91,7 @@
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/token_ajax?action=revoke_token&redirect=false' + var url = '/token_ajax?action_revoke_token=1&redirect=false' +
'&referer=' + encodeURIComponent(location.href) + '&referer=' + encodeURIComponent(location.href) +
'&session=' + target.getAttribute('data-session'); '&session=' + target.getAttribute('data-session');
@ -111,7 +111,7 @@
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&referer=' + encodeURIComponent(location.href) + '&referer=' + encodeURIComponent(location.href) +
'&c=' + target.getAttribute('data-ucid'); '&c=' + target.getAttribute('data-ucid');

View File

@ -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}&nbsp;&nbsp;<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>&nbsp;&nbsp;${pagination_data.prev_page}</button>`
}
container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
}
}
});

View File

@ -134,32 +134,26 @@ player.on('timeupdate', function () {
// YouTube links // YouTube links
let elem_yt_watch = document.getElementById('link-yt-watch'); let elem_yt_watch = document.getElementById('link-yt-watch');
if (elem_yt_watch) {
let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch);
}
let elem_yt_embed = document.getElementById('link-yt-embed'); let elem_yt_embed = document.getElementById('link-yt-embed');
if (elem_yt_embed) {
let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url');
elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed); 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 // Invidious links
let domain = window.location.origin; let domain = window.location.origin;
let elem_iv_embed = document.getElementById('link-iv-embed'); let elem_iv_embed = document.getElementById('link-iv-embed');
if (elem_iv_embed) {
let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain);
}
let elem_iv_other = document.getElementById('link-iv-other'); let elem_iv_other = document.getElementById('link-iv-other');
if (elem_iv_other) {
let base_url_iv_other = elem_iv_other.getAttribute('data-base-url'); let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url');
elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain); 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);
}); });

View File

@ -6,7 +6,7 @@ function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1]; var select = target.parentNode.children[0].children[1];
var option = select.children[select.selectedIndex]; var option = select.children[select.selectedIndex];
var url = '/playlist_ajax?action=add_video&redirect=false' + var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') + '&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid'); '&playlist_id=' + option.getAttribute('data-plid');
@ -21,7 +21,7 @@ function add_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/playlist_ajax?action=add_video&redirect=false' + var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') + '&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + target.getAttribute('data-plid'); '&playlist_id=' + target.getAttribute('data-plid');
@ -36,7 +36,7 @@ function remove_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/playlist_ajax?action=remove_video&redirect=false' + var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') + '&set_video_id=' + target.getAttribute('data-index') +
'&playlist_id=' + target.getAttribute('data-plid'); '&playlist_id=' + target.getAttribute('data-plid');

View File

@ -16,7 +16,7 @@ function subscribe() {
subscribe_button.onclick = unsubscribe; subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' + var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
'&c=' + subscribe_data.ucid; '&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, { helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
@ -32,7 +32,7 @@ function unsubscribe() {
subscribe_button.onclick = subscribe; subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&c=' + subscribe_data.ucid; '&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {

View File

@ -67,10 +67,6 @@ function get_playlist(plid) {
'&format=html&hl=' + video_data.preferences.locale; '&format=html&hl=' + video_data.preferences.locale;
} }
if (video_data.params.listen) {
plid_url += '&listen=1'
}
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) { on200: function (response) {
playlist.innerHTML = response.playlistHtml; playlist.innerHTML = response.playlistHtml;

View File

@ -6,7 +6,7 @@ function mark_watched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none'; tile.style.display = 'none';
var url = '/watch_ajax?action=mark_watched&redirect=false' + var url = '/watch_ajax?action_mark_watched=1&redirect=false' +
'&id=' + target.getAttribute('data-id'); '&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, { helpers.xhr('POST', url, {payload: payload}, {
@ -22,7 +22,7 @@ function mark_unwatched(target) {
var count = document.getElementById('count'); var count = document.getElementById('count');
count.textContent--; count.textContent--;
var url = '/watch_ajax?action=mark_unwatched&redirect=false' + var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' +
'&id=' + target.getAttribute('data-id'); '&id=' + target.getAttribute('data-id');
helpers.xhr('POST', url, {payload: payload}, { helpers.xhr('POST', url, {payload: payload}, {

View File

@ -54,53 +54,6 @@ db:
## ##
#signature_server: #signature_server:
##
## Invidious companion is an external program
## for loading the video streams from YouTube servers.
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
## Note: multiple URL can be configured. In this case, invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
## to work. Once said cache has expired, requesting that video's data
## again will cause a new companion URL to be picked.
##
## The parameter private_url needs to be configured for the internal
## communication between the companion and Invidious.
## And public_url is the public URL from which companion is listening
## to the requests from the user(s).
##
## If you are using a reverse proxy then you will probably need to
## configure the public_url to be the same as the domain used for Invidious.
## Also apply when used from an external IP address (without a domain).
## Examples: https://MYINVIDIOUSDOMAIN or http://192.168.1.100:8282
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"
##
## API key for Invidious companion, used for securing the communication
## between Invidious and Invidious companion.
## The size of the key needs to be more or equal to 16.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"
######################################### #########################################
# #
@ -177,20 +130,6 @@ https_only: false
## ##
#hsts: true #hsts: true
##
## Path and permissions of a UNIX socket to listen on for incoming connections.
##
## Note: Enabling socket will make invidious stop listening on the address
## specified by 'host_binding' and 'port'.
##
## Accepted values: Any path to a new file (that doesn't exist yet) and its
## permissions following the UNIX octal convention.
## Default: <none>
##
#socket_binding:
# path: /tmp/invidious.sock
# permissions: 777
# ----------------------------- # -----------------------------
# Network (outbound) # Network (outbound)
@ -239,11 +178,11 @@ https_only: false
## ##
## If unset, then no HTTP proxy will be used. ## If unset, then no HTTP proxy will be used.
## ##
#http_proxy: http_proxy:
# user: user:
# password: password:
# host: host:
# port: port:
## ##
@ -858,9 +797,9 @@ default_user_preferences:
## Default video quality. ## Default video quality.
## ##
## Accepted values: dash, hd720, medium, small ## Accepted values: dash, hd720, medium, small
## Default: dash ## Default: hd720
## ##
#quality: dash #quality: hd720
## ##
## Default dash video quality. ## Default dash video quality.

View File

@ -21,7 +21,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release \ --release \
--static --warnings all \ --static --warnings all \

View File

@ -22,7 +22,7 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ RUN if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release \ --release \
--static --warnings all \ --static --warnings all \

View File

@ -559,12 +559,10 @@
"toggle_theme": "تبديل الموضوع", "toggle_theme": "تبديل الموضوع",
"Add to playlist": "أضف إلى قائمة التشغيل", "Add to playlist": "أضف إلى قائمة التشغيل",
"Add to playlist: ": "أضف إلى قائمة التشغيل: ", "Add to playlist: ": "أضف إلى قائمة التشغيل: ",
"Answer": "اجابة", "Answer": "الرد",
"Search for videos": "ابحث عن مقاطع الفيديو", "Search for videos": "ابحث عن مقاطع الفيديو",
"The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.", "The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.",
"carousel_slide": "الشريحة {{current}} من {{total}}", "carousel_slide": "الشريحة {{current}} من {{total}}",
"carousel_skip": "تخطي الكاروسيل", "carousel_skip": "تخطي الكاروسيل",
"carousel_go_to": "انتقل إلى الشريحة `x`", "carousel_go_to": "انتقل إلى الشريحة `x`"
"preferences_preload_label": "التحميل المسبق لبيانات الفيديو: ",
"Filipino (auto-generated)": "الفلبينية (المولدة تلقائيًا)"
} }

View File

@ -137,7 +137,7 @@
"Family friendly? ": "Vhodné pro rodiny? ", "Family friendly? ": "Vhodné pro rodiny? ",
"Engagement: ": "Zapojení: ", "Engagement: ": "Zapojení: ",
"English": "Angličtina", "English": "Angličtina",
"English (auto-generated)": "Angličtina (vytvořeno automaticky)", "English (auto-generated)": "Angličtina (automaticky generováno)",
"Afrikaans": "Afrikánština", "Afrikaans": "Afrikánština",
"Albanian": "Albánština", "Albanian": "Albánština",
"Amharic": "Amharština", "Amharic": "Amharština",
@ -294,8 +294,8 @@
"Chinese (China)": "Čínština (Čína)", "Chinese (China)": "Čínština (Čína)",
"Chinese (Hong Kong)": "Čínština (Hong Kong)", "Chinese (Hong Kong)": "Čínština (Hong Kong)",
"Chinese (Taiwan)": "Čínština (Taiwan)", "Chinese (Taiwan)": "Čínština (Taiwan)",
"Portuguese (auto-generated)": "Portugalština (vytvořeno automaticky)", "Portuguese (auto-generated)": "Portugalština (automaticky generováno)",
"Spanish (auto-generated)": "Španělština (vytvořeno automaticky)", "Spanish (auto-generated)": "Španělština (automaticky generováno)",
"Spanish (Mexico)": "Španělština (Mexiko)", "Spanish (Mexico)": "Španělština (Mexiko)",
"Spanish (Spain)": "Španělština (Španělsko)", "Spanish (Spain)": "Španělština (Španělsko)",
"generic_count_years_0": "{{count}} rokem", "generic_count_years_0": "{{count}} rokem",
@ -352,13 +352,13 @@
"comments_points_count_0": "{{count}} bod", "comments_points_count_0": "{{count}} bod",
"comments_points_count_1": "{{count}} body", "comments_points_count_1": "{{count}} body",
"comments_points_count_2": "{{count}} bodů", "comments_points_count_2": "{{count}} bodů",
"German (auto-generated)": "Němčina (vytvořeno automaticky)", "German (auto-generated)": "Němčina (automaticky generováno)",
"Indonesian (auto-generated)": "Indonéština (vytvořeno automaticky)", "Indonesian (auto-generated)": "Indonéština (automaticky generováno)",
"Interlingue": "Interlingue", "Interlingue": "Interlingue",
"Italian (auto-generated)": "Italština (vytvořeno automaticky)", "Italian (auto-generated)": "Italština (automaticky generováno)",
"Japanese (auto-generated)": "Japonština (vytvořeno automaticky)", "Japanese (auto-generated)": "Japonština (automaticky generováno)",
"Korean (auto-generated)": "Korejština (vytvořeno automaticky)", "Korean (auto-generated)": "Korejština (automaticky generováno)",
"Russian (auto-generated)": "Ruština (vytvořeno automaticky)", "Russian (auto-generated)": "Ruština (automaticky generováno)",
"generic_count_months_0": "{{count}} měsícem", "generic_count_months_0": "{{count}} měsícem",
"generic_count_months_1": "{{count}} měsíci", "generic_count_months_1": "{{count}} měsíci",
"generic_count_months_2": "{{count}} měsíci", "generic_count_months_2": "{{count}} měsíci",
@ -371,7 +371,7 @@
"footer_documentation": "Dokumentace", "footer_documentation": "Dokumentace",
"next_steps_error_message_refresh": "Obnovit stránku", "next_steps_error_message_refresh": "Obnovit stránku",
"Chinese": "Čínština", "Chinese": "Čínština",
"Dutch (auto-generated)": "Nizozemština (vytvořeno automaticky)", "Dutch (auto-generated)": "Nizozemština (automaticky generováno)",
"Erroneous token": "Chybný token", "Erroneous token": "Chybný token",
"tokens_count_0": "{{count}} token", "tokens_count_0": "{{count}} token",
"tokens_count_1": "{{count}} tokeny", "tokens_count_1": "{{count}} tokeny",
@ -380,9 +380,9 @@
"Token is expired, please try again": "Token vypršel, zkuste to prosím znovu", "Token is expired, please try again": "Token vypršel, zkuste to prosím znovu",
"English (United States)": "Angličtina (Spojené státy)", "English (United States)": "Angličtina (Spojené státy)",
"Cantonese (Hong Kong)": "Kantonština (Hong Kong)", "Cantonese (Hong Kong)": "Kantonština (Hong Kong)",
"French (auto-generated)": "Francouzština (vytvořeno automaticky)", "French (auto-generated)": "Francouzština (automaticky generováno)",
"Turkish (auto-generated)": "Turečtina (vytvořeno automaticky)", "Turkish (auto-generated)": "Turečtina (automaticky generováno)",
"Vietnamese (auto-generated)": "Vietnamština (vytvořeno automaticky)", "Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)",
"Current version: ": "Aktuální verze: ", "Current version: ": "Aktuální verze: ",
"next_steps_error_message": "Měli byste zkusit: ", "next_steps_error_message": "Měli byste zkusit: ",
"footer_donate_page": "Přispět", "footer_donate_page": "Přispět",
@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.", "The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.",
"carousel_slide": "Snímek {{current}} z {{total}}", "carousel_slide": "Snímek {{current}} z {{total}}",
"carousel_skip": "Přeskočit galerii", "carousel_skip": "Přeskočit galerii",
"carousel_go_to": "Přejít na snímek `x`", "carousel_go_to": "Přejít na snímek `x`"
"preferences_preload_label": "Předem načíst data videa: ",
"Filipino (auto-generated)": "Filipínština (vytvořeno automaticky)"
} }

View File

@ -11,7 +11,6 @@
"last": "neueste", "last": "neueste",
"Next page": "Nächste Seite", "Next page": "Nächste Seite",
"Previous page": "Vorherige Seite", "Previous page": "Vorherige Seite",
"First page": "Erste Seite",
"Clear watch history?": "Verlauf löschen?", "Clear watch history?": "Verlauf löschen?",
"New password": "Neues Passwort", "New password": "Neues Passwort",
"New passwords must match": "Neue Passwörter müssen übereinstimmen", "New passwords must match": "Neue Passwörter müssen übereinstimmen",
@ -491,13 +490,12 @@
"generic_channels_count_plural": "{{count}} Kanäle", "generic_channels_count_plural": "{{count}} Kanäle",
"Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)", "Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)",
"Answer": "Antwort", "Answer": "Antwort",
"The Popular feed has been disabled by the administrator.": "Der 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", "Add to playlist": "Einer Wiedergabeliste hinzufügen",
"Search for videos": "Nach Videos suchen", "Search for videos": "Nach Videos suchen",
"toggle_theme": "Thema wechseln", "toggle_theme": "Thema wechseln",
"Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ", "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ",
"carousel_go_to": "Zu Element `x` springen", "carousel_go_to": "Zu Folie `x` gehen",
"carousel_slide": "Seite {{current}} von {{total}}", "carousel_slide": "Folie {{current}} von {{total}}",
"carousel_skip": "Galerie überspringen", "carousel_skip": "Karussell überspringen"
"Filipino (auto-generated)": "Philippinisch (automatisch generiert)"
} }

View File

@ -21,7 +21,7 @@
"Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων", "Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων",
"Import": "Εισαγωγή", "Import": "Εισαγωγή",
"Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON", "Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON",
"Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube απο CVS/OPML", "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML",
"Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)",
"Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)", "Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)",
@ -455,7 +455,7 @@
"channel_tab_streams_label": "Ζωντανή μετάδοση", "channel_tab_streams_label": "Ζωντανή μετάδοση",
"playlist_button_add_items": "Προσθήκη βίντεο", "playlist_button_add_items": "Προσθήκη βίντεο",
"Artist: ": "Καλλιτέχνης: ", "Artist: ": "Καλλιτέχνης: ",
"search_message_use_another_instance": "Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.", "search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.",
"generic_button_save": "Αποθήκευση", "generic_button_save": "Αποθήκευση",
"generic_button_cancel": "Ακύρωση", "generic_button_cancel": "Ακύρωση",
"subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση", "subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση",
@ -490,13 +490,9 @@
"Search for videos": "Αναζήτηση βίντεο", "Search for videos": "Αναζήτηση βίντεο",
"The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.", "The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.",
"Answer": "Απάντηση", "Answer": "Απάντηση",
"Add to playlist": "Προσθήκη στην λίιστα αναπαραγωγής", "Add to playlist": "Λίιστα αναπαραγωγής",
"Add to playlist: ": "Προσθήκη στην λίστα αναπαραγωγής : ", "Add to playlist: ": "Λίστα αναπαραγωγής: ",
"carousel_slide": "Εικόνα {{current}}απο {{total}}", "carousel_slide": "Εικόνα {{current}}απο {{total}}",
"carousel_go_to": "Πήγαινε στην εικόνα`x`", "carousel_go_to": "Πήγαινε στην εικόνα`x`",
"toggle_theme": "Αλλαγή θέματος", "toggle_theme": "Αλλαγή θέματος"
"Import YouTube watch history (.json)": "Εισαγωγή ιστορικού προβολής YouTube (.json)",
"Filipino (auto-generated)": "Φιλιππινέζικα (αυτόματη παραγωγή)",
"preferences_preload_label": "Προφόρτιση δεδομένων βίντεο: ",
"carousel_skip": "Αποφυγή εμφάνισης εικόνων"
} }

View File

@ -33,7 +33,6 @@
"last": "last", "last": "last",
"Next page": "Next page", "Next page": "Next page",
"Previous page": "Previous page", "Previous page": "Previous page",
"First page": "First page",
"Clear watch history?": "Clear watch history?", "Clear watch history?": "Clear watch history?",
"New password": "New password", "New password": "New password",
"New passwords must match": "New passwords must match", "New passwords must match": "New passwords must match",
@ -493,10 +492,8 @@
"channel_tab_streams_label": "Livestreams", "channel_tab_streams_label": "Livestreams",
"channel_tab_podcasts_label": "Podcasts", "channel_tab_podcasts_label": "Podcasts",
"channel_tab_releases_label": "Releases", "channel_tab_releases_label": "Releases",
"channel_tab_courses_label": "Courses",
"channel_tab_playlists_label": "Playlists", "channel_tab_playlists_label": "Playlists",
"channel_tab_community_label": "Community", "channel_tab_community_label": "Community",
"channel_tab_posts_label": "Posts",
"channel_tab_channels_label": "Channels", "channel_tab_channels_label": "Channels",
"toggle_theme": "Toggle Theme", "toggle_theme": "Toggle Theme",
"carousel_slide": "Slide {{current}} of {{total}}", "carousel_slide": "Slide {{current}} of {{total}}",

View File

@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "El feed Popular ha sido desactivado por el administrador.", "The Popular feed has been disabled by the administrator.": "El feed Popular ha sido desactivado por el administrador.",
"carousel_slide": "Diapositiva {{current}} de {{total}}", "carousel_slide": "Diapositiva {{current}} de {{total}}",
"carousel_skip": "Saltar el carrusel", "carousel_skip": "Saltar el carrusel",
"carousel_go_to": "Ir a la diapositiva `x`", "carousel_go_to": "Ir a la diapositiva `x`"
"preferences_preload_label": "Precargar datos del vídeo: ",
"Filipino (auto-generated)": "Filipino (generado automáticamente)"
} }

View File

@ -496,6 +496,5 @@
"crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>", "crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>",
"crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:", "crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:",
"channel_tab_releases_label": "آثار", "channel_tab_releases_label": "آثار",
"toggle_theme": "تغییر وضعیت تم", "toggle_theme": "تغییر وضعیت تم"
"preferences_preload_label": "پیش بار کردن داده‌های ویدیو: "
} }

View File

@ -460,7 +460,7 @@
"search_filters_apply_button": "Ota valitut suodattimet käyttöön", "search_filters_apply_button": "Ota valitut suodattimet käyttöön",
"search_filters_date_label": "Latausaika", "search_filters_date_label": "Latausaika",
"search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)", "search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)",
"search_message_use_another_instance": "Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.", "search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.",
"search_filters_date_option_none": "Milloin tahansa", "search_filters_date_option_none": "Milloin tahansa",
"search_filters_type_option_all": "Mikä tahansa tyyppi", "search_filters_type_option_all": "Mikä tahansa tyyppi",
"Popular enabled: ": "Suosittu käytössä: ", "Popular enabled: ": "Suosittu käytössä: ",
@ -496,6 +496,5 @@
"generic_channels_count_plural": "{{count}} kanavaa", "generic_channels_count_plural": "{{count}} kanavaa",
"The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.", "The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.",
"Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)", "Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)",
"toggle_theme": "Vaihda teemaa", "toggle_theme": "Vaihda teemaa"
"preferences_preload_label": "Esilataa video data. "
} }

View File

@ -505,7 +505,7 @@
"channel_tab_releases_label": "Parutions", "channel_tab_releases_label": "Parutions",
"channel_tab_podcasts_label": "Émissions audio", "channel_tab_podcasts_label": "Émissions audio",
"Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)", "Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)",
"Add to playlist: ": "Ajouter à la playlist : ", "Add to playlist: ": "Ajouter à la playlist: ",
"Add to playlist": "Ajouter à la playlist", "Add to playlist": "Ajouter à la playlist",
"Answer": "Répondre", "Answer": "Répondre",
"Search for videos": "Rechercher des vidéos", "Search for videos": "Rechercher des vidéos",
@ -513,7 +513,5 @@
"carousel_skip": "Passez le carrousel", "carousel_skip": "Passez le carrousel",
"carousel_slide": "Diapositive {{current}} sur {{total}}", "carousel_slide": "Diapositive {{current}} sur {{total}}",
"carousel_go_to": "Aller à la diapositive `x`", "carousel_go_to": "Aller à la diapositive `x`",
"toggle_theme": "Changer le Thème", "toggle_theme": "Changer le Thème"
"Filipino (auto-generated)": "Philippines (automatiquement générer)",
"preferences_preload_label": "Précharger les données de la vidéo : "
} }

View File

@ -513,7 +513,5 @@
"toggle_theme": "Uklj./Isklj. temu", "toggle_theme": "Uklj./Isklj. temu",
"carousel_slide": "Kadar {{current}} od {{total}}", "carousel_slide": "Kadar {{current}} od {{total}}",
"carousel_go_to": "Idi na kadar `x`", "carousel_go_to": "Idi na kadar `x`",
"carousel_skip": "Preskoči vrtuljak", "carousel_skip": "Preskoči vrtuljak"
"Filipino (auto-generated)": "Filipinski (automatski generirano)",
"preferences_preload_label": "Unaprijed učitaj podatke videa: "
} }

View File

@ -496,7 +496,5 @@
"footer_documentation": "Leiðbeiningar", "footer_documentation": "Leiðbeiningar",
"channel_tab_channels_label": "Rásir", "channel_tab_channels_label": "Rásir",
"Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)", "Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)",
"preferences_quality_option_dash": "DASH (aðlaganleg gæði)", "preferences_quality_option_dash": "DASH (aðlaganleg gæði)"
"preferences_preload_label": "Forhlaða gögnum myndskeiðs: ",
"Filipino (auto-generated)": "Filippínska (sjálfvirkt útbúin)"
} }

View File

@ -469,8 +469,8 @@
"Spanish (auto-generated)": "Spagnolo (generati automaticamente)", "Spanish (auto-generated)": "Spagnolo (generati automaticamente)",
"Spanish (Mexico)": "Spagnolo (Messico)", "Spanish (Mexico)": "Spagnolo (Messico)",
"Spanish (Spain)": "Spagnolo (Spagna)", "Spanish (Spain)": "Spagnolo (Spagna)",
"Turkish (auto-generated)": "Turco (generati automaticamente)", "Turkish (auto-generated)": "Turco (auto-generato)",
"Vietnamese (auto-generated)": "Vietnamita (generati automaticamente)", "Vietnamese (auto-generated)": "Vietnamita (auto-generato)",
"search_filters_date_label": "Data caricamento", "search_filters_date_label": "Data caricamento",
"search_filters_date_option_none": "Qualunque data", "search_filters_date_option_none": "Qualunque data",
"search_filters_type_option_all": "Qualunque tipo", "search_filters_type_option_all": "Qualunque tipo",
@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.", "The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.",
"carousel_slide": "Fotogramma {{current}} di {{total}}", "carousel_slide": "Fotogramma {{current}} di {{total}}",
"carousel_skip": "Salta la galleria", "carousel_skip": "Salta la galleria",
"carousel_go_to": "Vai al fotogramma `x`", "carousel_go_to": "Vai al fotogramma `x`"
"preferences_preload_label": "Precarica dati video: ",
"Filipino (auto-generated)": "Filippino (generati automaticamente)"
} }

View File

@ -479,7 +479,5 @@
"carousel_go_to": "スライド`x`を表示", "carousel_go_to": "スライド`x`を表示",
"carousel_slide": "スライド{{current}} / 全{{total}}個中", "carousel_slide": "スライド{{current}} / 全{{total}}個中",
"carousel_skip": "画像のスライド表示をスキップ", "carousel_skip": "画像のスライド表示をスキップ",
"toggle_theme": "テーマの切り替え", "toggle_theme": "テーマの切り替え"
"preferences_preload_label": "動画データを事前に読み込む: ",
"Filipino (auto-generated)": "フィリピノ語 (自動生成)"
} }

View File

@ -70,7 +70,7 @@
"Next page": "다음 페이지", "Next page": "다음 페이지",
"last": "마지막", "last": "마지막",
"Shared `x` ago": "`x` 전", "Shared `x` ago": "`x` 전",
"popular": "인기", "popular": "인기",
"oldest": "과거순", "oldest": "과거순",
"newest": "최신순", "newest": "최신순",
"View playlist on YouTube": "유튜브에서 재생목록 보기", "View playlist on YouTube": "유튜브에서 재생목록 보기",
@ -479,6 +479,5 @@
"carousel_go_to": "`x` 슬라이드로 이동", "carousel_go_to": "`x` 슬라이드로 이동",
"Search for videos": "비디오 검색", "Search for videos": "비디오 검색",
"toggle_theme": "테마 전환", "toggle_theme": "테마 전환",
"carousel_slide": "{{total}}의 슬라이드 {{current}}", "carousel_slide": "{{total}}의 슬라이드 {{current}}"
"preferences_preload_label": "비디오 데이터 사전 로드: "
} }

View File

@ -496,6 +496,5 @@
"Add to playlist": "Legg til i spilleliste", "Add to playlist": "Legg til i spilleliste",
"Add to playlist: ": "Legg til i spilleliste: ", "Add to playlist: ": "Legg til i spilleliste: ",
"The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.", "The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.",
"toggle_theme": "Endre utseende", "toggle_theme": "Endre utseende"
"preferences_preload_label": "Last videodata på forhånd: "
} }

View File

@ -496,7 +496,5 @@
"Answer": "Antwoorden", "Answer": "Antwoorden",
"Search for videos": "Naar video's zoeken", "Search for videos": "Naar video's zoeken",
"carousel_skip": "Carousel overslaan", "carousel_skip": "Carousel overslaan",
"toggle_theme": "Thema omschakelen", "toggle_theme": "Thema omschakelen"
"preferences_preload_label": "Videogegevens vooraf laden: ",
"Filipino (auto-generated)": "Filipijns (automatisch gegenereerd)"
} }

View File

@ -513,7 +513,5 @@
"Add to playlist: ": "Dodaj do playlisty: ", "Add to playlist: ": "Dodaj do playlisty: ",
"carousel_slide": "Slajd {{current}} z {{total}}", "carousel_slide": "Slajd {{current}} z {{total}}",
"carousel_skip": "Pomiń karuzelę", "carousel_skip": "Pomiń karuzelę",
"carousel_go_to": "Przejdź do slajdu `x`", "carousel_go_to": "Przejdź do slajdu `x`"
"preferences_preload_label": "Wstępne ładowanie danych wideo: ",
"Filipino (auto-generated)": "filipiński (wygenerowany automatycznie)"
} }

View File

@ -513,7 +513,5 @@
"Answer": "Resposta", "Answer": "Resposta",
"carousel_slide": "Slide {{current}} de {{total}}", "carousel_slide": "Slide {{current}} de {{total}}",
"carousel_skip": "Ignorar carrossel", "carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir ao slide `x`", "carousel_go_to": "Ir ao slide `x`"
"preferences_preload_label": "Pré-carregar dados do vídeo: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
} }

View File

@ -513,7 +513,5 @@
"carousel_slide": "Diapositivo {{current}} de{{total}}", "carousel_slide": "Diapositivo {{current}} de{{total}}",
"carousel_skip": "Ignorar carrossel", "carousel_skip": "Ignorar carrossel",
"carousel_go_to": "Ir para o diapositivo`x`", "carousel_go_to": "Ir para o diapositivo`x`",
"The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador.", "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador."
"preferences_preload_label": "Pré-carregamento dos dados: ",
"Filipino (auto-generated)": "Filipino (gerado automaticamente)"
} }

View File

@ -11,7 +11,6 @@
"last": "последние", "last": "последние",
"Next page": "Следующая страница", "Next page": "Следующая страница",
"Previous page": "Предыдущая страница", "Previous page": "Предыдущая страница",
"First page": "Первая страница",
"Clear watch history?": "Очистить историю просмотров?", "Clear watch history?": "Очистить историю просмотров?",
"New password": "Новый пароль", "New password": "Новый пароль",
"New passwords must match": "Новые пароли не совпадают", "New passwords must match": "Новые пароли не совпадают",
@ -49,8 +48,8 @@
"preferences_category_player": "Настройки проигрывателя", "preferences_category_player": "Настройки проигрывателя",
"preferences_video_loop_label": "Всегда повторять: ", "preferences_video_loop_label": "Всегда повторять: ",
"preferences_autoplay_label": "Автовоспроизведение: ", "preferences_autoplay_label": "Автовоспроизведение: ",
"preferences_continue_label": "Воспроизводить следующее видео: ", "preferences_continue_label": "Переходить к следующему видео? ",
"preferences_continue_autoplay_label": "Автовоспроизведение следующего видео: ", "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ",
"preferences_listen_label": "Режим «только аудио» по умолчанию: ", "preferences_listen_label": "Режим «только аудио» по умолчанию: ",
"preferences_local_label": "Проигрывать видео через прокси? ", "preferences_local_label": "Проигрывать видео через прокси? ",
"preferences_speed_label": "Скорость видео по умолчанию: ", "preferences_speed_label": "Скорость видео по умолчанию: ",
@ -514,6 +513,5 @@
"toggle_theme": "Переключатель тем", "toggle_theme": "Переключатель тем",
"carousel_slide": "Пролистано {{current}} из {{total}}", "carousel_slide": "Пролистано {{current}} из {{total}}",
"carousel_skip": "Пропустить всё", "carousel_skip": "Пропустить всё",
"carousel_go_to": "Перейти к странице `x`", "carousel_go_to": "Перейти к странице `x`"
"preferences_preload_label": "Предзагрузка видеоданных: "
} }

View File

@ -13,7 +13,7 @@
"Import and Export Data": "Uvoz in izvoz podatkov", "Import and Export Data": "Uvoz in izvoz podatkov",
"Import": "Uvozi", "Import": "Uvozi",
"Import Invidious data": "Uvozi Invidious JSON podatke", "Import Invidious data": "Uvozi Invidious JSON podatke",
"Import YouTube subscriptions": "Uvozi YouTube CSV ali OPML naročnine", "Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine",
"Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine", "Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine",
"Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke", "Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke",
"Export": "Izvozi", "Export": "Izvozi",
@ -105,7 +105,7 @@
"Show more": "Pokaži več", "Show more": "Pokaži več",
"Switch Invidious Instance": "Preklopi Invidious instanco", "Switch Invidious Instance": "Preklopi Invidious instanco",
"search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.", "search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.",
"search_message_use_another_instance": "Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.", "search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.",
"Wilson score: ": "Wilsonov rezultat: ", "Wilson score: ": "Wilsonov rezultat: ",
"Engagement: ": "Sodelovanje: ", "Engagement: ": "Sodelovanje: ",
"Blacklisted regions: ": "Regije na seznamu nedovoljenih: ", "Blacklisted regions: ": "Regije na seznamu nedovoljenih: ",
@ -462,7 +462,7 @@
"search_filters_features_option_four_k": "4K", "search_filters_features_option_four_k": "4K",
"search_filters_features_option_hdr": "HDR", "search_filters_features_option_hdr": "HDR",
"next_steps_error_message_refresh": "Osveži", "next_steps_error_message_refresh": "Osveži",
"search_filters_date_option_hour": "V zadnji uri", "search_filters_date_option_hour": "Zadnja ura",
"search_filters_features_option_purchased": "Kupljeno", "search_filters_features_option_purchased": "Kupljeno",
"search_filters_sort_label": "Razvrsti po", "search_filters_sort_label": "Razvrsti po",
"search_filters_sort_option_views": "številu ogledov", "search_filters_sort_option_views": "številu ogledov",
@ -521,16 +521,5 @@
"generic_channels_count_1": "{{count}} kanala", "generic_channels_count_1": "{{count}} kanala",
"generic_channels_count_2": "{{count}} kanali", "generic_channels_count_2": "{{count}} kanali",
"generic_channels_count_3": "{{count}} kanalov", "generic_channels_count_3": "{{count}} kanalov",
"Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)", "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)"
"Add to playlist": "Dodaj na seznam predvajanja",
"Add to playlist: ": "Dodaj na seznam predvajanja: ",
"Search for videos": "Iskanje videoposnetkov",
"The Popular feed has been disabled by the administrator.": "Administrator je onemogočil priljubljeni vir.",
"Answer": "Odgovor",
"Filipino (auto-generated)": "filipinščina (samodejno ustvarjeno)",
"toggle_theme": "Preklopi temo",
"carousel_slide": "Diapozitiv {{current}} od {{total}}",
"carousel_skip": "Preskoči galerijo",
"carousel_go_to": "Pojdi na diapozitiv `x`",
"preferences_preload_label": "Predhodno naloži video podatke: "
} }

View File

@ -492,7 +492,5 @@
"The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.", "The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.",
"carousel_skip": "Anashkaloje Rrotullamen", "carousel_skip": "Anashkaloje Rrotullamen",
"carousel_slide": "Diapozitiv {{current}} nga {{total}}", "carousel_slide": "Diapozitiv {{current}} nga {{total}}",
"carousel_go_to": "Kalo te diapozitivi `x`", "carousel_go_to": "Kalo te diapozitivi `x`"
"Filipino (auto-generated)": "Filipineze (të prodhuara automatikisht)",
"preferences_preload_label": "Parangarko të dhëna videoje: "
} }

View File

@ -513,7 +513,5 @@
"Answer": "Odgovor", "Answer": "Odgovor",
"Search for videos": "Pretražite video snimke", "Search for videos": "Pretražite video snimke",
"carousel_skip": "Preskoči karusel", "carousel_skip": "Preskoči karusel",
"toggle_theme": "Подеси тему", "toggle_theme": "Подеси тему"
"preferences_preload_label": "Unapred učitaj podatke o video snimku: ",
"Filipino (auto-generated)": "Filipinski (automatski generisano)"
} }

View File

@ -513,7 +513,5 @@
"Add to playlist: ": "Додајте на плејлисту: ", "Add to playlist: ": "Додајте на плејлисту: ",
"carousel_skip": "Прескочи карусел", "carousel_skip": "Прескочи карусел",
"The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.", "The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.",
"carousel_slide": "Слајд {{current}} од {{total}}", "carousel_slide": "Слајд {{current}} од {{total}}"
"preferences_preload_label": "Унапред учитај податке о видео снимку: ",
"Filipino (auto-generated)": "Филипински (аутоматски генерисано)"
} }

View File

@ -496,7 +496,5 @@
"The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.", "The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.",
"carousel_slide": "Bildspel {{current}} av {{total}}", "carousel_slide": "Bildspel {{current}} av {{total}}",
"carousel_skip": "Hoppa över karusellen", "carousel_skip": "Hoppa över karusellen",
"carousel_go_to": "Gå till bildspel `x`", "carousel_go_to": "Gå till bildspel `x`"
"preferences_preload_label": "Förladda video data: ",
"Filipino (auto-generated)": "Filippinska (auto-genererad)"
} }

View File

@ -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": "கொணர்வி தவிர்க்கவும்"
}

View File

@ -1 +0,0 @@
{}

View File

@ -496,6 +496,5 @@
"carousel_slide": "Sunum {{current}} / {{total}}", "carousel_slide": "Sunum {{current}} / {{total}}",
"carousel_skip": "Kayar menüyü atla", "carousel_skip": "Kayar menüyü atla",
"carousel_go_to": "`x` sunumuna git", "carousel_go_to": "`x` sunumuna git",
"The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı.", "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı."
"preferences_preload_label": "Video verilerini önceden yükle: "
} }

View File

@ -513,7 +513,5 @@
"The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.", "The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.",
"carousel_slide": "Слайд {{current}} з {{total}}", "carousel_slide": "Слайд {{current}} з {{total}}",
"carousel_skip": "Пропустити карусель", "carousel_skip": "Пропустити карусель",
"carousel_go_to": "Перейти до слайда `x`", "carousel_go_to": "Перейти до слайда `x`"
"preferences_preload_label": "Попереднє завантаження відеоданих: ",
"Filipino (auto-generated)": "Філіппінська (згенеровано автоматично)"
} }

View File

@ -479,7 +479,5 @@
"The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。", "The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。",
"carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图", "carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图",
"carousel_skip": "跳过图集", "carousel_skip": "跳过图集",
"carousel_go_to": "转到图 `x`", "carousel_go_to": "转到图 `x`"
"preferences_preload_label": "预加载视频数据: ",
"Filipino (auto-generated)": "菲律宾语 (自动生成)"
} }

View File

@ -479,7 +479,5 @@
"carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張", "carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張",
"carousel_skip": "略過輪播", "carousel_skip": "略過輪播",
"carousel_go_to": "跳到投影片 `x`", "carousel_go_to": "跳到投影片 `x`",
"The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。", "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。"
"preferences_preload_label": "預先載入影片資訊 ",
"Filipino (auto-generated)": "菲律賓語(自動產生)"
} }

View File

@ -1,5 +1,5 @@
name: invidious name: invidious
version: 2.20250314.0-dev version: 2.20241110.0
authors: authors:
- Invidious team <contact@invidious.io> - Invidious team <contact@invidious.io>

View File

@ -97,10 +97,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) GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
COMPANION_POOL = CompanionConnectionPool.new(
capacity: CONFIG.pool_size
)
# CLI # CLI
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
@ -196,9 +192,8 @@ if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32) CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
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::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
@ -244,6 +239,8 @@ add_context_storage_type(Preferences)
add_context_storage_type(Invidious::User) add_context_storage_type(Invidious::User)
Kemal.config.logger = LOGGER Kemal.config.logger = LOGGER
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
Kemal.config.app_name = "Invidious" Kemal.config.app_name = "Invidious"
# Use in kemal's production mode. # Use in kemal's production mode.
@ -252,16 +249,4 @@ Kemal.config.app_name = "Invidious"
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
{% end %} {% end %}
Kemal.run do |config| Kemal.run
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

View File

@ -249,7 +249,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if was_insert if was_insert
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
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 else
LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
end end
@ -281,7 +285,11 @@ def fetch_channel(ucid, pull_all_videos : Bool)
if Time.utc - video.published > 1.minute if Time.utc - video.published > 1.minute
was_insert = Invidious::Database::ChannelVideos.insert(video) was_insert = Invidious::Database::ChannelVideos.insert(video)
if was_insert if was_insert
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 end
end end

View File

@ -44,12 +44,3 @@ def fetch_channel_releases(ucid, author, continuation)
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end end
def fetch_channel_courses(ucid, author, continuation)
if continuation
initial_data = YoutubeAPI.browse(continuation)
else
initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
end
return extract_items(initial_data, author, ucid)
end

View File

@ -8,13 +8,6 @@ struct DBConfig
property dbname : String property dbname : String
end end
struct SocketBindingConfig
include YAML::Serializable
property path : String
property permissions : String
end
struct ConfigPreferences struct ConfigPreferences
include YAML::Serializable include YAML::Serializable
@ -35,7 +28,7 @@ struct ConfigPreferences
property max_results : Int32 = 40 property max_results : Int32 = 40
property notifications_only : Bool = false property notifications_only : Bool = false
property player_style : String = "invidious" property player_style : String = "invidious"
property quality : String = "dash" property quality : String = "hd720"
property quality_dash : String = "auto" property quality_dash : String = "auto"
property default_home : String? = "Popular" property default_home : String? = "Popular"
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
@ -74,16 +67,6 @@ end
class Config class Config
include YAML::Serializable include YAML::Serializable
class CompanionConfig
include YAML::Serializable
@[YAML::Field(converter: Preferences::URIConverter)]
property private_url : URI = URI.parse("")
@[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("")
end
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1 property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update). # Time interval between two executions of the job that crawls channel videos (subscriptions update).
@ -155,8 +138,6 @@ class Config
property port : Int32 = 3000 property port : Int32 = 3000
# Host to bind (overridden by command line argument) # Host to bind (overridden by command line argument)
property host_binding : String = "0.0.0.0" property host_binding : String = "0.0.0.0"
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
property socket_binding : SocketBindingConfig? = nil
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
property pool_size : Int32 = 100 property pool_size : Int32 = 100
# HTTP Proxy configuration # HTTP Proxy configuration
@ -170,12 +151,6 @@ class Config
# poToken for passing bot attestation # poToken for passing bot attestation
property po_token : String? = nil property po_token : String? = nil
# Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig
# Invidious companion API key
property invidious_companion_key : String = ""
# Saved cookies in "name1=value1; name2=value2..." format # Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)] @[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new property cookies : HTTP::Cookies = HTTP::Cookies.new
@ -209,9 +184,6 @@ class Config
config = Config.from_yaml(config_yaml) config = Config.from_yaml(config_yaml)
# Update config from env vars (upcased and prefixed with "INVIDIOUS_") # Update config from env vars (upcased and prefixed with "INVIDIOUS_")
#
# Also checks if any top-level config options are set to "CHANGE_ME!!"
# TODO: Support non-top-level config options such as the ones in DBConfig
{% for ivar in Config.instance_vars %} {% for ivar in Config.instance_vars %}
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
@ -248,40 +220,16 @@ class Config
exit(1) exit(1)
end end
end end
# Warn when any config attribute is set to "CHANGE_ME!!"
if config.{{ivar.id}} == "CHANGE_ME!!"
puts "Config: The value of '#{ {{ivar.stringify}} }' needs to be changed!!"
exit(1)
end
{% end %} {% end %}
if config.invidious_companion.present?
# invidious_companion and signature_server can't work together
if config.signature_server
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
exit(1)
elsif config.invidious_companion_key.empty?
puts "Config: Please configure a key if you are using invidious companion."
exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!"
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
exit(1)
elsif config.invidious_companion_key.size != 16
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
exit(1)
end
elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
else
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/companion-installation/")
end
# HMAC_key is mandatory # HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854 # See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty? if config.hmac_key.empty?
puts "Config: 'hmac_key' is required/can't be empty" puts "Config: 'hmac_key' is required/can't be empty"
exit(1) exit(1)
elsif config.hmac_key == "CHANGE_ME!!"
puts "Config: The value of 'hmac_key' needs to be changed!!"
exit(1)
end end
# Build database_url from db.* if it's not set directly # Build database_url from db.* if it's not set directly
@ -301,24 +249,6 @@ class Config
end end
end end
# Check if the socket configuration is valid
if sb = config.socket_binding
if sb.path.ends_with?("/") || File.directory?(sb.path)
puts "Config: The socket path " + sb.path + " must not be a directory!"
exit(1)
end
d = File.dirname(sb.path)
if !File.directory?(d)
puts "Config: Socket directory " + sb.path + " does not exist or is not a directory!"
exit(1)
end
p = sb.permissions.to_i?(base: 8)
if !p || p < 0 || p > 0o777
puts "Config: Socket permissions must be an octal between 0 and 777!"
exit(1)
end
end
return config return config
end end
end end

View File

@ -119,15 +119,15 @@ module Invidious::Database::Users
# Update (notifs) # Update (notifs)
# ------------------- # -------------------
def add_multiple_notifications(channel_id : String, video_ids : Array(String)) def add_notification(video : ChannelVideo)
request = <<-SQL request = <<-SQL
UPDATE users UPDATE users
SET notifications = array_cat(notifications, $1), SET notifications = array_append(notifications, $1),
feed_needs_update = true feed_needs_update = true
WHERE $2 = ANY(subscriptions) WHERE $2 = ANY(subscriptions)
SQL SQL
PG_DB.exec(request, video_ids, channel_id) PG_DB.exec(request, video.id, video.ucid)
end end
def remove_notification(user : User, vid : String) def remove_notification(user : User, vid : String)
@ -154,14 +154,14 @@ module Invidious::Database::Users
# Update (misc) # Update (misc)
# ------------------- # -------------------
def feed_needs_update(channel_id : String) def feed_needs_update(video : ChannelVideo)
request = <<-SQL request = <<-SQL
UPDATE users UPDATE users
SET feed_needs_update = true SET feed_needs_update = true
WHERE $1 = ANY(subscriptions) WHERE $1 = ANY(subscriptions)
SQL SQL
PG_DB.exec(request, channel_id) PG_DB.exec(request, video.ucid)
end end
def update_preferences(user : User) def update_preferences(user : User)

View File

@ -7,9 +7,8 @@ module Invidious::Frontend::ChannelPage
Streams Streams
Podcasts Podcasts
Releases Releases
Courses
Playlists Playlists
Posts Community
Channels Channels
end end

View File

@ -3,24 +3,6 @@ require "uri"
module Invidious::Frontend::Pagination module Invidious::Frontend::Pagination
extend self extend self
private def first_page(str : String::Builder, locale : String?, url : String)
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
if locale_is_rtl?(locale)
# Inverted arrow ("first" points to the right)
str << translate(locale, "First page")
str << "&nbsp;&nbsp;"
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 << "&nbsp;&nbsp;"
str << translate(locale, "First page")
end
str << "</a>"
end
private def previous_page(str : String::Builder, locale : String?, url : String) private def previous_page(str : String::Builder, locale : String?, url : String)
# Link # Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">) str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
@ -90,24 +72,18 @@ module Invidious::Frontend::Pagination
end end
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| return String.build do |str|
str << %(<div class="h-box">\n) str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n) str << %(<div class="page-nav-container flexible">\n)
str << %(<div class="page-prev-container flex-left">) str << %(<div class="page-prev-container flex-left"></div>\n)
if !first_page
self.first_page(str, locale, base_url.to_s)
end
str << %(</div>\n)
str << %(<div class="page-next-container flex-right">) str << %(<div class="page-next-container flex-right">)
if !ctoken.nil? if !ctoken.nil?
params["continuation"] = ctoken params_next = URI::Params{"continuation" => ctoken}
url_next = HttpServer::Utils.add_params_to_url(base_url, params) url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
self.next_page(str, locale, url_next.to_s) self.next_page(str, locale, url_next.to_s)
end end

View File

@ -13,7 +13,7 @@ module Invidious::Frontend::WatchPage
@full_videos, @full_videos,
@video_streams, @video_streams,
@audio_streams, @audio_streams,
@captions, @captions
) )
end end
end end

View File

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

View File

@ -130,7 +130,7 @@ def error_json_helper(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
exception : Exception, exception : Exception,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil
) )
if exception.is_a?(InfoException) if exception.is_a?(InfoException)
return error_json_helper(env, status_code, exception.message || "", additional_fields) return error_json_helper(env, status_code, exception.message || "", additional_fields)
@ -152,7 +152,7 @@ def error_json_helper(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
message : String, message : String,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil
) )
env.response.content_type = "application/json" env.response.content_type = "application/json"
env.response.status_code = status_code env.response.status_code = status_code

View File

@ -27,7 +27,6 @@ class Kemal::RouteHandler
# Processes the route if it's a match. Otherwise renders 404. # Processes the route if it's a match. Otherwise renders 404.
private def process_request(context) private def process_request(context)
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found? raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
return if context.response.closed?
content = context.route.handler.call(context) content = context.route.handler.call(context)
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context) if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)

View File

@ -54,7 +54,6 @@ LOCALES_LIST = {
"sr" => "Srpski (latinica)", # Serbian (Latin) "sr" => "Srpski (latinica)", # Serbian (Latin)
"sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic)
"sv-SE" => "Svenska", # Swedish "sv-SE" => "Svenska", # Swedish
"ta" => "தமிழ்", # Tamil
"tr" => "Türkçe", # Turkish "tr" => "Türkçe", # Turkish
"uk" => "Українська", # Ukrainian "uk" => "Українська", # Ukrainian
"vi" => "Tiếng Việt", # Vietnamese "vi" => "Tiếng Việt", # Vietnamese

View File

@ -24,7 +24,6 @@ struct SearchVideo
property length_seconds : Int32 property length_seconds : Int32
property premiere_timestamp : Time? property premiere_timestamp : Time?
property author_verified : Bool property author_verified : Bool
property author_thumbnail : String?
property badges : VideoBadges property badges : VideoBadges
def to_xml(auto_generated, query_params, xml : XML::Builder) def to_xml(auto_generated, query_params, xml : XML::Builder)
@ -89,24 +88,6 @@ struct SearchVideo
json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorVerified", self.author_verified json.field "authorVerified", self.author_verified
author_thumbnail = self.author_thumbnail
if author_thumbnail
json.field "authorThumbnails" do
json.array do
qualities = {32, 48, 76, 100, 176, 512}
qualities.each do |quality|
json.object do
json.field "url", author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
json.field "width", quality
json.field "height", quality
end
end
end
end
end
json.field "videoThumbnails" do json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, self.id) Invidious::JSONify::APIv1.thumbnails(json, self.id)
end end
@ -242,7 +223,7 @@ struct SearchChannel
qualities.each do |quality| qualities.each do |quality|
json.object do 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 "width", quality
json.field "height", quality json.field "height", quality
end end

View File

@ -383,22 +383,3 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
end end
return text return text
end end
def encrypt_ecb_without_salt(data, key)
cipher = OpenSSL::Cipher.new("aes-128-ecb")
cipher.encrypt
cipher.key = key
io = IO::Memory.new
io.write(cipher.update(data))
io.write(cipher.final)
io.rewind
return io
end
def invidious_companion_encrypt(data)
timestamp = Time.utc.to_unix
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
return Base64.urlsafe_encode(encrypted_data)
end

View File

@ -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 class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
private getter notification_channel : ::Channel(VideoNotification)
private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)})
private getter pg_url : URI private getter pg_url : URI
def initialize(@notification_channel, @connection_channel, @pg_url) def initialize(@connection_channel, @pg_url)
end end
def begin def begin
@ -34,70 +10,6 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
# hash of channels to their videos (id+published) that need notifying
to_notify = Hash(String, Set(VideoNotification)).new(
->(hash : Hash(String, Set(VideoNotification)), key : String) {
hash[key] = Set(VideoNotification).new
}
)
notify_mutex = Mutex.new
# fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job)
spawn do
begin
loop do
notification = notification_channel.receive
notify_mutex.synchronize do
to_notify[notification.channel_id] << notification
end
end
end
end
# fiber to regularly persist all cached notifications
spawn do
loop do
begin
LOGGER.debug("NotificationJob: waking up")
cloned = {} of String => Set(VideoNotification)
notify_mutex.synchronize do
cloned = to_notify.clone
to_notify.clear
end
cloned.each do |channel_id, notifications|
if notifications.empty?
next
end
LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications")
if CONFIG.enable_user_notifications
video_ids = notifications.map(&.video_id)
Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids)
PG_DB.using_connection do |conn|
notifications.each do |n|
# Deliver notifications to `/api/v1/auth/notifications`
payload = {
"topic" => n.channel_id,
"videoId" => n.video_id,
"published" => n.published.to_unix,
}.to_json
conn.exec("NOTIFY notifications, E'#{payload}'")
end
end
else
Invidious::Database::Users.feed_needs_update(channel_id)
end
end
LOGGER.trace("NotificationJob: Done, sleeping")
rescue ex
LOGGER.error("NotificationJob: #{ex.message}")
end
sleep 1.minute
Fiber.yield
end
end
loop do loop do
action, connection = connection_channel.receive action, connection = connection_channel.receive

View File

@ -267,12 +267,6 @@ module Invidious::JSONify::APIv1
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]? json.field "viewCountText", rv["short_view_count"]?
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
json.field "published", rv["published"]?
if rv["published"]?.try &.presence
json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
else
json.field "publishedText", ""
end
end end
end end
end end

View File

@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
}) })
end end
def template_mix(mix, listen) def template_mix(mix)
html = <<-END_HTML html = <<-END_HTML
<h3> <h3>
<a href="/mix?list=#{mix["mixId"]}"> <a href="/mix?list=#{mix["mixId"]}">
@ -95,7 +95,7 @@ def template_mix(mix, listen)
mix["videos"].as_a.each do |video| mix["videos"].as_a.each do |video|
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>

View File

@ -505,7 +505,7 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
return videos return videos
end end
def template_playlist(playlist, listen) def template_playlist(playlist)
html = <<-END_HTML html = <<-END_HTML
<h3> <h3>
<a href="/playlist?list=#{playlist["playlistId"]}"> <a href="/playlist?list=#{playlist["playlistId"]}">
@ -519,7 +519,7 @@ def template_playlist(playlist, listen)
playlist["videos"].as_a.each do |video| playlist["videos"].as_a.each do |video|
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item" id="#{video["videoId"]}"> <li class="pure-menu-item" id="#{video["videoId"]}">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}"> <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<div class="thumbnail"> <div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>

View File

@ -328,9 +328,17 @@ module Invidious::Routes::Account
end end
end end
case action = env.params.query["action"]? if env.params.query["action_revoke_token"]?
when "revoke_token" action = "action_revoke_token"
session = env.params.query["session"] 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) Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else else
return error_json(400, "Unsupported action #{action}") return error_json(400, "Unsupported action #{action}")

View File

@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest
id = env.params.url["id"] id = env.params.url["id"]
region = env.params.query["region"]? region = env.params.query["region"]?
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
end
# Since some implementations create playlists based on resolution regardless of different codecs, # Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation # we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@ -32,21 +27,28 @@ module Invidious::Routes::API::Manifest
haltf env, status_code: response.status_code haltf env, status_code: response.status_code
end end
# Proxy URLs for video playback on invidious. manifest = response.body
# Other API clients can get the original URLs by omiting `local=true`.
manifest = response.body.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
url = baseurl.lchop("<BaseURL>").rchop("</BaseURL>") url = baseurl.lchop("<BaseURL>")
url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local url = url.rchop("</BaseURL>")
if local
uri = URI.parse(url)
url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/"
end
"<BaseURL>#{url}</BaseURL>" "<BaseURL>#{url}</BaseURL>"
end end
return manifest return manifest
end end
# Ditto, only proxify URLs if `local=true` is used adaptive_fmts = video.adaptive_fmts
if local if local
video.adaptive_fmts.each do |fmt| adaptive_fmts.each do |fmt|
fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true)) fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}")
end end
end end
@ -68,23 +70,17 @@ module Invidious::Routes::API::Manifest
# OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any
lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und"
is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0
displayname = audio_track["displayName"]?.try &.as_s || "Unknown"
bitrate = fmt["bitrate"]
# Different representations of the same audio should be groupped into one AdaptationSet. # Different representations of the same audio should be groupped into one AdaptationSet.
# However, most players don't support auto quality switching, so we have to trick them # However, most players don't support auto quality switching, so we have to trick them
# into providing a quality selector. # into providing a quality selector.
# See https://github.com/iv-org/invidious/issues/3074 for more details. # See https://github.com/iv-org/invidious/issues/3074 for more details.
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{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('"') codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i itag = fmt["itag"].as_i
url = fmt["url"].as_s url = fmt["url"].as_s
xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: 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("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
@ -181,9 +177,8 @@ module Invidious::Routes::API::Manifest
manifest = response.body manifest = response.body
if local if local
manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match| manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
uri = URI.parse(match) path = URI.parse(match).path
path = uri.path
path = path.lchop("/videoplayback/") path = path.lchop("/videoplayback/")
path = path.rchop("/") path = path.rchop("/")
@ -212,7 +207,7 @@ module Invidious::Routes::API::Manifest
raw_params["fvip"] = fvip["fvip"] raw_params["fvip"] = fvip["fvip"]
end end
raw_params["host"] = uri.host.not_nil! raw_params["local"] = "true"
"#{HOST_URL}/videoplayback?#{raw_params}" "#{HOST_URL}/videoplayback?#{raw_params}"
end end

View File

@ -368,35 +368,6 @@ module Invidious::Routes::API::V1::Channels
end end
end end
def self.courses(env)
locale = env.get("preferences").as(Preferences).locale
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
continuation = env.params.query["continuation"]?
# Use the macro defined above
channel = nil # Make the compiler happy
get_channel()
items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
JSON.build do |json|
json.object do
json.field "playlists" do
json.array do
items.each do |item|
item.to_json(locale, json) if item.is_a?(SearchPlaylist)
end
end
end
json.field "continuation", next_continuation if next_continuation
end
end
end
def self.community(env) def self.community(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale

View File

@ -42,9 +42,6 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
listen_param = env.params.query["listen"]?
listen = (listen_param == "true" || listen_param == "1")
if plid.starts_with? "RD" if plid.starts_with? "RD"
return env.redirect "/api/v1/mixes/#{plid}" return env.redirect "/api/v1/mixes/#{plid}"
end end
@ -88,7 +85,7 @@ module Invidious::Routes::API::V1::Misc
end end
if format == "html" 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} index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
response = { response = {
@ -114,9 +111,6 @@ module Invidious::Routes::API::V1::Misc
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
listen_param = env.params.query["listen"]?
listen = (listen_param == "true" || listen_param == "1")
begin begin
mix = fetch_mix(rdid, continuation, locale: locale) mix = fetch_mix(rdid, continuation, locale: locale)
@ -147,7 +141,9 @@ module Invidious::Routes::API::V1::Misc
json.field "authorUrl", "/channel/#{video.ucid}" json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
Invidious::JSONify::APIv1.thumbnails(json, video.id) json.array do
Invidious::JSONify::APIv1.thumbnails(json, video.id)
end
end end
json.field "index", video.index json.field "index", video.index
@ -161,7 +157,7 @@ module Invidious::Routes::API::V1::Misc
if format == "html" if format == "html"
response = JSON.parse(response) response = JSON.parse(response)
playlist_html = template_mix(response, listen) playlist_html = template_mix(response)
next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
response = { response = {

View File

@ -429,90 +429,4 @@ module Invidious::Routes::API::V1::Videos
end end
end end
end end
# Fetches transcripts from YouTube
#
# Use the `lang` and `autogen` query parameter to select which transcript to fetch
# Request without any URL parameters to see all the available transcripts.
def self.transcripts(env)
env.response.content_type = "application/json"
id = env.params.url["id"]
lang = env.params.query["lang"]?
label = env.params.query["label"]?
auto_generated = env.params.query["autogen"]? ? true : false
# Return all available transcript options when none is given
if !label && !lang
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
response = JSON.build do |json|
# The amount of transcripts available to fetch is the
# same as the amount of captions available.
available_transcripts = video.captions
json.object do
json.field "transcripts" do
json.array do
available_transcripts.each do |transcript|
json.object do
json.field "label", transcript.name
json.field "languageCode", transcript.language_code
json.field "autoGenerated", transcript.auto_generated
if transcript.auto_generated
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen"
else
json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}"
end
end
end
end
end
end
end
return response
end
# If lang is not given then we attempt to fetch
# the transcript through the given label
if lang.nil?
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
target_transcript = video.captions.select(&.name.== label)
if target_transcript.empty?
return error_json(404, NotFoundException.new("Requested transcript does not exist"))
else
target_transcript = target_transcript[0]
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
end
end
params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated)
begin
transcript = Invidious::Videos::Transcript.from_raw(
YoutubeAPI.get_transcript(params), lang, auto_generated
)
rescue ex : NotFoundException
return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
return transcript.to_json
end
end end

View File

@ -197,29 +197,7 @@ module Invidious::Routes::Channels
templated "channel" templated "channel"
end end
def self.courses(env)
data = self.fetch_basic_information(env)
return data if !data.is_a?(Tuple)
locale, user, subscriptions, continuation, ucid, channel = data
sort_by = ""
sort_options = [] of String
items, next_continuation = fetch_channel_courses(
channel.ucid, channel.author, continuation
)
items = items.select(SearchPlaylist)
items.each(&.author = "")
selected_tab = Frontend::ChannelPage::TabsAvailable::Courses
templated "channel"
end
def self.community(env) def self.community(env)
return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts"
data = self.fetch_basic_information(env) data = self.fetch_basic_information(env)
if !data.is_a?(Tuple) if !data.is_a?(Tuple)
return data return data
@ -236,7 +214,7 @@ module Invidious::Routes::Channels
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
if !channel.tabs.includes? "community" && "posts" if !channel.tabs.includes? "community"
return env.redirect "/channel/#{channel.ucid}" return env.redirect "/channel/#{channel.ucid}"
end end
@ -329,8 +307,7 @@ module Invidious::Routes::Channels
private KNOWN_TABS = { private KNOWN_TABS = {
"home", "videos", "shorts", "streams", "podcasts", "home", "videos", "shorts", "streams", "podcasts",
"releases", "courses", "playlists", "community", "channels", "about", "releases", "playlists", "community", "channels", "about",
"posts",
} }
# Redirects brand url channels to a normal /channel/:ucid route # Redirects brand url channels to a normal /channel/:ucid route

View File

@ -157,12 +157,10 @@ module Invidious::Routes::Embed
adaptive_fmts = video.adaptive_fmts adaptive_fmts = video.adaptive_fmts
if params.local if params.local
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
end end
# Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
video_streams = video.video_streams video_streams = video.video_streams
audio_streams = video.audio_streams audio_streams = video.audio_streams
@ -203,14 +201,6 @@ module Invidious::Routes::Embed
return env.redirect url return env.redirect url
end end
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
end
rendered "embed" rendered "embed"
end end
end end

View File

@ -143,25 +143,32 @@ module Invidious::Routes::Feeds
# RSS feeds # RSS feeds
def self.rss_channel(env) def self.rss_channel(env)
locale = env.get("preferences").as(Preferences).locale
env.response.headers["Content-Type"] = "application/atom+xml" env.response.headers["Content-Type"] = "application/atom+xml"
env.response.content_type = "application/atom+xml" env.response.content_type = "application/atom+xml"
if env.params.url["ucid"].matches?(/^[\w-]+$/) ucid = env.params.url["ucid"]
ucid = env.params.url["ucid"]
else
return error_atom(400, InfoException.new("Invalid channel ucid provided."))
end
params = HTTP::Params.parse(env.params.query["params"]? || "") 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 = { namespaces = {
"yt" => "http://www.youtube.com/xml/schemas/2015", "yt" => "http://www.youtube.com/xml/schemas/2015",
"media" => "http://search.yahoo.com/mrss/", "media" => "http://search.yahoo.com/mrss/",
"default" => "http://www.w3.org/2005/Atom", "default" => "http://www.w3.org/2005/Atom",
} }
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}") response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body) rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@ -172,7 +179,7 @@ module Invidious::Routes::Feeds
updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content)
author = entry.xpath_node("default:author/default:name", 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 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 views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64
@ -180,44 +187,41 @@ module Invidious::Routes::Feeds
title: title, title: title,
id: video_id, id: video_id,
author: author, author: author,
ucid: video_ucid, ucid: ucid,
published: published, published: published,
views: views, views: views,
description_html: description_html, description_html: description_html,
length_seconds: 0, length_seconds: 0,
premiere_timestamp: nil, premiere_timestamp: nil,
author_verified: false, author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::None,
}) })
end end
author = ""
author = videos[0].author if videos.size > 0
XML.build(indent: " ", encoding: "UTF-8") do |xml| XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", 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", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom",
"xml:lang": "en-US") do "xml:lang": "en-US") do
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
xml.element("id") { xml.text "yt:channel:#{ucid}" } xml.element("id") { xml.text "yt:channel:#{channel.ucid}" }
xml.element("yt:channelId") { xml.text ucid } xml.element("yt:channelId") { xml.text channel.ucid }
xml.element("title") { author } xml.element("icon") { xml.text channel.author_thumbnail }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}") xml.element("title") { xml.text channel.author }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
xml.element("author") do xml.element("author") do
xml.element("name") { xml.text author } xml.element("name") { xml.text channel.author }
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
end end
xml.element("image") do xml.element("image") do
xml.element("url") { xml.text "" } xml.element("url") { xml.text channel.author_thumbnail }
xml.element("title") { xml.text author } xml.element("title") { xml.text channel.author }
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
end end
videos.each do |video| videos.each do |video|
video.to_xml(false, params, xml) video.to_xml(channel.auto_generated, params, xml)
end end
end end
end end
@ -305,9 +309,8 @@ module Invidious::Routes::Feeds
end end
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") 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 = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
node.attributes.each do |attribute| node.attributes.each do |attribute|
case attribute.name case attribute.name
@ -420,6 +423,16 @@ module Invidious::Routes::Feeds
next # skip this video since it raised an exception (e.g. it is a scheduled live event) next # skip this video since it raised an exception (e.g. it is a scheduled live event)
end end
if CONFIG.enable_user_notifications
# Deliver notifications to `/api/v1/auth/notifications`
payload = {
"topic" => video.ucid,
"videoId" => video.id,
"published" => published.to_unix,
}.to_json
PG_DB.exec("NOTIFY notifications, E'#{payload}'")
end
video = ChannelVideo.new({ video = ChannelVideo.new({
id: id, id: id,
title: video.title, title: video.title,
@ -435,7 +448,11 @@ module Invidious::Routes::Feeds
was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true)
if was_insert if was_insert
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 end
end end

View File

@ -111,7 +111,7 @@ module Invidious::Routes::Images
if name == "maxres.jpg" if name == "maxres.jpg"
build_thumbnails(id).each do |thumb| build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
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" name = thumb[:url] + ".jpg"
break break
end end

View File

@ -42,17 +42,12 @@ module Invidious::Routes::Misc
referer = get_referer(env) referer = get_referer(env)
instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"]
# Filter out the current instance if instance_list.empty?
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
instance_url = "redirect.invidious.io" instance_url = "redirect.invidious.io"
else else
# Select other random instance
# Sample returns an array # Sample returns an array
# Instances are packaged as {region, domain} in the instance list # 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 end
env.redirect "https://#{instance_url}#{referer}" env.redirect "https://#{instance_url}#{referer}"

View File

@ -304,6 +304,23 @@ module Invidious::Routes::Playlists
end end
end end
if env.params.query["action_create_playlist"]?
action = "action_create_playlist"
elsif env.params.query["action_delete_playlist"]?
action = "action_delete_playlist"
elsif env.params.query["action_edit_playlist"]?
action = "action_edit_playlist"
elsif env.params.query["action_add_video"]?
action = "action_add_video"
video_id = env.params.query["video_id"]
elsif env.params.query["action_remove_video"]?
action = "action_remove_video"
elsif env.params.query["action_move_video_before"]?
action = "action_move_video_before"
else
return env.redirect referer
end
begin begin
playlist_id = env.params.query["playlist_id"] playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist) playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
@ -318,8 +335,12 @@ module Invidious::Routes::Playlists
end end
end end
case action = env.params.query["action"]? email = user.email
when "add_video"
case action
when "action_edit_playlist"
# TODO: Playlist stub
when "action_add_video"
if playlist.index.size >= CONFIG.playlist_length_limit if playlist.index.size >= CONFIG.playlist_length_limit
if redirect if redirect
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
@ -356,14 +377,12 @@ module Invidious::Routes::Playlists
Invidious::Database::PlaylistVideos.insert(playlist_video) Invidious::Database::PlaylistVideos.insert(playlist_video)
Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index)
when "remove_video" when "action_remove_video"
index = env.params.query["set_video_id"] index = env.params.query["set_video_id"]
Invidious::Database::PlaylistVideos.delete(index) Invidious::Database::PlaylistVideos.delete(index)
Invidious::Database::Playlists.update_video_removed(playlist_id, index) Invidious::Database::Playlists.update_video_removed(playlist_id, index)
when "move_video_before" when "action_move_video_before"
# TODO: Playlist stub # TODO: Playlist stub
when nil
return error_json(400, "Missing action")
else else
return error_json(400, "Unsupported action #{action}") return error_json(400, "Unsupported action #{action}")
end end

View File

@ -32,16 +32,24 @@ module Invidious::Routes::Subscriptions
end end
end end
if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1
action = "action_create_subscription_to_channel"
elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1
action = "action_remove_subscriptions"
else
return env.redirect referer
end
channel_id = env.params.query["c"]? channel_id = env.params.query["c"]?
channel_id ||= "" channel_id ||= ""
case action = env.params.query["action"]? case action
when "create_subscription_to_channel" when "action_create_subscription_to_channel"
if !user.subscriptions.includes? channel_id if !user.subscriptions.includes? channel_id
get_channel(channel_id) get_channel(channel_id)
Invidious::Database::Users.subscribe_channel(user, channel_id) Invidious::Database::Users.subscribe_channel(user, channel_id)
end end
when "remove_subscriptions" when "action_remove_subscriptions"
Invidious::Database::Users.unsubscribe_channel(user, channel_id) Invidious::Database::Users.unsubscribe_channel(user, channel_id)
else else
return error_json(400, "Unsupported action #{action}") return error_json(400, "Unsupported action #{action}")

View File

@ -164,13 +164,10 @@ module Invidious::Routes::VideoPlayback
env.response.headers["Access-Control-Allow-Origin"] = "*" env.response.headers["Access-Control-Allow-Origin"] = "*"
if location = resp.headers["Location"]? if location = resp.headers["Location"]?
url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) location = URI.parse(location)
location = "#{location.request_target}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
if title = query_params["title"]? env.redirect location
url = "#{url}&title=#{URI.encode_www_form(title)}"
end
env.redirect url
break break
end end
@ -256,11 +253,6 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours, # YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version # so we have a mechanism here to redirect to the latest version
def self.latest_version(env) def self.latest_version(env)
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
end
id = env.params.query["id"]? id = env.params.query["id"]?
itag = env.params.query["itag"]?.try &.to_i? itag = env.params.query["itag"]?.try &.to_i?

View File

@ -121,12 +121,10 @@ module Invidious::Routes::Watch
adaptive_fmts = video.adaptive_fmts adaptive_fmts = video.adaptive_fmts
if params.local if params.local
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) }
end end
# Always proxy DASH streams, otherwise youtube CORS headers will prevent playback
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) }
video_streams = video.video_streams video_streams = video.video_streams
audio_streams = video.audio_streams audio_streams = video.audio_streams
@ -192,14 +190,6 @@ module Invidious::Routes::Watch
captions: video.captions captions: video.captions
) )
if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
end
templated "watch" templated "watch"
end end
@ -251,10 +241,18 @@ module Invidious::Routes::Watch
end end
end end
case action = env.params.query["action"]? if env.params.query["action_mark_watched"]?
when "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, id) Invidious::Database::Users.mark_watched(user, id)
when "mark_unwatched" when "action_mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id) Invidious::Database::Users.mark_unwatched(user, id)
else else
return error_json(400, "Unsupported action #{action}") return error_json(400, "Unsupported action #{action}")
@ -322,19 +320,14 @@ module Invidious::Routes::Watch
env.params.query["label"] = URI.decode_www_form(label.as_s) env.params.query["label"] = URI.decode_www_form(label.as_s)
return Invidious::Routes::API::V1::Videos.captions(env) return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i.to_s elsif itag = download_widget["itag"]?.try &.as_i
# URL params specific to /latest_version # URL params specific to /latest_version
env.params.query["id"] = video_id env.params.query["id"] = video_id
env.params.query["itag"] = itag.to_s
env.params.query["title"] = filename env.params.query["title"] = filename
env.params.query["local"] = "true" env.params.query["local"] = "true"
if (CONFIG.invidious_companion.present?) return Invidious::Routes::VideoPlayback.latest_version(env)
video = get_video(video_id)
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
else
return Invidious::Routes::VideoPlayback.latest_version(env)
end
else else
return error_template(400, "Invalid label or itag") return error_template(400, "Invalid label or itag")
end end

View File

@ -120,10 +120,8 @@ module Invidious::Routing
get "/channel/:ucid/streams", Routes::Channels, :streams get "/channel/:ucid/streams", Routes::Channels, :streams
get "/channel/:ucid/podcasts", Routes::Channels, :podcasts get "/channel/:ucid/podcasts", Routes::Channels, :podcasts
get "/channel/:ucid/releases", Routes::Channels, :releases get "/channel/:ucid/releases", Routes::Channels, :releases
get "/channel/:ucid/courses", Routes::Channels, :courses
get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/playlists", Routes::Channels, :playlists
get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/community", Routes::Channels, :community
get "/channel/:ucid/posts", Routes::Channels, :community
get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/channels", Routes::Channels, :channels
get "/channel/:ucid/about", Routes::Channels, :about get "/channel/:ucid/about", Routes::Channels, :about
@ -238,7 +236,6 @@ module Invidious::Routing
get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
get "/api/v1/comments/:id", {{namespace}}::Videos, :comments get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
get "/api/v1/clips/:id", {{namespace}}::Videos, :clips get "/api/v1/clips/:id", {{namespace}}::Videos, :clips
get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts
# Feeds # Feeds
get "/api/v1/trending", {{namespace}}::Feeds, :trending get "/api/v1/trending", {{namespace}}::Feeds, :trending
@ -252,10 +249,8 @@ module Invidious::Routing
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
get "/api/v1/channels/:ucid/courses", {{namespace}}::Channels, :courses
get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists
get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community
get "/api/v1/channels/:ucid/posts", {{namespace}}::Channels, :community
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search

View File

@ -75,7 +75,7 @@ module Invidious::Search
@type : Type = Type::All, @type : Type = Type::All,
@duration : Duration = Duration::None, @duration : Duration = Duration::None,
@features : Features = Features::None, @features : Features = Features::None,
@sort : Sort = Sort::Relevance, @sort : Sort = Sort::Relevance
) )
end end

View File

@ -47,7 +47,7 @@ module Invidious::Search
def initialize( def initialize(
params : HTTP::Params, params : HTTP::Params,
@type : Type = Type::Regular, @type : Type = Type::Regular,
@region : String? = nil, @region : String? = nil
) )
# Get the raw search query string (common to all search types). In # Get the raw search query string (common to all search types). In
# Regular search mode, also look for the `search_query` URL parameter # Regular search mode, also look for the `search_query` URL parameter

View File

@ -290,39 +290,42 @@ struct Invidious::User
end end
def from_newpipe(user : User, body : String) : Bool def from_newpipe(user : User, body : String) : Bool
Compress::Zip::File.open(IO::Memory.new(body), true) do |file| io = IO::Memory.new(body)
entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" }
return false if entry.nil?
entry.open do |file_io|
# Ensure max size of 4MB
io_sized = IO::Sized.new(file_io, 0x400000)
begin Compress::Zip::File.open(io) do |file|
temp = File.tempfile(".db") do |tempfile| file.entries.each do |entry|
begin entry.open do |file_io|
File.write(tempfile.path, io_sized.gets_to_end) # Ensure max size of 4MB
rescue io_sized = IO::Sized.new(file_io, 0x400000)
return false
end
DB.open("sqlite3://" + tempfile.path) do |db| next if entry.filename != "newpipe.db"
user.watched += db.query_all("SELECT url FROM streams", as: String)
.map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq! tempfile = File.tempfile(".db")
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) begin
.map(&.lchop("https://www.youtube.com/channel/")) File.write(tempfile.path, io_sized.gets_to_end)
rescue
user.subscriptions.uniq! return false
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
end
end end
ensure
temp.delete if !temp.nil? db = DB.open("sqlite3://" + tempfile.path)
user.watched += db.query_all("SELECT url FROM streams", as: String)
.map(&.lchop("https://www.youtube.com/watch?v="))
user.watched.uniq!
Invidious::Database::Users.update_watch_history(user)
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
.map(&.lchop("https://www.youtube.com/channel/"))
user.subscriptions.uniq!
user.subscriptions = get_batch_channels(user.subscriptions)
Invidious::Database::Users.update_subscriptions(user)
db.close
tempfile.delete
end end
end end
end end

View File

@ -15,7 +15,7 @@ struct Video
# NOTE: don't forget to bump this number if any change is made to # NOTE: don't forget to bump this number if any change is made to
# the `params` structure in videos/parser.cr!!! # the `params` structure in videos/parser.cr!!!
# #
SCHEMA_VERSION = 3 SCHEMA_VERSION = 2
property id : String property id : String
@ -106,7 +106,7 @@ struct Video
if formats = info.dig?("streamingData", "adaptiveFormats") if formats = info.dig?("streamingData", "adaptiveFormats")
return formats return formats
.as_a.map(&.as_h) .as_a.map(&.as_h)
.sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 } .sort_by! { |f| f["width"]?.try &.as_i || 0 }
else else
return [] of Hash(String, JSON::Any) return [] of Hash(String, JSON::Any)
end end

View File

@ -36,13 +36,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
if published_time_text = related["publishedTimeText"]?
decoded_time = decode_date(published_time_text["simpleText"].to_s)
published = decoded_time.to_rfc3339.to_s
else
published = nil
end
# TODO: when refactoring video types, make a struct for related videos # TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits. # or reuse an existing type, if that fits.
return { return {
@ -54,7 +47,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
"view_count" => JSON::Any.new(view_count || "0"), "view_count" => JSON::Any.new(view_count || "0"),
"short_view_count" => JSON::Any.new(short_view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"),
"author_verified" => JSON::Any.new(author_verified), "author_verified" => JSON::Any.new(author_verified),
"published" => JSON::Any.new(published || ""),
} }
end end
@ -108,29 +100,27 @@ def extract_video_info(video_id : String)
params = parse_video_info(video_id, player_response) params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason params["reason"] = JSON::Any.new(reason) if reason
if !CONFIG.invidious_companion.present? new_player_response = nil
new_player_response = nil
# Don't use Android test suite client if po_token is passed because po_token doesn't # Don't use Android test suite client if po_token is passed because po_token doesn't
# work for Android test suite client. # work for Android test suite client.
if reason.nil? && CONFIG.po_token.nil? if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the # Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the # decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs: # following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config) new_player_response = try_fetch_streaming_data(video_id, client_config)
end end
# Replace player response and reset reason # Replace player response and reset reason
if !new_player_response.nil? if !new_player_response.nil?
# Preserve captions & storyboard data before replacement # Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]? new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response player_response = new_player_response
params.delete("reason") params.delete("reason")
end
end end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|

View File

@ -20,7 +20,7 @@ module Invidious::Videos
def initialize( def initialize(
*, @url, @width, @height, @count, @interval, *, @url, @width, @height, @count, @interval,
@rows, @columns, @images_count, @rows, @columns, @images_count
) )
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]? authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?

View File

@ -122,40 +122,5 @@ module Invidious::Videos
return vtt return vtt
end end
def to_json(json : JSON::Builder)
json.field "languageCode", @language_code
json.field "autoGenerated", @auto_generated
json.field "label", @label
json.field "body" do
json.array do
@lines.each do |line|
json.object do
if line.is_a? HeadingLine
json.field "type", "heading"
else
json.field "type", "regular"
end
json.field "startMs", line.start_ms.total_milliseconds
json.field "endMs", line.end_ms.total_milliseconds
json.field "line", line.line
end
end
end
end
end
def to_json
JSON.build do |json|
json.object do
json.field "transcript" do
json.object do
to_json(json)
end
end
end
end
end
end end
end end

View File

@ -11,7 +11,6 @@
when .channels? then "/channel/#{ucid}/channels" when .channels? then "/channel/#{ucid}/channels"
when .podcasts? then "/channel/#{ucid}/podcasts" when .podcasts? then "/channel/#{ucid}/podcasts"
when .releases? then "/channel/#{ucid}/releases" when .releases? then "/channel/#{ucid}/releases"
when .courses? then "/channel/#{ucid}/courses"
else else
"/channel/#{ucid}" "/channel/#{ucid}"
end end
@ -21,9 +20,7 @@
page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale,
base_url: relative_url, base_url: relative_url,
ctoken: next_continuation, ctoken: next_continuation
first_page: continuation.nil?,
params: env.params.query,
) )
%> %>
@ -43,8 +40,6 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" /> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" />
<%- end -%> <%- end -%>
<script src="/js/pagination.js?v=<%= ASSET_COMMIT %>"></script>
<link rel="alternate" href="<%= youtube_url %>"> <link rel="alternate" href="<%= youtube_url %>">
<title><%= author %> - Invidious</title> <title><%= author %> - Invidious</title>
<% end %> <% end %>

View File

@ -7,7 +7,7 @@
youtube_url = "https://www.youtube.com#{relative_url}" youtube_url = "https://www.youtube.com#{relative_url}"
redirect_url = Invidious::Frontend::Misc.redirect_url(env) redirect_url = Invidious::Frontend::Misc.redirect_url(env)
selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Posts selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community
-%> -%>
<% content_for "header" do %> <% content_for "header" do %>

View File

@ -128,7 +128,7 @@
<div class="top-left-overlay"> <div class="top-left-overlay">
<%- if env.get? "show_watched" -%> <%- if env.get? "show_watched" -%>
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_watched&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile" <button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_watched" data-id="<%= item.id %>"> data-onclick="mark_watched" data-id="<%= item.id %>">
@ -138,14 +138,14 @@
<%- end -%> <%- end -%>
<%- if plid_form = env.get?("add_playlist_items") -%> <%- if plid_form = env.get?("add_playlist_items") -%>
<%- form_parameters = "action=add_video&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile" <button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button> data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
</form> </form>
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%> <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
<%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile" <button type="submit" class="pure-button pure-button-secondary low-profile"

View File

@ -8,14 +8,4 @@
<%= page_nav_html %> <%= page_nav_html %>
<script id="pagination-data" type="application/json">
<%=
{
"next_page" => translate(locale, "Next page"),
"prev_page" => translate(locale, "Previous page"),
"is_rtl" => locale_is_rtl?(locale)
}.to_pretty_json
%>
</script>
<script src="/js/watched_indicator.js"></script> <script src="/js/watched_indicator.js"></script>

View File

@ -4,7 +4,7 @@
<% if params.autoplay %>autoplay<% end %> <% if params.autoplay %>autoplay<% end %>
<% if params.video_loop %>loop<% end %> <% if params.video_loop %>loop<% end %>
<% if params.controls %>controls<% end %>> <% if params.controls %>controls<% end %>>
<% if (hlsvp = video.hls_manifest_url) && video.live_now && !CONFIG.disabled?("livestreams") %> <% if (hlsvp = video.hls_manifest_url) && !CONFIG.disabled?("livestreams") %>
<source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream"> <source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
<% else %> <% else %>
<% if params.listen %> <% if params.listen %>
@ -22,8 +22,6 @@
audio_streams.each_with_index do |fmt, i| audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
bitrate = fmt["bitrate"] bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)
@ -36,12 +34,8 @@
<% end %> <% end %>
<% end %> <% end %>
<% else %> <% else %>
<% if params.quality == "dash" <% if params.quality == "dash" %>
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1" <source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
%>
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
<% end %> <% end %>
<% <%
@ -50,8 +44,6 @@
fmt_stream.each_with_index do |fmt, i| fmt_stream.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local src_url += "&local=true" if params.local
src_url = invidious_companion.public_url.to_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (invidious_companion)
quality = fmt["quality"] quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s) mimetype = HTML.escape(fmt["mimeType"].as_s)

View File

@ -1,13 +1,13 @@
<% if user %> <% if user %>
<% if subscriptions.includes? ucid %> <% if subscriptions.includes? ucid %>
<form action="/subscription_ajax?action=remove_subscriptions&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> <form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary"> <button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button> </button>
</form> </form>
<% else %> <% else %>
<form action="/subscription_ajax?action=create_subscription_to_channel&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> <form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary"> <button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>

View File

@ -37,7 +37,7 @@
</a> </a>
<div class="top-left-overlay"><div class="watched"> <div class="top-left-overlay"><div class="watched">
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile" <button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button> data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>

View File

@ -37,7 +37,7 @@
<div class="pure-u-2-5"></div> <div class="pure-u-2-5"></div>
<div class="pure-u-1-5" style="text-align:right"> <div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em"> <h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action=remove_subscriptions&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>"> <input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form> </form>

View File

@ -29,7 +29,7 @@
</div> </div>
<div class="pure-u-1-5" style="text-align:right"> <div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em"> <h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/token_ajax?action=revoke_token&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>"> <input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
</form> </form>

View File

@ -158,7 +158,7 @@ we're going to need to do it here in order to allow for translations.
<% if user %> <% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %> <% if !playlists.empty? %>
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax?action=add_video" method="post" target="_blank"> <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank">
<div class="pure-control-group"> <div class="pure-control-group">
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label> <label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
<select style="width:100%" name="playlist_id" id="playlist_id"> <select style="width:100%" name="playlist_id" id="playlist_id">
@ -169,6 +169,7 @@ we're going to need to do it here in order to allow for translations.
</div> </div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<input type="hidden" name="action_add_video" value="1">
<input type="hidden" name="video_id" value="<%= video.id %>"> <input type="hidden" name="video_id" value="<%= video.id %>">
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary"> <button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
<b><%= translate(locale, "Add to playlist") %></b> <b><%= translate(locale, "Add to playlist") %></b>

View File

@ -46,43 +46,6 @@ struct YoutubeConnectionPool
end end
end end
struct CompanionConnectionPool
property pool : DB::Pool(HTTP::Client)
def initialize(capacity = 5, timeout = 5.0)
options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: capacity,
max_idle_pool_size: capacity,
checkout_timeout: timeout
)
@pool = DB::Pool(HTTP::Client).new(options) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
end
end
def client(&)
conn = pool.checkout
begin
response = yield conn
rescue ex
conn.close
companion = CONFIG.invidious_companion.sample
conn = make_client(companion.private_url, use_http_proxy: false)
response = yield conn
ensure
pool.release(conn)
end
response
end
end
def add_yt_headers(request) def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
@ -98,9 +61,9 @@ def add_yt_headers(request)
end end
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true) def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false)
client = HTTP::Client.new(url) client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
# Force the usage of a specific configured IP Family # Force the usage of a specific configured IP Family
if force_resolve if force_resolve
@ -115,8 +78,8 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you
return client return client
end end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &) def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy) client = make_client(url, region, force_resolve: force_resolve)
begin begin
yield client yield client
ensure ensure

View File

@ -67,8 +67,6 @@ private module Parsers
author_id = author_fallback.id author_id = author_fallback.id
end end
author_thumbnail = item_contents.dig?("channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail", "thumbnails", 0, "url").try &.as_s
author_verified = has_verified_badge?(item_contents["ownerBadges"]?) author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
# For live videos (and possibly recently premiered videos) there is no published information. # For live videos (and possibly recently premiered videos) there is no published information.
@ -150,7 +148,6 @@ private module Parsers
length_seconds: length_seconds, length_seconds: length_seconds,
premiere_timestamp: premiere_timestamp, premiere_timestamp: premiere_timestamp,
author_verified: author_verified, author_verified: author_verified,
author_thumbnail: author_thumbnail,
badges: badges, badges: badges,
}) })
end end
@ -582,7 +579,6 @@ private module Parsers
length_seconds: duration, length_seconds: duration,
premiere_timestamp: Time.unix(0), premiere_timestamp: Time.unix(0),
author_verified: false, author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::None,
}) })
end end
@ -712,7 +708,6 @@ private module Parsers
length_seconds: duration, length_seconds: duration,
premiere_timestamp: Time.unix(0), premiere_timestamp: Time.unix(0),
author_verified: false, author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::None,
}) })
end end
@ -1029,7 +1024,7 @@ end
def extract_items( def extract_items(
initial_data : InitialData, initial_data : InitialData,
author_fallback : String? = nil, author_fallback : String? = nil,
author_id_fallback : String? = nil, author_id_fallback : String? = nil
) : {Array(SearchItem), String?} ) : {Array(SearchItem), String?}
items = [] of SearchItem items = [] of SearchItem
continuation = nil continuation = nil

View File

@ -211,7 +211,7 @@ module YoutubeAPI
def initialize( def initialize(
*, *,
@client_type = ClientType::Web, @client_type = ClientType::Web,
@region = "US", @region = "US"
) )
end end
@ -370,7 +370,7 @@ module YoutubeAPI
browse_id : String, browse_id : String,
*, # Force the following parameters to be passed by name *, # Force the following parameters to be passed by name
params : String, params : String,
client_config : ClientConfig | Nil = nil, client_config : ClientConfig | Nil = nil
) )
# JSON Request data, required by the API # JSON Request data, required by the API
data = { data = {
@ -464,7 +464,7 @@ module YoutubeAPI
video_id : String, video_id : String,
*, # Force the following parameters to be passed by name *, # Force the following parameters to be passed by name
params : String, params : String,
client_config : ClientConfig | Nil = nil, client_config : ClientConfig | Nil = nil
) )
# Playback context, separate because it can be different between clients # Playback context, separate because it can be different between clients
playback_ctx = { playback_ctx = {
@ -500,11 +500,7 @@ module YoutubeAPI
data["params"] = params data["params"] = params
end end
if CONFIG.invidious_companion.present? return self._post_json("/youtubei/v1/player", data, client_config)
return self._post_invidious_companion("/youtubei/v1/player", data)
else
return self._post_json("/youtubei/v1/player", data, client_config)
end
end end
#################################################################### ####################################################################
@ -561,7 +557,7 @@ module YoutubeAPI
def search( def search(
search_query : String, search_query : String,
params : String, params : String,
client_config : ClientConfig | Nil = nil, client_config : ClientConfig | Nil = nil
) )
# JSON Request data, required by the API # JSON Request data, required by the API
data = { data = {
@ -587,7 +583,7 @@ module YoutubeAPI
def get_transcript( def get_transcript(
params : String, params : String,
client_config : ClientConfig | Nil = nil, client_config : ClientConfig | Nil = nil
) : Hash(String, JSON::Any) ) : Hash(String, JSON::Any)
data = { data = {
"context" => self.make_context(client_config), "context" => self.make_context(client_config),
@ -609,7 +605,7 @@ module YoutubeAPI
def _post_json( def _post_json(
endpoint : String, endpoint : String,
data : Hash, data : Hash,
client_config : ClientConfig | Nil, client_config : ClientConfig | Nil
) : Hash(String, JSON::Any) ) : Hash(String, JSON::Any)
# Use the default client config if nil is passed # Use the default client config if nil is passed
client_config ||= DEFAULT_CLIENT_CONFIG client_config ||= DEFAULT_CLIENT_CONFIG
@ -670,49 +666,6 @@ module YoutubeAPI
return initial_data return initial_data
end end
####################################################################
# _post_invidious_companion(endpoint, data)
#
# Internal function that does the actual request to Invidious companion
# and handles errors.
#
# The requested data is an endpoint (URL without the domain part)
# and the data as a Hash object.
#
def _post_invidious_companion(
endpoint : String,
data : Hash,
) : Hash(String, JSON::Any)
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
"Authorization" => "Bearer #{CONFIG.invidious_companion_key}",
}
# Logging
LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("Invidious companion: POST data: #{data}")
# Send the POST request
begin
response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json)
body = response.body
if (response.status_code != 200)
raise Exception.new(
"Error while communicating with Invidious companion: \
status code: #{response.status_code} and body: #{body.dump}"
)
end
rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end
# Convert result to Hash
initial_data = JSON.parse(body).as_h
return initial_data
end
#################################################################### ####################################################################
# _decompress(body_io, headers) # _decompress(body_io, headers)
# #