122 Commits

Author SHA1 Message Date
Omar Roth
50bab26a3a Add support for CONNECT proxy 2020-05-25 18:52:36 -05:00
Omar Roth
ceb252986e Update captcha job 2020-05-25 12:52:15 -05:00
Omar Roth
750ef296c6 Update captcha handler 2020-05-13 16:09:39 -05:00
Omar Roth
454ae8656a Cleanup request headers 2020-05-08 09:00:53 -05:00
Omar Roth
75450dcdbc Update signature param 2020-05-08 08:59:09 -05:00
tleydxdy
bd2c7e3bb9 Verify download, fix invidious file permission (#949)
* Fix docker
2020-04-30 20:35:34 -05:00
mendel5
9d23cf33fd Consistent IDs for info section (#1133) 2020-04-30 15:01:29 -05:00
Omar Roth
97eb01a28d Merge weblate 2020-04-20 16:46:00 -05:00
Lucas Araujo
9a2a636aed Update Portuguese (Brazil) translation 2020-04-20 16:12:20 -05:00
Oğuz Ersen
61c8256ef0 Update Turkish translation 2020-04-20 16:12:15 -05:00
Tolstovka
8e1791570e Update Ukrainian translation 2020-04-20 16:12:15 -05:00
Bruno Guerreiro
aa30d1f359 Add Portuguese (Portugal) translation 2020-04-20 16:12:12 -05:00
khalasa47
326f4bd681 Update Basque translation 2020-04-20 16:12:09 -05:00
Mihail Iosilevitch
7690c6c33d Update Russian translation 2020-04-20 16:12:06 -05:00
Allan Nordhøy
fece1077f2 Update Swedish translation 2020-04-20 16:12:05 -05:00
Allan Nordhøy
75fc7db50d Update Romanian translation 2020-04-20 16:12:05 -05:00
Sylke Vicious
96da04576e Update Italian translation 2020-04-20 16:12:05 -05:00
bongo bongo
001ec3663e Add Serbian (cyrillic) translation 2020-04-20 16:12:02 -05:00
Tamas Cservenak
21a00b77bd Add Hungarian translation (#1111) 2020-04-20 16:05:28 -05:00
Omar Roth
408f3852ec Hide playlist widget when user has no playlists 2020-04-15 16:30:02 -05:00
Omar Roth
61150c74d2 Move privacy type into playlists.sql 2020-04-14 18:09:48 -05:00
Omar Roth
7bb7003c9d Fix authorThumbnails in /api/v1/channels 2020-04-10 11:49:51 -05:00
Omar Roth
920463f2ff Fix playlist_ajax 2020-04-10 11:49:18 -05:00
Omar Roth
ca1185d0be Fix warnings in latest version of Crystal 2020-04-09 12:18:09 -05:00
Omar Roth
be655ee328 Bump dependencies 2020-04-09 11:14:21 -05:00
Omar Roth
02d4186b11 Fix player matching 2020-04-09 10:55:50 -05:00
Omar Roth
3f97bebd69 Support adding video to playlist from watch page 2020-04-09 10:55:32 -05:00
Omar Roth
2e378da922 Add support for Swedish locale 2020-04-04 15:57:29 -05:00
Omar Roth
b37f51bd7f Fix /c/ redirect 2020-04-04 15:31:24 -05:00
Olle Jonsson
eb8b0f72cc Add Swedish translation (#1078)
Co-authored-by: Daniel Lublin <daniel@lublin.se>
2020-04-02 16:26:54 -05:00
Olle Jonsson
d8fe9a4d29 nb-NO: Translate "subscription" correctly (#1089)
Co-authored-by: Oskar Gewalli <gewalli@gmail.com>
2020-04-02 16:16:27 -05:00
Omar Roth
c97cdf551e Refactor extract_plid 2020-03-30 14:27:07 -05:00
Omar Roth
80fc60b5e2 Add spec for extract_plid 2020-03-30 14:23:51 -05:00
Omar Roth
3b2e142542 Fix JSON serialization 2020-03-29 18:04:44 -04:00
Omar Roth
0e58d99f4e Fix player mouseover events 2020-03-27 09:47:46 -05:00
Omar Roth
92798abb5d Add manifest-src to CSP 2020-03-19 13:41:08 -05:00
Omar Roth
bd7950b757 Add toggle_parent to dynamic handlers 2020-03-15 18:52:49 -04:00
Omar Roth
59a15ceef6 Remove VarInt class 2020-03-15 17:47:16 -04:00
Omar Roth
4011a113cc Strip invalid characters from referer URLs 2020-03-15 17:47:16 -04:00
leonklingele
70cbe91776 Migrate to a good Content Security Policy (#1023)
So attacks such as XSS (see [0]) will no longer be of an issue.

[0]: https://github.com/omarroth/invidious/issues/1022
2020-03-15 16:46:08 -05:00
Omar Roth
f92027c44b Escape 'sort_by' 2020-03-10 11:25:32 -04:00
Omar Roth
1443335315 Switch textcaptcha to HTTPS 2020-03-10 11:12:11 -04:00
Omar Roth
6ff2229a09 Bump dependencies 2020-03-06 13:59:42 -05:00
Omar Roth
bb72672dd9 Replace static asset requests with QUIC 2020-03-06 13:53:35 -05:00
Omar Roth
d96dee3aa6 Add debug info to videoplayback 2020-03-06 13:50:00 -05:00
Omar Roth
bd0aaa343b Prevent storyboards from hanging 2020-03-05 13:49:06 -05:00
Omar Roth
3126e1ac94 docker: allow to configure Invidious by env var (#1030)
Invidious gained support to read its configuration from an env var
instead of config file in e3c10d779d.

Unfortunately, Docker doesn't allow newline characters in env var
values (see [0]) which means we can only provide a proper YAML config
by using the inlined configuration in docker-compose.yml which,
unfortunately, is tracked by Git. Once support for multiline env var
values has been added to Docker, we should migrate and read the config
from a .env file instead (which is not tracked by Git).

[0]: https://github.com/docker/compose/issues/3527
2020-03-04 12:33:13 -06:00
Omar Roth
a117d87f33 Skip validation checks for videoplayback, ggpht 2020-03-04 13:06:17 -05:00
Omar Roth
9dc4f8a1aa Escape item titles in search page 2020-03-04 13:03:14 -05:00
leonklingele
0d536d11e3 Verify token signature in constant time, Run cheap checks first in token validation process (#1032)
* Verify token signature in constant time

To prevent timing side channel attacks

* Run cheap checks first in token validation process

Expensive checks such as the nonce lookup on the database or the
signature check can be run after cheap/fast checks.
2020-03-02 10:04:36 -06:00
B͈̤̖̪̪̱ͅl̯̯̮̼͎̬͚̳̩̖̲u̜̼͉͈̠b͙̬̘̙̱̗̲͙b͍̞̬̬͓̼l̰̪͖̯̼̟̟͈̖͕̜̱̜ͅl̻̗͔̝̭̰͚͇̯̥
72a4962fd0 add lapisTube (#1027) 2020-03-02 09:35:28 -06:00
Kyle Copperfield
a3045a3953 Use a MediaQueryListener to toggle on demand. Tested on OSX. (#925)
Closes #867.
2020-03-02 09:33:47 -06:00
Tommy Miland
c620a22017 Add logfile to logrotate (#892) 2020-03-02 09:19:07 -06:00
Omar Roth
856ec03cc7 Revert "Add HOST_AUTH_METHOD=trust to docker compose (see docker-library/postgres#681)"
This reverts commit ef70668a77.
2020-03-01 11:07:37 -05:00
leonklingele
c80c5631f0 docker: do not require password for PostgreSQL superuser, docker,kubernetes: create "privacy" type before using it, travis: do not run "docker-compose up" in detached mode (#1042)
* docker: do not require password for PostgreSQL superuser

A password is now required by the postgres Docker image which makes
initial setup (and our CI build) fail with the following error:

    postgres_1   | Error: Database is uninitialized and superuser password is not specified.
    postgres_1   |        You must specify POSTGRES_PASSWORD for the superuser. Use
    postgres_1   |        "-e POSTGRES_PASSWORD=password" to set it in "docker run".
    postgres_1   |
    postgres_1   |        You may also use POSTGRES_HOST_AUTH_METHOD=trust to allow all connections
    postgres_1   |        without a password. This is *not* recommended. See PostgreSQL
    postgres_1   |        documentation about "trust":
    postgres_1   |        https://www.postgresql.org/docs/current/auth-trust.html

See https://github.com/docker-library/postgres/issues/681.

* docker,kubernetes: create PostgreSQL "privacy" type before using it

Fixes the following error when setting up the database:

    postgres_1   | 2020-02-21 01:01:22.371 UTC [172] ERROR:  type "privacy" does not exist at character 200
    postgres_1   | 2020-02-21 01:01:22.371 UTC [172] STATEMENT:  CREATE TABLE public.playlists
    postgres_1   | 	(
    postgres_1   | 	    title text,
    postgres_1   | 	    id text primary key,
    postgres_1   | 	    author text,
    postgres_1   | 	    description text,
    postgres_1   | 	    video_count integer,
    postgres_1   | 	    created timestamptz,
    postgres_1   | 	    updated timestamptz,
    postgres_1   | 	    privacy privacy,
    postgres_1   | 	    index int8[]
    postgres_1   | 	);
    postgres_1   | ERROR:  type "privacy" does not exist
    postgres_1   | LINE 10:     privacy privacy,

* travis: do not run "docker-compose up" in detached mode

Rather, allow database to finish its setup procedure and grant
Invidious time to launch.
2020-03-01 10:06:45 -06:00
Omar Roth
ef70668a77 Add HOST_AUTH_METHOD=trust to docker compose (see docker-library/postgres#681) 2020-03-01 10:51:17 -05:00
Karol Kosek
ebd4691462 Update Polish translation 2020-03-01 16:31:32 +01:00
Tymofij Lytvynenko
28554235be Update Ukrainian translation 2020-03-01 16:31:32 +01:00
Deleted User
efbbb6fd20 Update German translation 2020-03-01 16:31:32 +01:00
Omar Roth
9de57021a3 Update postgres setup 2020-03-01 10:30:55 -05:00
Omar Roth
e21f770485 Fix status check for channel page 2020-02-28 15:57:45 -05:00
Omar Roth
697c00dccf Sanitize PLID 2020-02-28 14:10:01 -05:00
Omar Roth
1caf6a3298 Fix deadlock when updating notifications 2020-02-28 13:13:48 -05:00
Omar Roth
02fd02d482 Remove DB array concatenation 2020-02-28 12:14:29 -05:00
Pedro Lucas Porcellis
239fb0db94 Remove duplicated Github logo on footer (#986)
* Remove duplicated Github logo on footer
2020-02-20 18:50:54 -05:00
Omar Roth
fe1d73c3e5 Merge pull request #1015 from leonklingele/add-kubernetes
Add support to run on Kubernetes, add Helm chart
2020-02-20 18:45:25 -05:00
Omar Roth
43da06a354 Remove temp fix for crystal/crystal-lang#7383 2020-02-20 18:30:46 -05:00
Omar Roth
fea6b67067 Remove 'type' attribute from community embed 2020-02-20 18:30:46 -05:00
Omar Roth
f065ae54d5 Merge pull request #1031 from leonklingele/crystal-0.33.0-format
Update code formatting for Crystal 0.33.0
2020-02-20 18:10:56 -05:00
Omar Roth
3cf417766d Merge pull request #1033 from leanderseidlitz/master
readme.md: fix missing playlist relation in postgresql
2020-02-20 18:10:26 -05:00
Leander Seidlitz
0fb41b10e9 readme.md: fix missing playlist relation in postgresql 2020-02-15 20:58:52 +01:00
Leon Klingele
bc9dc3bf1e Update code formatting for Crystal 0.33.0
Crystal 0.33.0 introduced some changes to to the code formatter.
Run "crystal tool format" so CI doesn't fail anymore.
2020-02-15 19:52:28 +01:00
Leon Klingele
3cde5e28a8 Add support to run on Kubernetes, add Helm chart
See relevant README.md for more details.
2020-02-07 13:46:12 +01:00
Omar Roth
cb8e7181c4 Merge pull request #1016 from leonklingele/config-env
Add support to read config from environment variable
2020-02-06 20:13:34 -05:00
Omar Roth
9a3becdecc Merge pull request #1011 from jorgesumle/master
Remove invalid and useless HTML from embed player
2020-02-06 20:12:17 -05:00
Leon Klingele
e3c10d779d Add support to read config from environment variable
Try to read app config from the "INVIDIOUS_CONFIG" environment variable.
If the variable is undefined, read config from config.yml file as before.

Required by https://github.com/omarroth/invidious/pull/1015 et al.
2020-02-04 15:53:46 +01:00
Jorge Maldonado Ventura
dd9f1024f4 Remove invalid HTML from embed player 2020-02-01 19:25:03 +01:00
Omar Roth
9841f74adc Add handling for comments with no content 2020-02-01 12:14:37 -05:00
Omar Roth
b56e493d92 Remove frameborder from community embeds 2020-02-01 11:23:12 -05:00
Omar Roth
a2c5211b20 Check /browse_ajax for channel blocks 2020-02-01 11:23:12 -05:00
Omar Roth
b7a7abed48 Merge pull request #1004 from outloudvi/zhcn-l10n
Update zh-CN translation
2020-02-01 11:13:03 -05:00
Omar Roth
72bfdfd925 Merge pull request #975 from jorgesumle/embed
Change embed code
2020-02-01 11:11:12 -05:00
Outvi V
b80d34612a Update zh-CN translation 2020-01-27 13:01:53 +08:00
Omar Roth
648cc0f006 Refactor signature extraction 2020-01-24 17:02:28 -05:00
chr56
830692dd60 Update Chinese (Simplified) translation 2020-01-17 22:50:16 -05:00
Adam Zieliński
95a6759381 Update Polish translation 2020-01-17 22:50:16 -05:00
Jorge Maldonado Ventura
960b37b1c2 Update Spanish translation 2020-01-17 22:50:16 -05:00
Jorge Maldonado Ventura
b1d17dea4f Update Esperanto translation 2020-01-17 22:50:16 -05:00
Jeff Huang
6b06471953 Update Chinese (Traditional) translation 2020-01-17 22:50:16 -05:00
dimqua
4ca957d3eb Update Russian translation 2020-01-17 22:50:16 -05:00
Oguz Ersen
eb9b63477c Update Turkish translation 2020-01-17 22:50:16 -05:00
Allan Nordhøy
80c01b055c Update Norwegian Bokmål translation 2020-01-17 22:50:16 -05:00
Omar Roth
50aec67069 Merge pull request #984 from rreuvekamp/202001_improve-dutch-locale
Improve Dutch locale
2020-01-17 22:26:46 -05:00
Omar Roth
7baced75e5 Fix channel redirect 2020-01-14 08:21:17 -05:00
Remi Reuvekamp
99743a94fb Improve Dutch locale 2020-01-12 19:00:10 +01:00
Omar Roth
9bdfd6025b Add base-devel to Arch dependencies 2020-01-08 21:06:22 -05:00
Omar Roth
91400d2ce0 Merge pull request #959 from frajibe/wip/frajibe/frenchTs
Small fixes for the french translation
2020-01-08 20:29:26 -05:00
Omar Roth
7b88d0efe3 Minor refactor 2020-01-08 20:27:21 -05:00
Omar Roth
4aada65dae Fix channel playlists for genre channels 2020-01-08 20:26:47 -05:00
Omar Roth
0560d2cfb7 Bump video.js 2020-01-08 20:19:47 -05:00
Jorge Maldonado Ventura
58c1a68ad9 Change embed code 2020-01-04 15:27:45 +01:00
Omar Roth
588fc6df85 Bump dependencies 2019-12-14 16:10:46 -05:00
frajibe
2c9e4ded40 Fix the french translation 2019-12-14 18:20:26 +01:00
Omar Roth
88a538e71b Minor refactor for channel playlists 2019-12-05 15:47:35 -05:00
Omar Roth
513363504f Add better error message for fetch_channel 2019-12-05 15:46:21 -05:00
Omar Roth
0e844edacb Add support for pt-BR 2019-12-05 15:26:35 -05:00
Everton
5751bb2481 Add Brazilian Portuguese locale (#915)
* adding Brazilian Portuguese locale
2019-12-05 15:24:53 -05:00
Omar Roth
28669d940a Remove --release from dockerfile 2019-12-05 14:49:44 -05:00
Omar Roth
3d87bdb6b4 Merge pull request #938 from tleydxdy/patch-2
Proper fix for docker build
2019-12-05 14:49:14 -05:00
Omar Roth
1499ce43bf Add support for Romanian locale 2019-12-03 19:41:58 -05:00
Omar Roth
4d22b43d65 Merge pull request #942 from vcvlad/master
Invidious translated into Romanian
2019-12-03 19:41:26 -05:00
Omar Roth
823603650f Add support for /sorry/index CAPTCHA 2019-12-03 19:14:11 -05:00
Omar Roth
062867a38d Strip domain from caption URLs 2019-12-01 17:52:39 -05:00
Vlad Crangă
f3e0c5d653 Update ro.json
Invidious translated from English into Romanian.
2019-11-28 17:16:46 +00:00
Vlad Crangă
fc7f48b7db Create ro.json 2019-11-28 15:09:41 +00:00
Omar Roth
04d56420d1 Run 'crystal tool format' 2019-11-28 08:20:44 -06:00
Omar Roth
a017574f74 Add support for force_resolve to QUIC client 2019-11-28 08:19:28 -06:00
tleydxdy
ae24360c02 Proper fix for docker build
return to static linking
2019-11-26 18:20:23 -05:00
Omar Roth
3fea1976c8 Update dependencies 2019-11-24 15:26:19 -05:00
Omar Roth
cf97dd9fcd Bump dependencies 2019-11-24 14:00:53 -05:00
Omar Roth
0e3a48ff76 Update QUICPool 2019-11-24 13:41:47 -05:00
Omar Roth
276bf09238 Skip preferences for assets 2019-11-20 12:04:53 -05:00
92 changed files with 3766 additions and 1136 deletions

View File

@@ -28,7 +28,4 @@ jobs:
- docker-compose build - docker-compose build
script: script:
- docker-compose up -d - docker-compose up -d
- sleep 15 # Wait for cluster to become ready, TODO: do not sleep - while curl -Isf http://localhost:3000; do sleep 1; done
- HEADERS="$(curl -I -s http://localhost:3000/)"
- STATUS="$(echo $HEADERS | head -n1)"
- if [[ "$STATUS" != *"200 OK"* ]]; then echo "$HEADERS"; exit 1; fi

View File

@@ -79,7 +79,7 @@ $ docker-compose build
```bash ```bash
# Arch Linux # Arch Linux
$ sudo pacman -S shards crystal librsvg postgresql $ sudo pacman -S base-devel shards crystal librsvg postgresql
# Ubuntu or Debian # Ubuntu or Debian
# First you have to add the repository to your APT configuration. For easy setup just run in your command line: # First you have to add the repository to your APT configuration. For easy setup just run in your command line:
@@ -115,6 +115,8 @@ $ psql invidious kemal < /home/invidious/invidious/config/sql/users.sql
$ psql invidious kemal < /home/invidious/invidious/config/sql/session_ids.sql $ psql invidious kemal < /home/invidious/invidious/config/sql/session_ids.sql
$ psql invidious kemal < /home/invidious/invidious/config/sql/nonces.sql $ psql invidious kemal < /home/invidious/invidious/config/sql/nonces.sql
$ psql invidious kemal < /home/invidious/invidious/config/sql/annotations.sql $ psql invidious kemal < /home/invidious/invidious/config/sql/annotations.sql
$ psql invidious kemal < /home/invidious/invidious/config/sql/playlists.sql
$ psql invidious kemal < /home/invidious/invidious/config/sql/playlist_videos.sql
$ exit $ exit
``` ```
@@ -138,6 +140,20 @@ $ sudo systemctl enable invidious.service
$ sudo systemctl start invidious.service $ sudo systemctl start invidious.service
``` ```
#### Logrotate
```bash
$ sudo echo "/home/invidious/invidious/invidious.log {
rotate 4
weekly
notifempty
missingok
compress
minsize 1048576
}" | tee /etc/logrotate.d/invidious.logrotate
$ sudo chmod 0644 /etc/logrotate.d/invidious.logrotate
```
### OSX: ### OSX:
```bash ```bash
@@ -158,6 +174,9 @@ $ psql invidious kemal < config/sql/users.sql
$ psql invidious kemal < config/sql/session_ids.sql $ psql invidious kemal < config/sql/session_ids.sql
$ psql invidious kemal < config/sql/nonces.sql $ psql invidious kemal < config/sql/nonces.sql
$ psql invidious kemal < config/sql/annotations.sql $ psql invidious kemal < config/sql/annotations.sql
$ psql invidious kemal < config/sql/privacy.sql
$ psql invidious kemal < config/sql/playlists.sql
$ psql invidious kemal < config/sql/playlist_videos.sql
# Setup Invidious # Setup Invidious
$ shards update && shards install $ shards update && shards install
@@ -209,6 +228,7 @@ $ ./sentry
- [CloudTube](https://cadence.moe/cloudtube/subscriptions): A JS-rich alternate YouTube player - [CloudTube](https://cadence.moe/cloudtube/subscriptions): A JS-rich alternate YouTube player
- [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. - [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists.
- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube. - [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube.
- [LapisTube](https://github.com/blubbll/lapis-tube): A fancy and advanced (experimental) YouTube frontend. Combined streams & custom YT features.
## Contributing ## Contributing

10
assets/css/embed.css Normal file
View File

@@ -0,0 +1,10 @@
#player {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
z-index: -100;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
var community_data = JSON.parse(document.getElementById('community_data').innerHTML);
String.prototype.supplant = function (o) { String.prototype.supplant = function (o) {
return this.replace(/{([^{}]*)}/g, function (a, b) { return this.replace(/{([^{}]*)}/g, function (a, b) {
var r = o[b]; var r = o[b];

View File

@@ -1,3 +1,5 @@
var video_data = JSON.parse(document.getElementById('video_data').innerHTML);
function get_playlist(plid, retries) { function get_playlist(plid, retries) {
if (retries == undefined) retries = 5; if (retries == undefined) retries = 5;

3
assets/js/global.js Normal file
View File

@@ -0,0 +1,3 @@
// Disable Web Workers. Fixes Video.js CSP violation (created by `new Worker(objURL)`):
// Refused to create a worker from 'blob:http://host/id' because it violates the following Content Security Policy directive: "worker-src 'self'".
window.Worker = undefined;

144
assets/js/handlers.js Normal file
View File

@@ -0,0 +1,144 @@
'use strict';
(function () {
var n2a = function (n) { return Array.prototype.slice.call(n); };
var video_player = document.getElementById('player_html5_api');
if (video_player) {
video_player.onmouseenter = function () { video_player['data-title'] = video_player['title']; video_player['title'] = ''; };
video_player.onmouseleave = function () { video_player['title'] = video_player['data-title']; video_player['data-title'] = ''; };
video_player.oncontextmenu = function () { video_player['title'] = video_player['data-title']; };
}
// For dynamically inserted elements
document.addEventListener('click', function (e) {
if (!e || !e.target) { return; }
e = e.target;
var handler_name = e.getAttribute('data-onclick');
switch (handler_name) {
case 'jump_to_time':
var time = e.getAttribute('data-jump-time');
player.currentTime(time);
break;
case 'get_youtube_replies':
var load_more = e.getAttribute('data-load-more') !== null;
get_youtube_replies(e, load_more);
break;
case 'toggle_parent':
toggle_parent(e);
break;
default:
break;
}
});
n2a(document.querySelectorAll('[data-mouse="switch_classes"]')).forEach(function (e) {
var classes = e.getAttribute('data-switch-classes').split(',');
var ec = classes[0];
var lc = classes[1];
var onoff = function (on, off) {
var cs = e.getAttribute('class');
cs = cs.split(off).join(on);
e.setAttribute('class', cs);
};
e.onmouseenter = function () { onoff(ec, lc); };
e.onmouseleave = function () { onoff(lc, ec); };
});
n2a(document.querySelectorAll('[data-onsubmit="return_false"]')).forEach(function (e) {
e.onsubmit = function () { return false; };
});
n2a(document.querySelectorAll('[data-onclick="mark_watched"]')).forEach(function (e) {
e.onclick = function () { mark_watched(e); };
});
n2a(document.querySelectorAll('[data-onclick="mark_unwatched"]')).forEach(function (e) {
e.onclick = function () { mark_unwatched(e); };
});
n2a(document.querySelectorAll('[data-onclick="add_playlist_video"]')).forEach(function (e) {
e.onclick = function () { add_playlist_video(e); };
});
n2a(document.querySelectorAll('[data-onclick="add_playlist_item"]')).forEach(function (e) {
e.onclick = function () { add_playlist_item(e); };
});
n2a(document.querySelectorAll('[data-onclick="remove_playlist_item"]')).forEach(function (e) {
e.onclick = function () { remove_playlist_item(e); };
});
n2a(document.querySelectorAll('[data-onclick="revoke_token"]')).forEach(function (e) {
e.onclick = function () { revoke_token(e); };
});
n2a(document.querySelectorAll('[data-onclick="remove_subscription"]')).forEach(function (e) {
e.onclick = function () { remove_subscription(e); };
});
n2a(document.querySelectorAll('[data-onclick="notification_requestPermission"]')).forEach(function (e) {
e.onclick = function () { Notification.requestPermission(); };
});
n2a(document.querySelectorAll('[data-onrange="update_volume_value"]')).forEach(function (e) {
var cb = function () { update_volume_value(e); }
e.oninput = cb;
e.onchange = cb;
});
function update_volume_value(element) {
document.getElementById('volume-value').innerText = element.value;
}
function revoke_token(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.innerText = count.innerText - 1;
var referer = window.encodeURIComponent(document.location.href);
var url = '/token_ajax?action_revoke_token=1&redirect=false' +
'&referer=' + referer +
'&session=' + target.getAttribute('data-session');
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status != 200) {
count.innerText = parseInt(count.innerText) + 1;
row.style.display = '';
}
}
}
var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value;
xhr.send('csrf_token=' + csrf_token);
}
function remove_subscription(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.innerText = count.innerText - 1;
var referer = window.encodeURIComponent(document.location.href);
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&referer=' + referer +
'&c=' + target.getAttribute('data-ucid');
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status != 200) {
count.innerText = parseInt(count.innerText) + 1;
row.style.display = '';
}
}
}
var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value;
xhr.send('csrf_token=' + csrf_token);
}
})();

View File

@@ -1,3 +1,5 @@
var notification_data = JSON.parse(document.getElementById('notification_data').innerHTML);
var notifications, delivered; var notifications, delivered;
function get_subscriptions(callback, retries) { function get_subscriptions(callback, retries) {

View File

@@ -1,3 +1,6 @@
var player_data = JSON.parse(document.getElementById('player_data').innerHTML);
var video_data = JSON.parse(document.getElementById('video_data').innerHTML);
var options = { var options = {
preload: 'auto', preload: 'auto',
liveui: true, liveui: true,
@@ -35,7 +38,7 @@ var shareOptions = {
title: player_data.title, title: player_data.title,
description: player_data.description, description: player_data.description,
image: player_data.thumbnail, image: player_data.thumbnail,
embedCode: "<iframe id='ivplayer' type='text/html' width='640' height='360' src='" + embed_url + "' frameborder='0'></iframe>" embedCode: "<iframe id='ivplayer' width='640' height='360' src='" + embed_url + "' style='border:none;'></iframe>"
} }
var player = videojs('player', options); var player = videojs('player', options);

View File

@@ -1,3 +1,29 @@
var playlist_data = JSON.parse(document.getElementById('playlist_data').innerHTML);
function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1];
var option = select.children[select.selectedIndex];
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid');
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
option.innerText = '✓' + option.innerText;
}
}
}
xhr.send('csrf_token=' + playlist_data.csrf_token);
}
function add_playlist_item(target) { 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';

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
var subscribe_data = JSON.parse(document.getElementById('subscribe_data').innerHTML);
var subscribe_button = document.getElementById('subscribe'); var subscribe_button = document.getElementById('subscribe');
subscribe_button.parentNode['action'] = 'javascript:void(0)'; subscribe_button.parentNode['action'] = 'javascript:void(0)';

View File

@@ -28,6 +28,27 @@ window.addEventListener('load', function () {
update_mode(window.localStorage.dark_mode); update_mode(window.localStorage.dark_mode);
}); });
var darkScheme = window.matchMedia('(prefers-color-scheme: dark)');
var lightScheme = window.matchMedia('(prefers-color-scheme: light)');
darkScheme.addListener(scheme_switch);
lightScheme.addListener(scheme_switch);
function scheme_switch (e) {
// ignore this method if we have a preference set
if (localStorage.getItem('dark_mode')) {
return;
}
if (e.matches) {
if (e.media.includes("dark")) {
set_mode(true);
} else if (e.media.includes("light")) {
set_mode(false);
}
}
}
function set_mode (bool) { function set_mode (bool) {
document.getElementById('dark_theme').media = !bool ? 'none' : ''; document.getElementById('dark_theme').media = !bool ? 'none' : '';
document.getElementById('light_theme').media = bool ? 'none' : ''; document.getElementById('light_theme').media = bool ? 'none' : '';

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
var video_data = JSON.parse(document.getElementById('video_data').innerHTML);
String.prototype.supplant = function (o) { String.prototype.supplant = function (o) {
return this.replace(/{([^{}]*)}/g, function (a, b) { return this.replace(/{([^{}]*)}/g, function (a, b) {
var r = o[b]; var r = o[b];

View File

@@ -1,3 +1,5 @@
var watched_data = JSON.parse(document.getElementById('watched_data').innerHTML);
function mark_watched(target) { 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';

View File

@@ -1,3 +1,14 @@
-- Type: public.privacy
-- DROP TYPE public.privacy;
CREATE TYPE public.privacy AS ENUM
(
'Public',
'Unlisted',
'Private'
);
-- Table: public.playlists -- Table: public.playlists
-- DROP TABLE public.playlists; -- DROP TABLE public.playlists;

View File

@@ -1,10 +0,0 @@
-- Type: public.privacy
-- DROP TYPE public.privacy;
CREATE TYPE public.privacy AS ENUM
(
'Public',
'Unlisted',
'Private'
);

View File

@@ -16,6 +16,20 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
environment:
# Adapted from ./config/config.yml
INVIDIOUS_CONFIG: |
channel_threads: 1
feed_threads: 1
db:
user: kemal
password: kemal
host: postgres
port: 5432
dbname: invidious
full_refresh: false
https_only: false
domain:
depends_on: depends_on:
- postgres - postgres

View File

@@ -1,34 +1,38 @@
FROM alpine:edge FROM alpine:edge AS builder
RUN apk add --no-cache crystal shards libc-dev \ RUN apk add --no-cache curl crystal shards libc-dev \
yaml-dev libxml2-dev sqlite-dev zlib-dev curl && \ yaml-dev libxml2-dev sqlite-dev zlib-dev openssl-dev \
curl -Lo /etc/apk/keys/omarroth.rsa.pub https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/omarroth.rsa.pub && \ yaml-static sqlite-static zlib-static openssl-libs-static
WORKDIR /invidious
RUN curl -Lo /etc/apk/keys/omarroth.rsa.pub https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/omarroth.rsa.pub && \
curl -Lo boringssl-dev.apk https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/boringssl-dev-1.1.0-r0.apk && \ curl -Lo boringssl-dev.apk https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/boringssl-dev-1.1.0-r0.apk && \
curl -Lo lsquic.apk https://github.com/omarroth/lsquic-alpine/releases/download/2.6.3-r0/lsquic-2.6.3-r0.apk && \ curl -Lo lsquic.apk https://github.com/omarroth/lsquic-alpine/releases/download/2.6.3-r0/lsquic-2.6.3-r0.apk && \
apk update && \ apk verify --no-cache boringssl-dev.apk lsquic.apk && \
apk add boringssl-dev.apk lsquic.apk && \ tar -xf boringssl-dev.apk usr/lib/libcrypto.a usr/lib/libssl.a && \
rm -rf /var/cache/apk/* boringssl-dev.apk lsquic.apk tar -xf lsquic.apk usr/lib/liblsquic.a && \
WORKDIR /invidious rm /etc/apk/keys/omarroth.rsa.pub boringssl-dev.apk lsquic.apk
COPY ./shard.yml ./shard.yml COPY ./shard.yml ./shard.yml
RUN shards update && shards install RUN shards update && shards install && \
RUN cp /usr/lib/libcrypto.a ./lib/lsquic/src/lsquic/ext/libcrypto.a && \ mv ./usr/lib/* ./lib/lsquic/src/lsquic/ext && \
cp /usr/lib/libssl.a ./lib/lsquic/src/lsquic/ext/libssl.a && \ rm -r ./usr /root/.cache
cp /usr/lib/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a
COPY ./src/ ./src/ COPY ./src/ ./src/
# TODO: .git folder is required for building this is destructive. # TODO: .git folder is required for building this is destructive.
# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
COPY ./.git/ ./.git/ COPY ./.git/ ./.git/
RUN crystal build --release --warnings all --error-on-warnings \ RUN crystal build ./src/invidious.cr \
# TODO: Remove next line, see https://github.com/crystal-lang/crystal/issues/7946 --static --warnings all --error-on-warnings \
-Dmusl \ --link-flags "-lxml2 -llzma"
./src/invidious.cr
FROM alpine:latest
RUN apk add --no-cache librsvg ttf-opensans RUN apk add --no-cache librsvg ttf-opensans
WORKDIR /invidious
RUN addgroup -g 1000 -S invidious && \ RUN addgroup -g 1000 -S invidious && \
adduser -u 1000 -S invidious -G invidious adduser -u 1000 -S invidious -G invidious
COPY ./assets/ ./assets/ COPY ./assets/ ./assets/
COPY ./config/config.yml ./config/config.yml COPY --chown=invidious ./config/config.yml ./config/config.yml
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml
COPY ./config/sql/ ./config/sql/ COPY ./config/sql/ ./config/sql/
COPY ./locales/ ./locales/ COPY ./locales/ ./locales/
RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml COPY --from=builder /invidious/invidious .
USER invidious USER invidious
CMD [ "/invidious/invidious" ] CMD [ "/invidious/invidious" ]

View File

@@ -1,6 +1,9 @@
FROM postgres:10 FROM postgres:10
ENV POSTGRES_USER postgres ENV POSTGRES_USER postgres
# Do not require a PostgreSQL superuser password.
# See https://github.com/docker-library/postgres/issues/681.
ENV POSTGRES_HOST_AUTH_METHOD trust
ADD ./config/sql /config/sql ADD ./config/sql /config/sql
ADD ./docker/entrypoint.postgres.sh /entrypoint.sh ADD ./docker/entrypoint.postgres.sh /entrypoint.sh

View File

@@ -21,7 +21,6 @@ if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
su postgres -c 'psql invidious kemal < config/sql/annotations.sql' su postgres -c 'psql invidious kemal < config/sql/annotations.sql'
su postgres -c 'psql invidious kemal < config/sql/playlists.sql' su postgres -c 'psql invidious kemal < config/sql/playlists.sql'
su postgres -c 'psql invidious kemal < config/sql/playlist_videos.sql' su postgres -c 'psql invidious kemal < config/sql/playlist_videos.sql'
su postgres -c 'psql invidious kemal < config/sql/privacy.sql'
touch /var/lib/postgresql/data/setupFinished touch /var/lib/postgresql/data/setupFinished
echo "### invidious database setup finished" echo "### invidious database setup finished"
exit exit

1
kubernetes/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/charts/*.tgz

6
kubernetes/Chart.lock Normal file
View File

@@ -0,0 +1,6 @@
dependencies:
- name: postgresql
repository: https://kubernetes-charts.storage.googleapis.com/
version: 8.3.0
digest: sha256:1feec3c396cbf27573dc201831ccd3376a4a6b58b2e7618ce30a89b8f5d707fd
generated: "2020-02-07T13:39:38.624846+01:00"

22
kubernetes/Chart.yaml Normal file
View File

@@ -0,0 +1,22 @@
apiVersion: v2
name: invidious
description: Invidious is an alternative front-end to YouTube
version: 1.0.0
appVersion: 0.20.1
keywords:
- youtube
- proxy
- video
- privacy
home: https://invidio.us/
icon: https://raw.githubusercontent.com/omarroth/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png
sources:
- https://github.com/omarroth/invidious
maintainers:
- name: Leon Klingele
email: mail@leonklingele.de
dependencies:
- name: postgresql
version: ~8.3.0
repository: "https://kubernetes-charts.storage.googleapis.com/"
engine: gotpl

41
kubernetes/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Invidious Helm chart
Easily deploy Invidious to Kubernetes.
## Installing Helm chart
```sh
# Build Helm dependencies
$ helm dep build
# Add PostgreSQL init scripts
$ kubectl create configmap invidious-postgresql-init \
--from-file=../config/sql/channels.sql \
--from-file=../config/sql/videos.sql \
--from-file=../config/sql/channel_videos.sql \
--from-file=../config/sql/users.sql \
--from-file=../config/sql/session_ids.sql \
--from-file=../config/sql/nonces.sql \
--from-file=../config/sql/annotations.sql \
--from-file=../config/sql/playlists.sql \
--from-file=../config/sql/playlist_videos.sql
# Install Helm app to your Kubernetes cluster
$ helm install invidious ./
```
## Upgrading
```sh
# Upgrading is easy, too!
$ helm upgrade invidious ./
```
## Uninstall
```sh
# Get rid of everything (except database)
$ helm delete invidious
# To also delete the database, remove all invidious-postgresql PVCs
```

View File

@@ -0,0 +1,16 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "invidious.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "invidious.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ template "invidious.fullname" . }}
labels:
app: {{ template "invidious.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: {{ .Release.Name }}
data:
INVIDIOUS_CONFIG: |
{{ toYaml .Values.config | indent 4 }}

View File

@@ -0,0 +1,53 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ template "invidious.fullname" . }}
labels:
app: {{ template "invidious.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ template "invidious.name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ template "invidious.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: {{ .Release.Name }}
spec:
securityContext:
runAsUser: {{ .Values.securityContext.runAsUser }}
runAsGroup: {{ .Values.securityContext.runAsGroup }}
fsGroup: {{ .Values.securityContext.fsGroup }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 3000
env:
- name: INVIDIOUS_CONFIG
valueFrom:
configMapKeyRef:
key: INVIDIOUS_CONFIG
name: {{ template "invidious.fullname" . }}
securityContext:
allowPrivilegeEscalation: {{ .Values.securityContext.allowPrivilegeEscalation }}
capabilities:
drop:
- ALL
resources:
{{ toYaml .Values.resources | indent 10 }}
readinessProbe:
httpGet:
port: 3000
path: /
livenessProbe:
httpGet:
port: 3000
path: /
restartPolicy: Always

View File

@@ -0,0 +1,18 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
name: {{ template "invidious.fullname" . }}
labels:
app: {{ template "invidious.name" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: {{ .Release.Name }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ template "invidious.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
targetCPUUtilizationPercentage: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ template "invidious.fullname" . }}
labels:
app: {{ template "invidious.name" . }}
chart: {{ .Chart.Name }}
release: {{ .Release.Name }}
spec:
ports:
- name: http
port: 3000
targetPort: 3000
selector:
app: {{ template "invidious.name" . }}
release: {{ .Release.Name }}

51
kubernetes/values.yaml Normal file
View File

@@ -0,0 +1,51 @@
name: invidious
image:
repository: omarroth/invidious
tag: latest
pullPolicy: Always
replicaCount: 1
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 16
targetCPUUtilizationPercentage: 50
resources: {}
#requests:
# cpu: 100m
# memory: 64Mi
#limits:
# cpu: 800m
# memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
# See https://github.com/helm/charts/tree/master/stable/postgresql
postgresql:
postgresqlUsername: kemal
postgresqlPassword: kemal
postgresqlDatabase: invidious
initdbUsername: kemal
initdbPassword: kemal
initdbScriptsConfigMap: invidious-postgresql-init
# Adapted from ../config/config.yml
config:
channel_threads: 1
feed_threads: 1
db:
user: kemal
password: kemal
host: invidious-postgresql
port: 5432
dbname: invidious
full_refresh: false
https_only: false
domain:

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` Abonnenten", "`x` subscribers": "`x` Abonnenten",
"`x` videos": "`x` Videos", "`x` videos": "`x` Videos",
"`x` playlists": "", "`x` playlists": "`x` Wiedergabelisten",
"LIVE": "LIVE", "LIVE": "LIVE",
"Shared `x` ago": "Vor `x` geteilt", "Shared `x` ago": "Vor `x` geteilt",
"Unsubscribe": "Abbestellen", "Unsubscribe": "Abbestellen",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "Datenschutzerklärung einsehen.", "View privacy policy.": "Datenschutzerklärung einsehen.",
"Trending": "Trending", "Trending": "Trending",
"Public": "", "Public": "Öffentlich",
"Unlisted": "Nicht aufgeführt", "Unlisted": "Nicht aufgeführt",
"Private": "", "Private": "Privat",
"View all playlists": "", "View all playlists": "Alle Wiedergabelisten anzeigen",
"Updated `x` ago": "", "Updated `x` ago": "Aktualisiert `x` vor",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Wiedergabeliste löschen `x`?",
"Delete playlist": "", "Delete playlist": "Wiedergabeliste löschen",
"Create playlist": "", "Create playlist": "Wiedergabeliste erstellen",
"Title": "", "Title": "Titel",
"Playlist privacy": "", "Playlist privacy": "Vertrauliche Wiedergabeliste",
"Editing playlist `x`": "", "Editing playlist `x`": "Wiedergabeliste bearbeiten `x`",
"Watch on YouTube": "Video auf YouTube ansehen", "Watch on YouTube": "Video auf YouTube ansehen",
"Hide annotations": "Anmerkungen ausblenden", "Hide annotations": "Anmerkungen ausblenden",
"Show annotations": "Anmerkungen anzeigen", "Show annotations": "Anmerkungen anzeigen",

View File

@@ -8,7 +8,7 @@
"": "`x` videos" "": "`x` videos"
}, },
"`x` playlists": { "`x` playlists": {
"(\\D|^)1(\\D|$)": "`x` playlist", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist",
"": "`x` playlists" "": "`x` playlists"
}, },
"LIVE": "LIVE", "LIVE": "LIVE",
@@ -177,7 +177,7 @@
"View YouTube comments": "View YouTube comments", "View YouTube comments": "View YouTube comments",
"View more comments on Reddit": "View more comments on Reddit", "View more comments on Reddit": "View more comments on Reddit",
"View `x` comments": { "View `x` comments": {
"(\\D|^)1(\\D|$)": "View `x` comment", "([^.,0-9]|^)1([^.,0-9]|$)": "View `x` comment",
"": "View `x` comments" "": "View `x` comments"
}, },
"View Reddit comments": "View Reddit comments", "View Reddit comments": "View Reddit comments",

View File

@@ -1,13 +1,13 @@
{ {
"`x` subscribers": "`x` abonantoj", "`x` subscribers": "`x` abonantoj",
"`x` videos": "`x` videoj", "`x` videos": "`x` filmetoj",
"`x` playlists": "", "`x` playlists": "`x` ludlistoj",
"LIVE": "NUNA", "LIVE": "NUNA",
"Shared `x` ago": "Konigita antaŭ `x`", "Shared `x` ago": "Konigita antaŭ `x`",
"Unsubscribe": "Malaboni", "Unsubscribe": "Malaboni",
"Subscribe": "Aboni", "Subscribe": "Aboni",
"View channel on YouTube": "Vidi kanalon en YouTube", "View channel on YouTube": "Vidi kanalon en JuTubo",
"View playlist on YouTube": "Vidi ludliston en YouTube", "View playlist on YouTube": "Vidi ludliston en JuTubo",
"newest": "pli novaj", "newest": "pli novaj",
"oldest": "pli malnovaj", "oldest": "pli malnovaj",
"popular": "popularaj", "popular": "popularaj",
@@ -25,7 +25,7 @@
"Import and Export Data": "Importi kaj Eksporti Datumojn", "Import and Export Data": "Importi kaj Eksporti Datumojn",
"Import": "Importi", "Import": "Importi",
"Import Invidious data": "Importi datumojn de Invidious", "Import Invidious data": "Importi datumojn de Invidious",
"Import YouTube subscriptions": "Importi abonojn de YouTube", "Import YouTube subscriptions": "Importi abonojn de JuTubo",
"Import FreeTube subscriptions (.db)": "Importi abonojn de FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importi abonojn de FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importi abonojn de NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importi abonojn de NewPipe (.json)",
"Import NewPipe data (.zip)": "Importi datumojn de NewPipe (.zip)", "Import NewPipe data (.zip)": "Importi datumojn de NewPipe (.zip)",
@@ -35,7 +35,7 @@
"Export data as JSON": "Eksporti datumojn kiel JSON", "Export data as JSON": "Eksporti datumojn kiel JSON",
"Delete account?": "Ĉu forigi konton?", "Delete account?": "Ĉu forigi konton?",
"History": "Historio", "History": "Historio",
"An alternative front-end to YouTube": "Alternativa fasado al YouTube", "An alternative front-end to YouTube": "Alternativa fasado al JuTubo",
"JavaScript license information": "Ĝavoskripta licenca informo", "JavaScript license information": "Ĝavoskripta licenca informo",
"source": "fonto", "source": "fonto",
"Log in": "Ensaluti", "Log in": "Ensaluti",
@@ -55,18 +55,18 @@
"Always loop: ": "Ĉiam ripeti: ", "Always loop: ": "Ĉiam ripeti: ",
"Autoplay: ": "Aŭtomate ludi: ", "Autoplay: ": "Aŭtomate ludi: ",
"Play next by default: ": "Ludi sekvan defaŭlte: ", "Play next by default: ": "Ludi sekvan defaŭlte: ",
"Autoplay next video: ": "Aŭtomate ludi sekvan videon: ", "Autoplay next video: ": "Aŭtomate ludi sekvan filmeton: ",
"Listen by default: ": "Aŭskulti defaŭlte: ", "Listen by default: ": "Aŭskulti defaŭlte: ",
"Proxy videos: ": "Ĉu uzi prokuran servilon por videoj? ", "Proxy videos: ": "Ĉu uzi prokuran servilon por filmetojn? ",
"Default speed: ": "Defaŭlta rapido: ", "Default speed: ": "Defaŭlta rapido: ",
"Preferred video quality: ": "Preferita videkvalito: ", "Preferred video quality: ": "Preferita filmetkvalito: ",
"Player volume: ": "Ludila sonforteco: ", "Player volume: ": "Ludila sonforteco: ",
"Default comments: ": "Defaŭltaj komentoj: ", "Default comments: ": "Defaŭltaj komentoj: ",
"youtube": "youtube", "youtube": "JuTubo",
"reddit": "reddit", "reddit": "Reddit",
"Default captions: ": "Defaŭltaj subtekstoj: ", "Default captions: ": "Defaŭltaj subtekstoj: ",
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ", "Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
"Show related videos: ": "Ĉu montri rilatajn videojn? ", "Show related videos: ": "Ĉu montri rilatajn filmetojn? ",
"Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ", "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
"Visual preferences": "Vidaj preferoj", "Visual preferences": "Vidaj preferoj",
"Player style: ": "Ludila stilo: ", "Player style: ": "Ludila stilo: ",
@@ -78,20 +78,20 @@
"Subscription preferences": "Abonaj agordoj", "Subscription preferences": "Abonaj agordoj",
"Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
"Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ", "Number of videos shown in feed: ": "Nombro da filmetoj montritaj en fluo: ",
"Sort videos by: ": "Ordi videojn laŭ: ", "Sort videos by: ": "Ordi filmetojn per: ",
"published": "publikigo", "published": "publikigo",
"published - reverse": "publitigo - renverse", "published - reverse": "publitigo - renverse",
"alphabetically": "alfabete", "alphabetically": "alfabete",
"alphabetically - reverse": "alfabete - renverse", "alphabetically - reverse": "alfabete - renverse",
"channel name": "kanala nombro", "channel name": "kanala nombro",
"channel name - reverse": "kanala nombro - renverse", "channel name - reverse": "kanala nombro - renverse",
"Only show latest video from channel: ": "Nur montri pli novan videon el kanalo: ", "Only show latest video from channel: ": "Nur montri pli novan filmeton el kanalo: ",
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ", "Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan filmeton el kanalo: ",
"Only show unwatched: ": "Nur montri malviditajn: ", "Only show unwatched: ": "Nur montri malviditajn: ",
"Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ", "Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
"Enable web notifications": "Ebligi retejajn sciigojn", "Enable web notifications": "Ebligi retejajn sciigojn",
"`x` uploaded a video": "`x` alŝutis videon", "`x` uploaded a video": "`x` alŝutis filmeton",
"`x` is live": "`x` estas nuna", "`x` is live": "`x` estas nuna",
"Data preferences": "Datumagordoj", "Data preferences": "Datumagordoj",
"Clear watch history": "Forigi vidohistorion", "Clear watch history": "Forigi vidohistorion",
@@ -127,18 +127,18 @@
"View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.",
"View privacy policy.": "Vidi regularon pri privateco.", "View privacy policy.": "Vidi regularon pri privateco.",
"Trending": "Tendencoj", "Trending": "Tendencoj",
"Public": "", "Public": "Publika",
"Unlisted": "Ne listigita", "Unlisted": "Ne listigita",
"Private": "", "Private": "Privata",
"View all playlists": "", "View all playlists": "Vidi ĉiujn ludlistojn",
"Updated `x` ago": "", "Updated `x` ago": "Ĝisdatigita antaŭ `x`",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Ĉu forigi ludliston `x`?",
"Delete playlist": "", "Delete playlist": "Forigi ludliston",
"Create playlist": "", "Create playlist": "Krei ludliston",
"Title": "", "Title": "Titolo",
"Playlist privacy": "", "Playlist privacy": "Privateco de ludlisto",
"Editing playlist `x`": "", "Editing playlist `x`": "Redaktante ludlisto `x`",
"Watch on YouTube": "Vidi videon en Youtube", "Watch on YouTube": "Vidi filmeton en JuTubo",
"Hide annotations": "Kaŝi prinotojn", "Hide annotations": "Kaŝi prinotojn",
"Show annotations": "Montri prinotojn", "Show annotations": "Montri prinotojn",
"Genre: ": "Ĝenro: ", "Genre: ": "Ĝenro: ",
@@ -153,7 +153,7 @@
"Premieres in `x`": "Premieras en `x`", "Premieres in `x`": "Premieras en `x`",
"Premieres `x`": "Premieras `x`", "Premieres `x`": "Premieras `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.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.",
"View YouTube comments": "Vidi komentojn de YouTube", "View YouTube comments": "Vidi komentojn de JuTubo",
"View more comments on Reddit": "Vidi pli komentoj en Reddit", "View more comments on Reddit": "Vidi pli komentoj en Reddit",
"View `x` comments": "Vidi `x` komentojn", "View `x` comments": "Vidi `x` komentojn",
"View Reddit comments": "Vidi komentojn de Reddit", "View Reddit comments": "Vidi komentojn de Reddit",
@@ -324,12 +324,12 @@
"Download as: ": "Elŝuti kiel: ", "Download as: ": "Elŝuti kiel: ",
"%A %B %-d, %Y": "%A %-d de %B %Y", "%A %B %-d, %Y": "%A %-d de %B %Y",
"(edited)": "(redaktita)", "(edited)": "(redaktita)",
"YouTube comment permalink": "Fiksligilo de la komento en YouTube", "YouTube comment permalink": "Fiksligilo de la komento en JuTubo",
"permalink": "konstanta ligilo", "permalink": "konstanta ligilo",
"`x` marked it with a ❤": "`x` markis ĝin per ❤", "`x` marked it with a ❤": "`x` markis ĝin per ❤",
"Audio mode": "Aŭda reĝimo", "Audio mode": "Aŭda reĝimo",
"Video mode": "Videa reĝimo", "Video mode": "Videa reĝimo",
"Videos": "Videoj", "Videos": "Filmetoj",
"Playlists": "Ludlistoj", "Playlists": "Ludlistoj",
"Community": "Komunumo", "Community": "Komunumo",
"Current version: ": "Nuna versio: " "Current version: ": "Nuna versio: "

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` suscriptores", "`x` subscribers": "`x` suscriptores",
"`x` videos": "`x` vídeos", "`x` videos": "`x` vídeos",
"`x` playlists": "", "`x` playlists": "`x` listas de reproducción",
"LIVE": "DIRECTO", "LIVE": "DIRECTO",
"Shared `x` ago": "Compartido hace `x`", "Shared `x` ago": "Compartido hace `x`",
"Unsubscribe": "Desuscribirse", "Unsubscribe": "Desuscribirse",
@@ -69,11 +69,11 @@
"Show related videos: ": "¿Mostrar vídeos relacionados? ", "Show related videos: ": "¿Mostrar vídeos relacionados? ",
"Show annotations by default: ": "¿Mostrar anotaciones por defecto? ", "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
"Visual preferences": "Preferencias visuales", "Visual preferences": "Preferencias visuales",
"Player style: ": "", "Player style: ": "Estilo de reproductor: ",
"Dark mode: ": "Modo oscuro: ", "Dark mode: ": "Modo oscuro: ",
"Theme: ": "", "Theme: ": "Tema: ",
"dark": "", "dark": "oscuro",
"light": "", "light": "claro",
"Thin mode: ": "Modo compacto: ", "Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferencias de la suscripción", "Subscription preferences": "Preferencias de la suscripción",
"Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
@@ -90,9 +90,9 @@
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ", "Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
"Only show unwatched: ": "Mostrar solo los no vistos: ", "Only show unwatched: ": "Mostrar solo los no vistos: ",
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ", "Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
"Enable web notifications": "", "Enable web notifications": "Habilitar notificaciones web",
"`x` uploaded a video": "", "`x` uploaded a video": "`x` subió un video",
"`x` is live": "", "`x` is live": "`x` esta en vivo",
"Data preferences": "Preferencias de los datos", "Data preferences": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción", "Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos", "Import/export data": "Importar/Exportar datos",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Ver información de licencia de JavaScript.", "View JavaScript license information.": "Ver información de licencia de JavaScript.",
"View privacy policy.": "Ver la política de privacidad.", "View privacy policy.": "Ver la política de privacidad.",
"Trending": "Tendencias", "Trending": "Tendencias",
"Public": "", "Public": "Público",
"Unlisted": "No listado", "Unlisted": "No listado",
"Private": "", "Private": "Privado",
"View all playlists": "", "View all playlists": "Ver todas las listas de reproducción",
"Updated `x` ago": "", "Updated `x` ago": "Actualizado hace `x`",
"Delete playlist `x`?": "", "Delete playlist `x`?": "¿Eliminar la lista de reproducción `x`?",
"Delete playlist": "", "Delete playlist": "Eliminar lista de reproducción",
"Create playlist": "", "Create playlist": "Crear lista de reproducción",
"Title": "", "Title": "Título",
"Playlist privacy": "", "Playlist privacy": "Privacidad de la lista de reproducción",
"Editing playlist `x`": "", "Editing playlist `x`": "Editando la lista de reproducción 'x'",
"Watch on YouTube": "Ver el vídeo en Youtube", "Watch on YouTube": "Ver el vídeo en Youtube",
"Hide annotations": "Ocultar anotaciones", "Hide annotations": "Ocultar anotaciones",
"Show annotations": "Mostrar anotaciones", "Show annotations": "Mostrar anotaciones",
@@ -151,7 +151,7 @@
"Shared `x`": "Compartido `x`", "Shared `x`": "Compartido `x`",
"`x` views": "`x` visualizaciones", "`x` views": "`x` visualizaciones",
"Premieres in `x`": "Se estrena en `x`", "Premieres in `x`": "Se estrena en `x`",
"Premieres `x`": "", "Premieres `x`": "Estrenos `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.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.",
"View YouTube comments": "Ver los comentarios de YouTube", "View YouTube comments": "Ver los comentarios de YouTube",
"View more comments on Reddit": "Ver más comentarios en Reddit", "View more comments on Reddit": "Ver más comentarios en Reddit",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)", "(edited)": "(editado)",
"YouTube comment permalink": "Enlace permanente de YouTube del comentario", "YouTube comment permalink": "Enlace permanente de YouTube del comentario",
"permalink": "", "permalink": "permalink",
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio", "Audio mode": "Modo de audio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Videos": "Vídeos", "Videos": "Vídeos",
"Playlists": "Listas de reproducción", "Playlists": "Listas de reproducción",
"Community": "", "Community": "Comunidad",
"Current version: ": "Versión actual: " "Current version: ": "Versión actual: "
} }

View File

@@ -1,13 +1,13 @@
{ {
"`x` subscribers": "`x` harpidedun", "`x` subscribers": "`x` harpidedun",
"`x` videos": "`x` bideo", "`x` videos": "`x` bideo",
"`x` playlists": "", "`x` playlists": "`x` erreprodukzio-zerrenda",
"LIVE": "ZUZENEAN", "LIVE": "ZUZENEAN",
"Shared `x` ago": "Duela `x` partekatua", "Shared `x` ago": "Duela `x` partekatua",
"Unsubscribe": "Harpidetza kendu", "Unsubscribe": "Harpidetza kendu",
"Subscribe": "Harpidetu", "Subscribe": "Harpidetu",
"View channel on YouTube": "Ikusi kanala YouTuben", "View channel on YouTube": "Ikusi kanala YouTuben",
"View playlist on YouTube": "", "View playlist on YouTube": "Ikusi erreprodukzio-zerrenda YouTuben",
"newest": "berrienak", "newest": "berrienak",
"oldest": "zaharrenak", "oldest": "zaharrenak",
"popular": "ospetsuenak", "popular": "ospetsuenak",
@@ -16,66 +16,66 @@
"Previous page": "Aurreko orria", "Previous page": "Aurreko orria",
"Clear watch history?": "Garbitu ikusitakoen historia?", "Clear watch history?": "Garbitu ikusitakoen historia?",
"New password": "Pasahitz berria", "New password": "Pasahitz berria",
"New passwords must match": "", "New passwords must match": "Pasahitza berriek bat egin behar dute",
"Cannot change password for Google accounts": "", "Cannot change password for Google accounts": "Ezin da pasahitza aldatu Google kontuetan",
"Authorize token?": "", "Authorize token?": "Baimendu tokena?",
"Authorize token for `x`?": "", "Authorize token for `x`?": "",
"Yes": "Bai", "Yes": "Bai",
"No": "Ez", "No": "Ez",
"Import and Export Data": "Datuak inportatu eta esportatu", "Import and Export Data": "Datuak inportatu eta esportatu",
"Import": "Inportatu", "Import": "Inportatu",
"Import Invidious data": "Invidiouseko datuak inportatu", "Import Invidious data": "Inportatu Invidiouseko datuak",
"Import YouTube subscriptions": "YouTubeko harpidetzak inportatu", "Import YouTube subscriptions": "Inportatu YouTubeko harpidetzak",
"Import FreeTube subscriptions (.db)": "FreeTubeko harpidetzak inportatu (.db)", "Import FreeTube subscriptions (.db)": "Inportatu FreeTubeko harpidetzak (.db)",
"Import NewPipe subscriptions (.json)": "NewPipeko harpidetzak inportatu (.json)", "Import NewPipe subscriptions (.json)": "Inportatu NewPipeko harpidetzak (.json)",
"Import NewPipe data (.zip)": "NewPipeko datuak inportatu (.zip)", "Import NewPipe data (.zip)": "Inportatu NewPipeko datuak (.zip)",
"Export": "Esportatu", "Export": "Esportatu",
"Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala", "Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Harpidetzak OPML bezala esportatu (NewPipe eta FreeTuberako)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esportatu harpidetzak OPML bezala (NewPipe eta FreeTuberako)",
"Export data as JSON": "Datuak JSON bezala esportatu", "Export data as JSON": "Esportatu datuak JSON bezala",
"Delete account?": "Kontua ezabatu?", "Delete account?": "Kontua ezabatu?",
"History": "Historia", "History": "Historia",
"An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat", "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
"JavaScript license information": "JavaScript lizentzia informazioa", "JavaScript license information": "JavaScript lizentzia informazioa",
"source": "iturburua", "source": "iturburua",
"Log in": "Saioa hasi", "Log in": "Saioa hasi",
"Log in/register": "Saioa hasi/Izena eman", "Log in/register": "Hasi saioa / Eman izena",
"Log in with Google": "Googlekin hasi saioa", "Log in with Google": "Hasi saioa Googlekin",
"User ID": "Erabiltzaile IDa", "User ID": "Erabiltzaile IDa",
"Password": "Pasahitza", "Password": "Pasahitza",
"Time (h:mm:ss):": "Denbora (o:mm:ss):", "Time (h:mm:ss):": "Denbora (h:mm:ss):",
"Text CAPTCHA": "Testu CAPTCHA", "Text CAPTCHA": "CAPTCHA testua",
"Image CAPTCHA": "Irudi CAPTCHA", "Image CAPTCHA": "CAPTCHA irudia",
"Sign In": "", "Sign In": "Hasi saioa",
"Register": "", "Register": "Eman izena",
"E-mail": "", "E-mail": "E-posta",
"Google verification code": "", "Google verification code": "",
"Preferences": "", "Preferences": "Hobespenak",
"Player preferences": "", "Player preferences": "Erreproduzigailuaren hobespenak",
"Always loop: ": "", "Always loop: ": "",
"Autoplay: ": "", "Autoplay: ": "Automatikoki erreproduzitu: ",
"Play next by default: ": "", "Play next by default: ": "",
"Autoplay next video: ": "", "Autoplay next video: ": "Erreproduzitu automatikoki hurrengo bideoa: ",
"Listen by default: ": "", "Listen by default: ": "",
"Proxy videos: ": "", "Proxy videos: ": "",
"Default speed: ": "", "Default speed: ": "",
"Preferred video quality: ": "", "Preferred video quality: ": "Hobetsitako bideoaren kalitatea: ",
"Player volume: ": "", "Player volume: ": "Erreproduzigailuaren bolumena: ",
"Default comments: ": "", "Default comments: ": "Lehenetsitako iruzkinak: ",
"youtube": "", "youtube": "youtube",
"reddit": "", "reddit": "reddit",
"Default captions: ": "", "Default captions: ": "Lehenetsitako azpitituluak: ",
"Fallback captions: ": "", "Fallback captions: ": "",
"Show related videos: ": "", "Show related videos: ": "Erakutsi erlazionatutako bideoak: ",
"Show annotations by default: ": "", "Show annotations by default: ": "Erakutsi oharrak modu lehenetsian: ",
"Visual preferences": "", "Visual preferences": "Hobespen bisualak",
"Player style: ": "", "Player style: ": "Erreproduzigailu mota: ",
"Dark mode: ": "", "Dark mode: ": "Gai iluna: ",
"Theme: ": "", "Theme: ": "Gaia: ",
"dark": "", "dark": "iluna",
"light": "", "light": "argia",
"Thin mode: ": "", "Thin mode: ": "",
"Subscription preferences": "", "Subscription preferences": "Harpidetzen hobespenak",
"Show annotations by default for subscribed channels: ": "", "Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "", "Redirect homepage to feed: ": "",
"Number of videos shown in feed: ": "", "Number of videos shown in feed: ": "",

View File

@@ -87,8 +87,8 @@
"channel name": "nom de la chaîne", "channel name": "nom de la chaîne",
"channel name - reverse": "nom de la chaîne - inversé", "channel name - reverse": "nom de la chaîne - inversé",
"Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés : ", "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés : ",
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui n'a pas était regardée : ", "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui n'a pas été regardée : ",
"Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas étaient regardées : ", "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas été regardées : ",
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "Activer les notifications web", "Enable web notifications": "Activer les notifications web",
"`x` uploaded a video": "`x` a partagé(e) une vidéo", "`x` uploaded a video": "`x` a partagé(e) une vidéo",
@@ -152,7 +152,7 @@
"`x` views": "`x` vues", "`x` views": "`x` vues",
"Premieres in `x`": "Première dans `x`", "Premieres in `x`": "Première dans `x`",
"Premieres `x`": "Première le `x`", "Premieres `x`": "Première le `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.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires sans. Gardez à l'esprit que le chargement peut prendre plus de temps.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires, mais gardez à l'esprit que le chargement peut prendre plus de temps.",
"View YouTube comments": "Voir les commentaires YouTube", "View YouTube comments": "Voir les commentaires YouTube",
"View more comments on Reddit": "Voir plus de commentaires sur Reddit", "View more comments on Reddit": "Voir plus de commentaires sur Reddit",
"View `x` comments": "Voir `x` commentaires", "View `x` comments": "Voir `x` commentaires",
@@ -160,7 +160,7 @@
"Hide replies": "Masquer les réponses", "Hide replies": "Masquer les réponses",
"Show replies": "Afficher les réponses", "Show replies": "Afficher les réponses",
"Incorrect password": "Mot de passe incorrect", "Incorrect password": "Mot de passe incorrect",
"Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassée, réessayez dans quelques heures", "Quota exceeded, try again in a few hours": "Nombre de tentatives de connexion dépassé, réessayez dans quelques heures",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.",
"Invalid TFA code": "Code d'authentification à deux facteurs invalide", "Invalid TFA code": "Code d'authentification à deux facteurs invalide",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.",

335
locales/hu-HU.json Normal file
View File

@@ -0,0 +1,335 @@
{
"`x` subscribers": "`x` feliratkozó",
"`x` videos": "`x` videó",
"`x` playlists": "`x` playlist",
"LIVE": "ÉLŐ",
"Shared `x` ago": "`x` óta megosztva",
"Unsubscribe": "Leiratkozás",
"Subscribe": "Feliratkozás",
"View channel on YouTube": "Csatokrna megtekintése a YouTube-on",
"View playlist on YouTube": "Playlist megtekintése a YouTube-on",
"newest": "legújabb",
"oldest": "legrégibb",
"popular": "népszerű",
"last": "utolsó",
"Next page": "Következő oldal",
"Previous page": "Előző oldal",
"Clear watch history?": "Megtekintési napló törlése?",
"New password": "Új jelszó",
"New passwords must match": "Az új jelszavaknak egyezniük kell",
"Cannot change password for Google accounts": "Google fiók jelszavát nem lehet cserélni",
"Authorize token?": "Token felhatalmazása?",
"Authorize token for `x`?": "Token felhatalmazása `x`-ra?",
"Yes": "Igen",
"No": "Nem",
"Import and Export Data": "Adatok importálása és exportálása",
"Import": "Importálás",
"Import Invidious data": "Invidious adatainak importálása",
"Import YouTube subscriptions": "YouTube feliratkozások importálása",
"Import FreeTube subscriptions (.db)": "FreeTube feliratkozások importálása (.db)",
"Import NewPipe subscriptions (.json)": "NewPipe feliratkozások importálása (.json)",
"Import NewPipe data (.zip)": "NewPipe adatainak importálása (.zip)",
"Export": "Exportálás",
"Export subscriptions as OPML": "Feliratkozások exportálása OPML-ként",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe és FreeTube számára)",
"Export data as JSON": "Adat exportálása JSON-ként",
"Delete account?": "Fiók törlése?",
"History": "Megtekintési napló",
"An alternative front-end to YouTube": "Alternatív YouTube front-end",
"JavaScript license information": "JavaScript licensz információ",
"source": "forrás",
"Log in": "Bejelentkezés",
"Log in/register": "Bejelentkezés/Regisztráció",
"Log in with Google": "Bejelentkezés Google fiókkal",
"User ID": "Felhasználó-ID",
"Password": "Jelszó",
"Time (h:mm:ss):": "Idő (h:mm:ss):",
"Text CAPTCHA": "Szöveg-CAPTCHA",
"Image CAPTCHA": "Kép-CAPTCHA",
"Sign In": "Bejelentkezés",
"Register": "Regisztráció",
"E-mail": "E-mail",
"Google verification code": "Google verifikációs kód",
"Preferences": "Beállítások",
"Player preferences": "Lejátszó beállítások",
"Always loop: ": "Mindig loop-ol: ",
"Autoplay: ": "Automatikus lejátszás: ",
"Play next by default: ": "Következő lejátszása alapértelmezésben: ",
"Autoplay next video: ": "Következő automatikus lejátszása: ",
"Listen by default: ": "Hallgatás alapértelmezésben: ",
"Proxy videos: ": "Proxy videók: ",
"Default speed: ": "Alapértelmezett sebesség: ",
"Preferred video quality: ": "Kívánt video minőség: ",
"Player volume: ": "Hangerő: ",
"Default comments: ": "Alapértelmezett kommentek: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Default captions: ": "Alapértelmezett feliratok: ",
"Fallback captions: ": "Másodlagos feliratok: ",
"Show related videos: ": "Kapcsolódó videók mutatása: ",
"Show annotations by default: ": "Annotációk mutatása alapértelmetésben: ",
"Visual preferences": "Vizuális preferenciák",
"Player style: ": "Lejátszó stílusa: ",
"Dark mode: ": "Sötét mód: ",
"Theme: ": "Téma: ",
"dark": "Sötét",
"light": "Világos",
"Thin mode: ": "Vékony mód: ",
"Subscription preferences": "Feliratkozási beállítások",
"Show annotations by default for subscribed channels: ": "Annotációk mutatása alapértelmezésben feliratkozott csatornák esetében: ",
"Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ",
"Number of videos shown in feed: ": "Feed-ben mutatott videók száma: ",
"Sort videos by: ": "Videók sorrendje: ",
"published": "közzétéve",
"published - reverse": "közzétéve (ford.)",
"alphabetically": "ABC sorrend",
"alphabetically - reverse": "ABC sorrend (ford.)",
"channel name": "csatorna neve",
"channel name - reverse": "csatorna neve (ford.)",
"Only show latest video from channel: ": "Csak a legutolsó videó mutatása a csatornából: ",
"Only show latest unwatched video from channel: ": "Csak a legutolsó nem megtekintett videó mutatása a csatornából: ",
"Only show unwatched: ": "Csak a nem megtekintettek mutatása: ",
"Only show notifications (if there are any): ": "Csak értesítések mutatása (ha van): ",
"Enable web notifications": "Web értesítések bekapcsolása",
"`x` uploaded a video": "`x` feltöltött egy videót",
"`x` is live": "`x` élő",
"Data preferences": "Adat beállítások",
"Clear watch history": "Megtekintési napló törlése",
"Import/export data": "Adat Import/Export",
"Change password": "Jelszócsere",
"Manage subscriptions": "Feliratkozások kezelése",
"Manage tokens": "Tokenek kezelése",
"Watch history": "Megtekintési napló",
"Delete account": "Fiók törlése",
"Administrator preferences": "Adminisztrátor beállítások",
"Default homepage: ": "Alapértelmezett honlap: ",
"Feed menu: ": "Feed menü: ",
"Top enabled: ": "Top lista engedélyezve: ",
"CAPTCHA enabled: ": "CAPTCHA engedélyezve: ",
"Login enabled: ": "Bejelentkezés engedélyezve: ",
"Registration enabled: ": "Registztráció engedélyezve: ",
"Report statistics: ": "Statisztikák gyűjtése: ",
"Save preferences": "Beállítások mentése",
"Subscription manager": "Feliratkozás kezelő",
"Token manager": "Token kezelő",
"Token": "Token",
"`x` subscriptions": "`x` feliratkozás",
"`x` tokens": "`x` token",
"Import/export": "Import/export",
"unsubscribe": "leiratkozás",
"revoke": "visszavonás",
"Subscriptions": "Feliratkozások",
"`x` unseen notifications": "`x` kimaradt érdesítés",
"search": "keresés",
"Log out": "Kijelentkezés",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth által release-elve AGPLv3 licensz alatt.",
"Source available here.": "Forrás elérhető itt.",
"View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.",
"View privacy policy.": "Adatvédelem irányelv megtekintése.",
"Trending": "Trending",
"Public": "Nyilvános",
"Unlisted": "Nem nyilvános",
"Private": "Privát",
"View all playlists": "Minden playlist megtekintése",
"Updated `x` ago": "Frissitve `x`",
"Delete playlist `x`?": "`x` playlist törlése?",
"Delete playlist": "Playlist törlése",
"Create playlist": "Playlist létrehozása",
"Title": "Címe",
"Playlist privacy": "Playlist láthatósága",
"Editing playlist `x`": "`x` playlist szerkesztése",
"Watch on YouTube": "Megtekintés a YouTube-on",
"Hide annotations": "Annotációk elrejtése",
"Show annotations": "Annotációk mutatása",
"Genre: ": "Zsáner: ",
"License: ": "Licensz: ",
"Family friendly? ": "Családbarát? ",
"Wilson score: ": "Wilson-ponstszém: ",
"Engagement: ": "Engagement: ",
"Whitelisted regions: ": "Engedélyezett régiók: ",
"Blacklisted regions: ": "Tiltott régiók: ",
"Shared `x`": "Megosztva `x`",
"`x` views": "`x` megtekintés",
"Premieres in `x`": "Premier `x`",
"Premieres `x`": "Premier `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.": "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 kommentek megtekintése",
"View more comments on Reddit": "További Reddit kommentek megtekintése",
"View `x` comments": "`x` komment megtekintése",
"View Reddit comments": "Reddit kommentek megtekintése",
"Hide replies": "Válaszok elrejtése",
"Show replies": "Válaszok mutatása",
"Incorrect password": "Helytelen jelszó",
"Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen belépés, győződj meg róla hogy a 2FA (Authenticator vagy SMS) engedélyezve van.",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen belépés, győződj meg róla hogy a 2FA (Authenticator vagy SMS) engedélyezve van.",
"Wrong answer": "Rossz válasz",
"Erroneous CAPTCHA": "Hibás CAPTCHA",
"CAPTCHA is a required field": "A CAPTCHA kötelező",
"User ID is a required field": "A felhasználó-ID kötelező",
"Password is a required field": "A jelszó kötelező",
"Wrong username or password": "Rossz felhasználónév vagy jelszó",
"Please sign in using 'Log in with Google'": "Kérem, jelentkezzen be a \"Bejelentkezés Google-el\"",
"Password cannot be empty": "A jelszó nem lehet üres",
"Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 betűnél",
"Please log in": "Kérem lépjen be",
"Invidious Private Feed for `x`": "`x` Invidious privát feed-je",
"channel:`x`": "`x` csatorna",
"Deleted or invalid channel": "Törölt vagy nemlétező csatorna",
"This channel does not exist.": "Ez a csatorna nem létezik.",
"Could not get channel info.": "Nem megszerezhető a csatorna információ.",
"Could not fetch comments": "Nem megszerezhetőek a kommentek",
"View `x` replies": "`x` válasz megtekintése",
"`x` ago": "`x` óta",
"Load more": "További betöltése",
"`x` points": "`x` pont",
"Could not create mix.": "Nem tudok mix-et készíteni.",
"Empty playlist": "Üres playlist",
"Not a playlist.": "Nem playlist.",
"Playlist does not exist.": "Nem létező playlist.",
"Could not pull trending pages.": "Nem tudom letölteni a trendek adatait.",
"Hidden field \"challenge\" is a required field": "A rejtett \"challenge\" mező kötelező",
"Hidden field \"token\" is a required field": "A rejtett \"token\" mező kötelező",
"Erroneous challenge": "Hibás challenge",
"Erroneous token": "Hibás token",
"No such user": "Nincs ilyen felhasználó",
"Token is expired, please try again": "Lejárt token, kérem próbáld újra",
"English": "",
"English (auto-generated)": "English (auto-genererat)",
"Afrikaans": "",
"Albanian": "",
"Amharic": "",
"Arabic": "",
"Armenian": "",
"Azerbaijani": "",
"Bangla": "",
"Basque": "",
"Belarusian": "",
"Bosnian": "",
"Bulgarian": "",
"Burmese": "",
"Catalan": "",
"Cebuano": "",
"Chinese (Simplified)": "",
"Chinese (Traditional)": "",
"Corsican": "",
"Croatian": "",
"Czech": "",
"Danish": "",
"Dutch": "",
"Esperanto": "",
"Estonian": "",
"Filipino": "",
"Finnish": "",
"French": "",
"Galician": "",
"Georgian": "",
"German": "",
"Greek": "",
"Gujarati": "",
"Haitian Creole": "",
"Hausa": "",
"Hawaiian": "",
"Hebrew": "",
"Hindi": "",
"Hmong": "",
"Hungarian": "",
"Icelandic": "",
"Igbo": "",
"Indonesian": "",
"Irish": "",
"Italian": "",
"Japanese": "",
"Javanese": "",
"Kannada": "",
"Kazakh": "",
"Khmer": "",
"Korean": "",
"Kurdish": "",
"Kyrgyz": "",
"Lao": "",
"Latin": "",
"Latvian": "",
"Lithuanian": "",
"Luxembourgish": "",
"Macedonian": "",
"Malagasy": "",
"Malay": "",
"Malayalam": "",
"Maltese": "",
"Maori": "",
"Marathi": "",
"Mongolian": "",
"Nepali": "",
"Norwegian Bokmål": "",
"Nyanja": "",
"Pashto": "",
"Persian": "",
"Polish": "",
"Portuguese": "",
"Punjabi": "",
"Romanian": "",
"Russian": "",
"Samoan": "",
"Scottish Gaelic": "",
"Serbian": "",
"Shona": "",
"Sindhi": "",
"Sinhala": "",
"Slovak": "",
"Slovenian": "",
"Somali": "",
"Southern Sotho": "",
"Spanish": "",
"Spanish (Latin America)": "",
"Sundanese": "",
"Swahili": "",
"Swedish": "",
"Tajik": "",
"Tamil": "",
"Telugu": "",
"Thai": "",
"Turkish": "",
"Ukrainian": "",
"Urdu": "",
"Uzbek": "",
"Vietnamese": "",
"Welsh": "",
"Western Frisian": "",
"Xhosa": "",
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
"`x` years": "`x` év",
"`x` months": "`x` hónap",
"`x` weeks": "`x` hét",
"`x` days": "`x` nap",
"`x` hours": "`x` óra",
"`x` minutes": "`x` perc",
"`x` seconds": "`x` másodperc",
"Fallback comments: ": "Másodlagos kommentek: ",
"Popular": "Népszerű",
"Top": "Top",
"About": "Leírás",
"Rating: ": "Besorolás: ",
"Language: ": "Nyelv: ",
"View as playlist": "Megtekintés playlist-ként",
"Default": "Alapértelmezett",
"Music": "Zene",
"Gaming": "Játékok",
"News": "Hírek",
"Movies": "Filmek",
"Download": "Letöltés",
"Download as: ": "Letöltés mint: ",
"%A %B %-d, %Y": "",
"(edited)": "(szerkesztve)",
"YouTube comment permalink": "YouTube komment permalink",
"permalink": "permalink",
"`x` marked it with a ❤": "`x` jelölte ❤-vel",
"Audio mode": "Audio mód",
"Video mode": "Video mód",
"Videos": "Videók",
"Playlists": "Playlistek",
"Community": "Közösség",
"Current version: ": "Jelenlegi verzió: "
}

View File

@@ -1,13 +1,13 @@
{ {
"`x` subscribers": { "`x` subscribers.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto",
"": "`x` iscritti" "": "`x` iscritti."
}, },
"`x` videos": { "`x` videos.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video",
"": "`x` video" "": "`x` video."
}, },
"`x` playlists": "", "`x` playlists": "`x` playlist",
"LIVE": "IN DIRETTA", "LIVE": "IN DIRETTA",
"Shared `x` ago": "Condiviso `x` fa", "Shared `x` ago": "Condiviso `x` fa",
"Unsubscribe": "Disiscriviti", "Unsubscribe": "Disiscriviti",
@@ -75,9 +75,9 @@
"Show related videos: ": "Mostra video correlati: ", "Show related videos: ": "Mostra video correlati: ",
"Show annotations by default: ": "Mostra le annotazioni in modo predefinito: ", "Show annotations by default: ": "Mostra le annotazioni in modo predefinito: ",
"Visual preferences": "Preferenze grafiche", "Visual preferences": "Preferenze grafiche",
"Player style: ": "Stile riproduttore", "Player style: ": "Stile riproduttore: ",
"Dark mode: ": "Tema scuro: ", "Dark mode: ": "Tema scuro: ",
"Theme: ": "Tema", "Theme: ": "Tema: ",
"dark": "scuro", "dark": "scuro",
"light": "chiaro", "light": "chiaro",
"Thin mode: ": "Modalità per connessioni lente: ", "Thin mode: ": "Modalità per connessioni lente: ",
@@ -110,7 +110,7 @@
"Administrator preferences": "Preferenze amministratore", "Administrator preferences": "Preferenze amministratore",
"Default homepage: ": "Pagina principale predefinita: ", "Default homepage: ": "Pagina principale predefinita: ",
"Feed menu: ": "Menu iscrizioni: ", "Feed menu: ": "Menu iscrizioni: ",
"Top enabled: ": "", "Top enabled: ": "Top abilitato: ",
"CAPTCHA enabled: ": "CAPTCHA attivati: ", "CAPTCHA enabled: ": "CAPTCHA attivati: ",
"Login enabled: ": "Accesso attivato: ", "Login enabled: ": "Accesso attivato: ",
"Registration enabled: ": "Registrazione attivata: ", "Registration enabled: ": "Registrazione attivata: ",
@@ -119,40 +119,40 @@
"Subscription manager": "Gestione delle iscrizioni", "Subscription manager": "Gestione delle iscrizioni",
"Token manager": "Gestione dei gettoni", "Token manager": "Gestione dei gettoni",
"Token": "Gettone", "Token": "Gettone",
"`x` subscriptions": { "`x` subscriptions.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione",
"": "`x` iscrizioni" "": "`x` iscrizioni."
}, },
"`x` tokens": { "`x` tokens.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone",
"": "`x` gettoni" "": "`x` gettoni."
}, },
"Import/export": "Importa/esporta", "Import/export": "Importa/esporta",
"unsubscribe": "disiscriviti", "unsubscribe": "disiscriviti",
"revoke": "revoca", "revoke": "revoca",
"Subscriptions": "Iscrizioni", "Subscriptions": "Iscrizioni",
"`x` unseen notifications": { "`x` unseen notifications.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata",
"": "`x` notifiche non visualizzate" "": "`x` notifiche non visualizzate."
}, },
"search": "Cerca", "search": "Cerca",
"Log out": "Esci", "Log out": "Esci",
"Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.",
"Source available here.": "Codice sorgente.", "Source available here.": "Codice sorgente.",
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
"View privacy policy.": "Vedi la politica sulla privacy", "View privacy policy.": "Vedi la politica sulla privacy.",
"Trending": "Tendenze", "Trending": "Tendenze",
"Public": "", "Public": "Pubblico",
"Unlisted": "Non elencati", "Unlisted": "Non elencati",
"Private": "", "Private": "Privato",
"View all playlists": "", "View all playlists": "Visualizza tutte le playlist",
"Updated `x` ago": "", "Updated `x` ago": "Aggiornato `x` fa",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Eliminare la playlist `x`?",
"Delete playlist": "", "Delete playlist": "Elimina playlist",
"Create playlist": "", "Create playlist": "Crea playlist",
"Title": "", "Title": "Titolo",
"Playlist privacy": "", "Playlist privacy": "Privacy playlist",
"Editing playlist `x`": "", "Editing playlist `x`": "Modificando la playlist `x`",
"Watch on YouTube": "Guarda su YouTube", "Watch on YouTube": "Guarda su YouTube",
"Hide annotations": "Nascondi annotazioni", "Hide annotations": "Nascondi annotazioni",
"Show annotations": "Mostra annotazioni", "Show annotations": "Mostra annotazioni",
@@ -164,12 +164,12 @@
"Whitelisted regions: ": "Regioni in lista bianca: ", "Whitelisted regions: ": "Regioni in lista bianca: ",
"Blacklisted regions: ": "Regioni in lista nera: ", "Blacklisted regions: ": "Regioni in lista nera: ",
"Shared `x`": "Condiviso `x`", "Shared `x`": "Condiviso `x`",
"`x` views": { "`x` views.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione",
"": "`x` visualizzazioni" "": "`x` visualizzazioni."
}, },
"Premieres in `x`": "", "Premieres in `x`": "In anteprima in `x`",
"Premieres `x`": "", "Premieres `x`": "In anteprima `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.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.",
"View YouTube comments": "Visualizza i commenti da YouTube", "View YouTube comments": "Visualizza i commenti da YouTube",
"View more comments on Reddit": "Visualizza più commenti su Reddit", "View more comments on Reddit": "Visualizza più commenti su Reddit",
@@ -198,15 +198,15 @@
"This channel does not exist.": "Questo canale non esiste.", "This channel does not exist.": "Questo canale non esiste.",
"Could not get channel info.": "Impossibile ottenere le informazioni del canale.", "Could not get channel info.": "Impossibile ottenere le informazioni del canale.",
"Could not fetch comments": "Impossibile recuperare i commenti", "Could not fetch comments": "Impossibile recuperare i commenti",
"View `x` replies": { "View `x` replies.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta", "([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta",
"": "Visualizza `x` risposte" "": "Visualizza `x` risposte."
}, },
"`x` ago": "`x` fa", "`x` ago": "`x` fa",
"Load more": "Carica altro", "Load more": "Carica altro",
"`x` points": { "`x` points.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto",
"": "`x` punti" "": "`x` punti."
}, },
"Could not create mix.": "Impossibile creare il mix.", "Could not create mix.": "Impossibile creare il mix.",
"Empty playlist": "Playlist vuota", "Empty playlist": "Playlist vuota",
@@ -325,33 +325,33 @@
"Yiddish": "Yiddish", "Yiddish": "Yiddish",
"Yoruba": "Yoruba", "Yoruba": "Yoruba",
"Zulu": "Zulu", "Zulu": "Zulu",
"`x` years": { "`x` years.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno",
"": "`x` anni" "": "`x` anni."
}, },
"`x` months": { "`x` months.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese",
"": "`x` mesi" "": "`x` mesi."
}, },
"`x` weeks": { "`x` weeks.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana",
"": "`x` settimane" "": "`x` settimane."
}, },
"`x` days": { "`x` days.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno",
"": "`x` giorni" "": "`x` giorni."
}, },
"`x` hours": { "`x` hours.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora",
"": "`x` ore" "": "`x` ore."
}, },
"`x` minutes": { "`x` minutes.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto",
"": "`x` minuti" "": "`x` minuti."
}, },
"`x` seconds": { "`x` seconds.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo",
"": "`x` secondi" "": "`x` secondi."
}, },
"Fallback comments: ": "Commenti alternativi: ", "Fallback comments: ": "Commenti alternativi: ",
"Popular": "Popolare", "Popular": "Popolare",
@@ -370,7 +370,7 @@
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(modificato)", "(edited)": "(modificato)",
"YouTube comment permalink": "Link permanente al commento di YouTube", "YouTube comment permalink": "Link permanente al commento di YouTube",
"permalink": "", "permalink": "permalink",
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio", "Audio mode": "Modalità audio",
"Video mode": "Modalità video", "Video mode": "Modalità video",

View File

@@ -8,7 +8,7 @@
"": "`x` 個の動画" "": "`x` 個の動画"
}, },
"`x` playlists": { "`x` playlists": {
"(\\D|^)1(\\D|$)": "`x` 個の再生リスト", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の再生リスト",
"": "`x` 個の再生リスト" "": "`x` 個の再生リスト"
}, },
"LIVE": "ライブ", "LIVE": "ライブ",
@@ -177,7 +177,7 @@
"View YouTube comments": "YouTube のコメントを見る", "View YouTube comments": "YouTube のコメントを見る",
"View more comments on Reddit": "Reddit でコメントをもっと見る", "View more comments on Reddit": "Reddit でコメントをもっと見る",
"View `x` comments": { "View `x` comments": {
"(\\D|^)1(\\D|$)": "`x` 件のコメントを見る", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件のコメントを見る",
"": "`x` 件のコメントを見る" "": "`x` 件のコメントを見る"
}, },
"View Reddit comments": "Reddit のコメントを見る", "View Reddit comments": "Reddit のコメントを見る",

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` abonnenter", "`x` subscribers": "`x` abonnenter",
"`x` videos": "`x` videoer", "`x` videos": "`x` videoer",
"`x` playlists": "", "`x` playlists": "`x` spillelister",
"LIVE": "SANNTIDSVISNING", "LIVE": "SANNTIDSVISNING",
"Shared `x` ago": "Delt for `x` siden", "Shared `x` ago": "Delt for `x` siden",
"Unsubscribe": "Opphev abonnement", "Unsubscribe": "Opphev abonnement",
@@ -25,13 +25,13 @@
"Import and Export Data": "Importer- og eksporter data", "Import and Export Data": "Importer- og eksporter data",
"Import": "Importer", "Import": "Importer",
"Import Invidious data": "Importer Invidious-data", "Import Invidious data": "Importer Invidious-data",
"Import YouTube subscriptions": "Importer YouTube-abonnenter", "Import YouTube subscriptions": "Importer YouTube-abonnementer",
"Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnenter (.db)", "Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)",
"Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnenter (.json)", "Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)",
"Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)", "Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)",
"Export": "Eksporter", "Export": "Eksporter",
"Export subscriptions as OPML": "Eksporter abonnenter som OPML", "Export subscriptions as OPML": "Eksporter abonnementer som OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporter abonnenter som OPML (for NewPipe og FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporter abonnementer som OPML (for NewPipe og FreeTube)",
"Export data as JSON": "Eksporter data som JSON", "Export data as JSON": "Eksporter data som JSON",
"Delete account?": "Slett konto?", "Delete account?": "Slett konto?",
"History": "Historikk", "History": "Historikk",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.",
"View privacy policy.": "Vis personvernspraksis.", "View privacy policy.": "Vis personvernspraksis.",
"Trending": "Trendsettende", "Trending": "Trendsettende",
"Public": "", "Public": "Offentlig",
"Unlisted": "Ulistet", "Unlisted": "Ulistet",
"Private": "", "Private": "Privat",
"View all playlists": "", "View all playlists": "Vis alle spillelister",
"Updated `x` ago": "", "Updated `x` ago": "Oppdatert `x` siden",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Slett spillelisten `x`?",
"Delete playlist": "", "Delete playlist": "Slett spilleliste",
"Create playlist": "", "Create playlist": "Opprett spilleliste",
"Title": "", "Title": "Tittel",
"Playlist privacy": "", "Playlist privacy": "Vern av spilleliste",
"Editing playlist `x`": "", "Editing playlist `x`": "Redigerer spillelisten `x`",
"Watch on YouTube": "Vis video på YouTube", "Watch on YouTube": "Vis video på YouTube",
"Hide annotations": "Skjul merknader", "Hide annotations": "Skjul merknader",
"Show annotations": "Vis merknader", "Show annotations": "Vis merknader",
@@ -197,12 +197,12 @@
"Token is expired, please try again": "Symbol utløpt, prøv igjen", "Token is expired, please try again": "Symbol utløpt, prøv igjen",
"English": "Engelsk", "English": "Engelsk",
"English (auto-generated)": "Engelsk (auto-generert)", "English (auto-generated)": "Engelsk (auto-generert)",
"Afrikaans": "", "Afrikaans": "Afrikansk",
"Albanian": "Albansk", "Albanian": "Albansk",
"Amharic": "", "Amharic": "Amharisk",
"Arabic": "Arabisk", "Arabic": "Arabisk",
"Armenian": "Armensk", "Armenian": "Armensk",
"Azerbaijani": "", "Azerbaijani": "Aserbajdsjansk",
"Bangla": "", "Bangla": "",
"Basque": "", "Basque": "",
"Belarusian": "Hviterussisk", "Belarusian": "Hviterussisk",
@@ -217,16 +217,16 @@
"Croatian": "", "Croatian": "",
"Czech": "Tsjekkisk", "Czech": "Tsjekkisk",
"Danish": "Dansk", "Danish": "Dansk",
"Dutch": "", "Dutch": "Nederlandsk",
"Esperanto": "Esperanto", "Esperanto": "Esperanto",
"Estonian": "", "Estonian": "Estisk",
"Filipino": "", "Filipino": "Filippinsk",
"Finnish": "Finsk", "Finnish": "Finsk",
"French": "Fransk", "French": "Fransk",
"Galician": "", "Galician": "",
"Georgian": "", "Georgian": "",
"German": "", "German": "Tysk",
"Greek": "", "Greek": "Gresk",
"Gujarati": "", "Gujarati": "",
"Haitian Creole": "", "Haitian Creole": "",
"Hausa": "", "Hausa": "",
@@ -309,7 +309,7 @@
"`x` minutes": "`x` minutter", "`x` minutes": "`x` minutter",
"`x` seconds": "`x` sekunder", "`x` seconds": "`x` sekunder",
"Fallback comments: ": "Tilbakefallskommentarer: ", "Fallback comments: ": "Tilbakefallskommentarer: ",
"Popular": "Pupulært", "Popular": "Populært",
"Top": "Topp", "Top": "Topp",
"About": "Om", "About": "Om",
"Rating: ": "Vurdering: ", "Rating: ": "Vurdering: ",

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` abonnees", "`x` subscribers": "`x` abonnees",
"`x` videos": "`x` video's", "`x` videos": "`x` video's",
"`x` playlists": "", "`x` playlists": "`x` afspeellijsten",
"LIVE": "LIVE", "LIVE": "LIVE",
"Shared `x` ago": "Gedeeld: `x` geleden", "Shared `x` ago": "Gedeeld: `x` geleden",
"Unsubscribe": "Deabonneren", "Unsubscribe": "Deabonneren",
@@ -69,11 +69,11 @@
"Show related videos: ": "Gerelateerde video's tonen? ", "Show related videos: ": "Gerelateerde video's tonen? ",
"Show annotations by default: ": "Standaard annotaties tonen? ", "Show annotations by default: ": "Standaard annotaties tonen? ",
"Visual preferences": "Visuele instellingen", "Visual preferences": "Visuele instellingen",
"Player style: ": "", "Player style: ": "Speler vormgeving",
"Dark mode: ": "Donkere modus: ", "Dark mode: ": "Donkere modus: ",
"Theme: ": "", "Theme: ": "Thema: ",
"dark": "", "dark": "donker",
"light": "", "light": "licht",
"Thin mode: ": "Smalle modus: ", "Thin mode: ": "Smalle modus: ",
"Subscription preferences": "Abonnementsinstellingen", "Subscription preferences": "Abonnementsinstellingen",
"Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
"View privacy policy.": "Privacybeleid tonen", "View privacy policy.": "Privacybeleid tonen",
"Trending": "Uitgelicht", "Trending": "Uitgelicht",
"Public": "", "Public": "Publiek",
"Unlisted": "Verborgen", "Unlisted": "Verborgen",
"Private": "", "Private": "Privé",
"View all playlists": "", "View all playlists": "Bekijk alle afspeellijsten",
"Updated `x` ago": "", "Updated `x` ago": "`x` geleden aangepast",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Afspeellijst `x` verwijderen?",
"Delete playlist": "", "Delete playlist": "Verwijder afspeellijst",
"Create playlist": "", "Create playlist": "Nieuwe afspeellijst",
"Title": "", "Title": "Titel",
"Playlist privacy": "", "Playlist privacy": "Afspeellijst privacy",
"Editing playlist `x`": "", "Editing playlist `x`": "Afspeellijst `x` wijzigen",
"Watch on YouTube": "Video bekijken op YouTube", "Watch on YouTube": "Video bekijken op YouTube",
"Hide annotations": "Annotaties verbergen", "Hide annotations": "Annotaties verbergen",
"Show annotations": "Annotaties tonen", "Show annotations": "Annotaties tonen",
@@ -331,6 +331,7 @@
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Video's", "Videos": "Video's",
"Playlists": "Afspeellijsten", "Playlists": "Afspeellijsten",
"Community": "", "Community": "Gemeenschap",
"Current version: ": "Huidige versie: " "Current version: ": "Huidige versie: ",
"Download is disabled.": "Downloaden is uitgeschakeld."
} }

View File

@@ -1,13 +1,13 @@
{ {
"`x` subscribers": "`x` subskrybcji", "`x` subscribers": "`x` subskrybcji",
"`x` videos": "`x` filmów", "`x` videos": "`x` filmów",
"`x` playlists": "", "`x` playlists": "`x` playlist",
"LIVE": "NA ŻYWO", "LIVE": "NA ŻYWO",
"Shared `x` ago": "Udostępniono `x` temu", "Shared `x` ago": "Udostępniono `x` temu",
"Unsubscribe": "Odsubskrybuj", "Unsubscribe": "Odsubskrybuj",
"Subscribe": "Subskrybuj", "Subscribe": "Subskrybuj",
"View channel on YouTube": "Wyświetl kanał na YouTube", "View channel on YouTube": "Wyświetl kanał na YouTube",
"View playlist on YouTube": "", "View playlist on YouTube": "Zobacz playlistę na YouTube",
"newest": "najnowsze", "newest": "najnowsze",
"oldest": "najstarsze", "oldest": "najstarsze",
"popular": "popularne", "popular": "popularne",
@@ -15,11 +15,11 @@
"Next page": "Następna strona", "Next page": "Następna strona",
"Previous page": "Poprzednia strona", "Previous page": "Poprzednia strona",
"Clear watch history?": "Wyczyścić historię?", "Clear watch history?": "Wyczyścić historię?",
"New password": "", "New password": "Nowe hasło",
"New passwords must match": "", "New passwords must match": "Nowe hasła muszą być identyczne",
"Cannot change password for Google accounts": "", "Cannot change password for Google accounts": "Nie można zmienić hasła do konta Google",
"Authorize token?": "", "Authorize token?": "Autoryzować token?",
"Authorize token for `x`?": "", "Authorize token for `x`?": "Autoryzować token dla `x`?",
"Yes": "Tak", "Yes": "Tak",
"No": "Nie", "No": "Nie",
"Import and Export Data": "Import i eksport danych", "Import and Export Data": "Import i eksport danych",
@@ -54,7 +54,7 @@
"Player preferences": "Ustawienia odtwarzacza", "Player preferences": "Ustawienia odtwarzacza",
"Always loop: ": "Zawsze zapętlaj: ", "Always loop: ": "Zawsze zapętlaj: ",
"Autoplay: ": "Autoodtwarzanie: ", "Autoplay: ": "Autoodtwarzanie: ",
"Play next by default: ": "", "Play next by default: ": "Domyślnie odtwarzaj następny: ",
"Autoplay next video: ": "Odtwórz następny film: ", "Autoplay next video: ": "Odtwórz następny film: ",
"Listen by default: ": "Tryb dźwiękowy: ", "Listen by default: ": "Tryb dźwiękowy: ",
"Proxy videos: ": "Filmy przez proxy? ", "Proxy videos: ": "Filmy przez proxy? ",
@@ -62,21 +62,21 @@
"Preferred video quality: ": "Preferowana jakość filmów: ", "Preferred video quality: ": "Preferowana jakość filmów: ",
"Player volume: ": "Głośność odtwarzacza: ", "Player volume: ": "Głośność odtwarzacza: ",
"Default comments: ": "Domyślne komentarze: ", "Default comments: ": "Domyślne komentarze: ",
"youtube": "", "youtube": "youtube",
"reddit": "", "reddit": "reddit",
"Default captions: ": "Domyślne napisy: ", "Default captions: ": "Domyślne napisy: ",
"Fallback captions: ": "Zastępcze napisy: ", "Fallback captions: ": "Zastępcze napisy: ",
"Show related videos: ": "Pokaż powiązane filmy? ", "Show related videos: ": "Pokaż powiązane filmy? ",
"Show annotations by default: ": "", "Show annotations by default: ": "Domyślnie pokazuj adnotacje: ",
"Visual preferences": "Preferencje Wizualne", "Visual preferences": "Preferencje Wizualne",
"Player style: ": "", "Player style: ": "Styl odtwarzacza: ",
"Dark mode: ": "Ciemny motyw: ", "Dark mode: ": "Ciemny motyw: ",
"Theme: ": "", "Theme: ": "Motyw: ",
"dark": "", "dark": "ciemny",
"light": "", "light": "jasny",
"Thin mode: ": "Tryb minimalny: ", "Thin mode: ": "Tryb minimalny: ",
"Subscription preferences": "Preferencje subskrybcji", "Subscription preferences": "Preferencje subskrybcji",
"Show annotations by default for subscribed channels: ": "", "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
"Sort videos by: ": "Sortuj filmy: ", "Sort videos by: ": "Sortuj filmy: ",
@@ -90,34 +90,34 @@
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ", "Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ", "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ", "Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
"Enable web notifications": "", "Enable web notifications": "Włącz powiadomienia",
"`x` uploaded a video": "", "`x` uploaded a video": "`x` dodał film",
"`x` is live": "", "`x` is live": "'x ' jest na żywo",
"Data preferences": "Preferencje danych", "Data preferences": "Preferencje danych",
"Clear watch history": "Wyczyść historię", "Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych", "Import/export data": "Import/Eksport danych",
"Change password": "", "Change password": "Zmień hasło",
"Manage subscriptions": "Organizuj subskrybcje", "Manage subscriptions": "Organizuj subskrybcje",
"Manage tokens": "", "Manage tokens": "Zarządzaj tokenami",
"Watch history": "Historia", "Watch history": "Historia",
"Delete account": "Usuń konto", "Delete account": "Usuń konto",
"Administrator preferences": "Preferencje administratora", "Administrator preferences": "Preferencje administratora",
"Default homepage: ": "Domyślna strona główna: ", "Default homepage: ": "Domyślna strona główna: ",
"Feed menu: ": "", "Feed menu: ": "",
"Top enabled: ": "", "Top enabled: ": "\"Top\" aktywne: ",
"CAPTCHA enabled: ": "CAPTCHA aktywna? ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ",
"Login enabled: ": "Logowanie włączone? ", "Login enabled: ": "Logowanie włączone? ",
"Registration enabled: ": "Rejestracja włączona? ", "Registration enabled: ": "Rejestracja włączona? ",
"Report statistics: ": "Raportować statystyki? ", "Report statistics: ": "Raportować statystyki? ",
"Save preferences": "Zapisz preferencje", "Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji", "Subscription manager": "Manager subskrybcji",
"Token manager": "", "Token manager": "Menedżer tokenów",
"Token": "", "Token": "Token",
"`x` subscriptions": "`x` subskrybcji", "`x` subscriptions": "`x` subskrybcji",
"`x` tokens": "", "`x` tokens": "",
"Import/export": "Import/Eksport", "Import/export": "Import/Eksport",
"unsubscribe": "odsubskrybuj", "unsubscribe": "odsubskrybuj",
"revoke": "", "revoke": "cofnij",
"Subscriptions": "Subskrybcje", "Subscriptions": "Subskrybcje",
"`x` unseen notifications": "`x` nowych powiadomień", "`x` unseen notifications": "`x` nowych powiadomień",
"search": "szukaj", "search": "szukaj",
@@ -127,20 +127,20 @@
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "Polityka prywatności.", "View privacy policy.": "Polityka prywatności.",
"Trending": "Na czasie", "Trending": "Na czasie",
"Public": "", "Public": "Publiczne",
"Unlisted": "", "Unlisted": "Niewidoczne",
"Private": "", "Private": "Prywatne",
"View all playlists": "", "View all playlists": "Pokaż wszystkie playlisty",
"Updated `x` ago": "", "Updated `x` ago": "Zaktualizowano `x` temu",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Usunąć playlistę 'x '?",
"Delete playlist": "", "Delete playlist": "Usuń playlistę",
"Create playlist": "", "Create playlist": "Utwórz playlistę",
"Title": "", "Title": "Tytuł",
"Playlist privacy": "", "Playlist privacy": "Widoczność playlisty",
"Editing playlist `x`": "", "Editing playlist `x`": "Edycja playlisty `x`",
"Watch on YouTube": "Zobacz film na YouTube", "Watch on YouTube": "Zobacz film na YouTube",
"Hide annotations": "", "Hide annotations": "Ukryj adnotacje",
"Show annotations": "", "Show annotations": "Pokaż adnotacje",
"Genre: ": "Gatunek: ", "Genre: ": "Gatunek: ",
"License: ": "Licencja: ", "License: ": "Licencja: ",
"Family friendly? ": "Przyjazny rodzinie? ", "Family friendly? ": "Przyjazny rodzinie? ",
@@ -151,7 +151,7 @@
"Shared `x`": "Udostępniono `x`", "Shared `x`": "Udostępniono `x`",
"`x` views": "`x` wyświetleń", "`x` views": "`x` wyświetleń",
"Premieres in `x`": "Publikacja za `x`", "Premieres in `x`": "Publikacja za `x`",
"Premieres `x`": "", "Premieres `x`": "Publikacja za `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.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.",
"View YouTube comments": "Wyświetl komentarze z YouTube", "View YouTube comments": "Wyświetl komentarze z YouTube",
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie", "View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
@@ -310,7 +310,7 @@
"`x` seconds": "`x` sekund", "`x` seconds": "`x` sekund",
"Fallback comments: ": "Zastępcze komentarze: ", "Fallback comments: ": "Zastępcze komentarze: ",
"Popular": "Popularne", "Popular": "Popularne",
"Top": "Najczęściej oglądane", "Top": "Top",
"About": "Informacje", "About": "Informacje",
"Rating: ": "Ocena: ", "Rating: ": "Ocena: ",
"Language: ": "Język: ", "Language: ": "Język: ",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "(edytowany)", "(edited)": "(edytowany)",
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
"permalink": "", "permalink": "bezpośredni odnośnik",
"`x` marked it with a ❤": "`x` oznaczonych ❤", "`x` marked it with a ❤": "`x` oznaczonych ❤",
"Audio mode": "Tryb audio", "Audio mode": "Tryb audio",
"Video mode": "Tryb wideo", "Video mode": "Tryb wideo",
"Videos": "Filmy", "Videos": "Filmy",
"Playlists": "Playlisty", "Playlists": "Playlisty",
"Community": "", "Community": "Społeczność",
"Current version: ": "Aktualna wersja: " "Current version: ": "Aktualna wersja: "
} }

336
locales/pt-BR.json Normal file
View File

@@ -0,0 +1,336 @@
{
"`x` subscribers": "`x` inscritos",
"`x` videos": "`x` videos",
"`x` playlists": "`x` lista de reprodução",
"LIVE": "AO VIVO",
"Shared `x` ago": "Compartilhado `x` atrás",
"Unsubscribe": "Desinscrever-se",
"Subscribe": "Inscrever-se",
"View channel on YouTube": "Ver canal no YouTube",
"View playlist on YouTube": "Ver lista de reprodução no YouTube",
"newest": "mais recentes",
"oldest": "mais antigos",
"popular": "populares",
"last": "último",
"Next page": "Próxima página",
"Previous page": "Página anterior",
"Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova senha",
"New passwords must match": "Nova senha deve ser igual",
"Cannot change password for Google accounts": "Não é possível alterar sua senha da conta Google",
"Authorize token?": "Autorizar o token?",
"Authorize token for `x`?": "Autorizar o token para `x`?",
"Yes": "Sim",
"No": "Não",
"Import and Export Data": "Importar e Exportar Dados",
"Import": "Importar",
"Import Invidious data": "Importar datos do Invidious",
"Import YouTube subscriptions": "Importar inscrições do YouTube",
"Import FreeTube subscriptions (.db)": "Importar inscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar inscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
"Export": "Exportar",
"Export subscriptions as OPML": "Exportar inscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar inscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados como JSON",
"Delete account?": "Deletar conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa para o YouTube",
"JavaScript license information": "Informação de licença do JavaScript",
"source": "código fonte",
"Log in": "Entrar",
"Log in/register": "Entrar/Registrar",
"Log in with Google": "Entrar com conta Google",
"User ID": "Usuário",
"Password": "Senha",
"Time (h:mm:ss):": "Hora (h:mm:ss):",
"Text CAPTCHA": "CAPTCHA em texto",
"Image CAPTCHA": "CAPTCHA em imagen",
"Sign In": "Entrar",
"Register": "Registrar",
"E-mail": "E-mail",
"Google verification code": "Código de verificação do Google",
"Preferences": "Preferências",
"Player preferences": "Preferências do reprodutor",
"Always loop: ": "Repetir sempre: ",
"Autoplay: ": "Reprodução automática: ",
"Play next by default: ": "Sempre reproduzir próximo: ",
"Autoplay next video: ": "Reproduzir próximo video automaticamente: ",
"Listen by default: ": "Sempre ativar som: ",
"Proxy videos: ": "Usar proxy nos videos: ",
"Default speed: ": "Velocidade preferida: ",
"Preferred video quality: ": "Qualidade de video preferida: ",
"Player volume: ": "Volume de reprodução: ",
"Default comments: ": "Preferência de comentários: ",
"youtube": "youtube",
"reddit": "reddit",
"Default captions: ": "Preferência de legendas: ",
"Fallback captions: ": "Legendas alternativas: ",
"Show related videos: ": "Ver videos relacionados: ",
"Show annotations by default: ": "Sempre mostrar anotações: ",
"Visual preferences": "Preferências visuais",
"Player style: ": "Estilo do reprodutor",
"Dark mode: ": "Modo escuro: ",
"Theme: ": "Tema",
"dark": "escuro",
"light": "claro",
"Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferências de inscrições",
"Show annotations by default for subscribed channels: ": "Sempre mostrar anotações nos videos de canais inscritos ",
"Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ",
"Number of videos shown in feed: ": "Número de videos no feed: ",
"Sort videos by: ": "Ordenar videos por: ",
"published": "publicado",
"published - reverse": "publicado - ordem inversa",
"alphabetically": "alfabética",
"alphabetically - reverse": "alfabética - ordem inversa",
"channel name": "nome do canal",
"channel name - reverse": "nome do canal - ordem inversa",
"Only show latest video from channel: ": "Mostrar apenas o video mais recente do canal: ",
"Only show latest unwatched video from channel: ": "Mostrar apenas o video mais recente não visualizados do canal: ",
"Only show unwatched: ": "Mostrar apenas videos não visualizados: ",
"Only show notifications (if there are any): ": "Mostrar apenas notificações (se existentes): ",
"Enable web notifications": "Ativar notificações pela web",
"`x` uploaded a video": "`x` publicou um novo video",
"`x` is live": "`x` está ao vivo",
"Data preferences": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução",
"Import/export data": "Importar/Exportar dados",
"Change password": "Alterar senha",
"Manage subscriptions": "Gerenciar inscrições",
"Manage tokens": "Gerenciar tokens",
"Watch history": "Histórico de reprodução",
"Delete account": "Apagar sua conta",
"Administrator preferences": "Preferências de administrador",
"Default homepage: ": "Página de inicio padrão: ",
"Feed menu: ": "Menú do feed: ",
"Top enabled: ": "Habilitar destaques: ",
"CAPTCHA enabled: ": "Habilitar CAPTCHA: ",
"Login enabled: ": "Habilitar login: ",
"Registration enabled: ": "Habilitar registro: ",
"Report statistics: ": "Habilitar estatísticas: ",
"Save preferences": "Salvar preferências",
"Subscription manager": "Gerenciador de inscrições",
"Token manager": "Gerenciador de tokens",
"Token": "Token",
"`x` subscriptions": "`x` inscrições",
"`x` tokens": "`x` tokens",
"Import/export": "Importar/Exportar",
"unsubscribe": "desinscrever-se",
"revoke": "revogar",
"Subscriptions": "Inscrições",
"`x` unseen notifications": "`x` notificações não visualizadas",
"search": "procurar",
"Log out": "Sair",
"Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.",
"Source available here.": "Código fonte disponível aqui.",
"View JavaScript license information.": "Ver informações da licença do JavaScript.",
"View privacy policy.": "Ver a política de privacidade",
"Trending": "Trending",
"Public": "Público",
"Unlisted": "No listado",
"Private": "Privado",
"View all playlists": "Mostrar todas listas de reprodução",
"Updated `x` ago": "Enviado `x` atrás",
"Delete playlist `x`?": "Apagar a playlist `x`?",
"Delete playlist": "Apagar playlist",
"Create playlist": "Criar playlist",
"Title": "Título",
"Playlist privacy": "Privacidade da playlist",
"Editing playlist `x`": "Editando playlist",
"Watch on YouTube": "Assistir vídeo no YouTube",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Gênero: ",
"License: ": "Licença: ",
"Family friendly? ": "Fistrar conteúdo impróprio: ",
"Wilson score: ": "Pontuação de Wilson: ",
"Engagement: ": "Engagement: ",
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Compartilhado `x`",
"`x` views": "`x` visualizações",
"Premieres in `x`": "Estreias em `x`",
"Premieres `x`": "Estreia `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.": "Oi! Parece que seu JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar um pouco mais de tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários do Reddit",
"View `x` comments": "Ver `x` comentários",
"View Reddit comments": "Ver comentários do Reddit",
"Hide replies": "Ocultar respostas",
"Show replies": "Mostrar respostas",
"Incorrect password": "Senha incorreta",
"Quota exceeded, try again in a few hours": "Cota excedida, tente novamente em algumas horas",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não foi possível fazer login, sua autenticação por dois passos (app autenticador ou sms) deve estar ativada.",
"Invalid TFA code": "Código TFA inválido",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Falha no login. Isso pode acontecer pois a autenticação por dois passos está desativada para sua conta.",
"Wrong answer": "Respuesta inválida",
"Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "O CAPTCHA é um campo obrigatório",
"User ID is a required field": "O nome de usuário é um campo obrigatório",
"Password is a required field": "A senha é um campo obrigatório",
"Wrong username or password": "Nome de usuário ou senha inválidos",
"Please sign in using 'Log in with Google'": "Por favor, entre usando 'Entrar com conta Google'",
"Password cannot be empty": "A senha não pode estar vazia",
"Password cannot be longer than 55 characters": "A senha não pode ter mais que 55 caracteres",
"Please log in": "Por favor, inicie sua seção",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal: `x`",
"Deleted or invalid channel": "Este canal foi apagado ou é inválido",
"This channel does not exist.": "Este canal não existe.",
"Could not get channel info.": "Não foi possível obter as informações do canal.",
"Could not fetch comments": "Não foi possível obter os comentários",
"View `x` replies": "Ver `x` respostas",
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
"`x` points": "`x` pontos",
"Could not create mix.": "Não foi possível criar o mix.",
"Empty playlist": "A lista de reprodução está vazia",
"Not a playlist.": "Lista de reprodução inválida.",
"Playlist does not exist.": "A lista de reprodução não existe.",
"Could not pull trending pages.": "Não foi possível oberter as páginas dos videos em alta.",
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é obrigatório",
"Erroneous challenge": "Desafío inválido",
"Erroneous token": "Símbolo inválido",
"No such user": "Usuario inválido",
"Token is expired, please try again": "Token expirou, tente novamente",
"English": "Inglês",
"English (auto-generated)": "Inglês (gerado automaticamente)",
"Afrikaans": "Africâner",
"Albanian": "Albanês",
"Amharic": "Amárico",
"Arabic": "Árabe",
"Armenian": "Armênio",
"Azerbaijani": "Azeri",
"Bangla": "Bengalês",
"Basque": "Basco",
"Belarusian": "Bielorrusso",
"Bosnian": "Língua Bósnia",
"Bulgarian": "Búlgaro",
"Burmese": "Birmanês",
"Catalan": "Catalão",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Chinês Simplificado",
"Chinese (Traditional)": "Chinês Tradicional",
"Corsican": "Corso",
"Croatian": "Croata",
"Czech": "Checo",
"Danish": "Dinamarquês",
"Dutch": "Holandês",
"Esperanto": "Esperanto",
"Estonian": "Estoniano",
"Filipino": "Filipino",
"Finnish": "Finlandês",
"French": "Francês",
"Galician": "Galego",
"Georgian": "Georgiano",
"German": "Alemão",
"Greek": "Grego",
"Gujarati": "Guzerate",
"Haitian Creole": "Crioulo Haitiano",
"Hausa": "Hauçá",
"Hawaiian": "Havaiano",
"Hebrew": "Hebraico",
"Hindi": "Hindi",
"Hmong": "Hmong",
"Hungarian": "Húngaro",
"Icelandic": "Islandês",
"Igbo": "Igbo",
"Indonesian": "Indonésio",
"Irish": "Irlandês",
"Italian": "Italiano",
"Japanese": "Japonês",
"Javanese": "Javanês",
"Kannada": "Canarẽs",
"Kazakh": "Cazaque",
"Khmer": "Khmer",
"Korean": "Coreano",
"Kurdish": "Curdo",
"Kyrgyz": "Quirguiz",
"Lao": "Laosiano",
"Latin": "Latim",
"Latvian": "Letão",
"Lithuanian": "Lituano",
"Luxembourgish": "Luxemburguês",
"Macedonian": "Macedônio",
"Malagasy": "Malgaxe",
"Malay": "Malaia",
"Malayalam": "Malaiala",
"Maltese": "Maltês",
"Maori": "Maori",
"Marathi": "Marathi",
"Mongolian": "Mongol",
"Nepali": "Nepalês",
"Norwegian Bokmål": "Bokmål Norueguês",
"Nyanja": "Nianja",
"Pashto": "Pachto",
"Persian": "Persa",
"Polish": "Polaco",
"Portuguese": "Português",
"Punjabi": "Panjábi",
"Romanian": "Língua Romena",
"Russian": "Russo",
"Samoan": "Samoano",
"Scottish Gaelic": "Ânglico Escocês",
"Serbian": "Língua Sérvia",
"Shona": "Xona",
"Sindhi": "Sindi",
"Sinhala": "Cingalês",
"Slovak": "Eslovaco",
"Slovenian": "Esloveno",
"Somali": "Língua Somalí",
"Southern Sotho": "Sesoto",
"Spanish": "Espanhol",
"Spanish (Latin America)": "Espanhol (América)",
"Sundanese": "Sondanese",
"Swahili": "Suaíli",
"Swedish": "Suéco",
"Tajik": "Tajiques",
"Tamil": "Tâmil",
"Telugu": "Telugo",
"Thai": "Tailandês",
"Turkish": "Turco",
"Ukrainian": "Ucraniano",
"Urdu": "Urdu",
"Uzbek": "Uzbeque",
"Vietnamese": "Vietnamita",
"Welsh": "Galês",
"Western Frisian": "Língua Frísia",
"Xhosa": "Xhosa",
"Yiddish": "Iídiche",
"Yoruba": "Iorubá",
"Zulu": "Língua Zulú",
"`x` years": "`x` anos",
"`x` months": "`x` meses",
"`x` weeks": "`x` semanas",
"`x` days": "`x` dias",
"`x` hours": "`x` horas",
"`x` minutes": "`x` minutos",
"`x` seconds": "`x` segundos",
"Fallback comments: ": "Comentários alternativos: ",
"Popular": "Populares",
"Top": "No topo",
"About": "Sobre",
"Rating: ": "Avaliação: ",
"Language: ": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
"Default": "Configuração padrão",
"Music": "Música",
"Gaming": "Video Games",
"News": "Notícias",
"Movies": "Filmes",
"Download": "Baixar",
"Download as: ": "Baixar como: ",
"%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Link permanente do comentário do YouTube",
"permalink": "Link permanente",
"`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de audio",
"Video mode": "Modo de video",
"Videos": "Vídeos",
"Playlists": "Listas de reprodução",
"Community": "Comunidade",
"Current version: ": "Versão atual: "
}

387
locales/pt-PT.json Normal file
View File

@@ -0,0 +1,387 @@
{
"`x` subscribers.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores.",
"": "`x` subscritores."
},
"`x` videos.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos.",
"": "`x` vídeos."
},
"`x` playlists.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução.",
"": "`x` listas de reprodução."
},
"LIVE": "Em direto",
"Shared `x` ago": "Partilhado `x` atrás",
"Unsubscribe": "Anular subscrição",
"Subscribe": "Subscrever",
"View channel on YouTube": "Ver canal no YouTube",
"View playlist on YouTube": "Ver lista de reprodução no YouTube",
"newest": "mais recentes",
"oldest": "mais antigos",
"popular": "popular",
"last": "últimos",
"Next page": "Próxima página",
"Previous page": "Página anterior",
"Clear watch history?": "Limpar histórico de reprodução?",
"New password": "Nova palavra-chave",
"New passwords must match": "As novas palavra-chaves devem corresponder",
"Cannot change password for Google accounts": "Não é possível alterar palavra-chave para contas do Google",
"Authorize token?": "Autorizar token?",
"Authorize token for `x`?": "Autorizar token para `x`?",
"Yes": "Sim",
"No": "Não",
"Import and Export Data": "Importar e Exportar Dados",
"Import": "Importar",
"Import Invidious data": "Importar dados do Invidious",
"Import YouTube subscriptions": "Importar subscrições do YouTube",
"Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)",
"Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)",
"Export": "Exportar",
"Export subscriptions as OPML": "Exportar subscrições como OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)",
"Export data as JSON": "Exportar dados como JSON",
"Delete account?": "Eliminar conta?",
"History": "Histórico",
"An alternative front-end to YouTube": "Uma interface alternativa para o YouTube",
"JavaScript license information": "Informação de licença do JavaScript",
"source": "código-fonte",
"Log in": "Iniciar sessão",
"Log in/register": "Iniciar sessão/Registar",
"Log in with Google": "Iniciar sessão com o Google",
"User ID": "Utilizador",
"Password": "Palavra-chave",
"Time (h:mm:ss):": "Tempo (h:mm:ss):",
"Text CAPTCHA": "Texto CAPTCHA",
"Image CAPTCHA": "Imagem CAPTCHA",
"Sign In": "Iniciar Sessão",
"Register": "Registar",
"E-mail": "E-mail",
"Google verification code": "Código de verificação do Google",
"Preferences": "Preferências",
"Player preferences": "Preferências do reprodutor",
"Always loop: ": "Repetir sempre: ",
"Autoplay: ": "Reprodução automática: ",
"Play next by default: ": "Sempre reproduzir próximo: ",
"Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ",
"Listen by default: ": "Apenas áudio: ",
"Proxy videos: ": "Usar proxy nos vídeos: ",
"Default speed: ": "Velocidade preferida: ",
"Preferred video quality: ": "Qualidade de vídeo preferida: ",
"Player volume: ": "Volume da reprodução: ",
"Default comments: ": "Preferência dos comentários: ",
"youtube": "youtube",
"reddit": "reddit",
"Default captions: ": "Legendas predefinidas: ",
"Fallback captions: ": "Legendas alternativas: ",
"Show related videos: ": "Mostrar vídeos relacionados: ",
"Show annotations by default: ": "Mostrar sempre anotações: ",
"Visual preferences": "Preferências visuais",
"Player style: ": "Estilo do reprodutor: ",
"Dark mode: ": "Modo escuro: ",
"Theme: ": "Tema: ",
"dark": "escuro",
"light": "claro",
"Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferências de subscrições",
"Show annotations by default for subscribed channels: ": "Mostrar sempre anotações para os canais subscritos: ",
"Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ",
"Number of videos shown in feed: ": "Número de vídeos nas subscrições: ",
"Sort videos by: ": "Ordenar vídeos por: ",
"published": "publicado",
"published - reverse": "publicado - inverso",
"alphabetically": "alfabeticamente",
"alphabetically - reverse": "alfabeticamente - inverso",
"channel name": "nome do canal",
"channel name - reverse": "nome do canal - inverso",
"Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ",
"Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ",
"Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ",
"Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ",
"Enable web notifications": "Ativar notificações pela web",
"`x` uploaded a video": "`x` publicou um novo vídeo",
"`x` is live": "`x` está em direto",
"Data preferences": "Preferências de dados",
"Clear watch history": "Limpar histórico de reprodução",
"Import/export data": "Importar/Exportar dados",
"Change password": "Alterar palavra-chave",
"Manage subscriptions": "Gerir as subscrições",
"Manage tokens": "Gerir tokens",
"Watch history": "Histórico de reprodução",
"Delete account": "Eliminar conta",
"Administrator preferences": "Preferências de administrador",
"Default homepage: ": "Página inicial padrão: ",
"Feed menu: ": "Menu de subscrições: ",
"Top enabled: ": "Top ativado: ",
"CAPTCHA enabled: ": "CAPTCHA ativado: ",
"Login enabled: ": "Iniciar sessão ativado: ",
"Registration enabled: ": "Registar ativado: ",
"Report statistics: ": "Relatório de estatísticas: ",
"Save preferences": "Gravar preferências",
"Subscription manager": "Gerir subscrições",
"Token manager": "Gerir tokens",
"Token": "Token",
"`x` subscriptions.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições.",
"": "`x` subscrições."
},
"`x` tokens.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens.",
"": "`x` tokens."
},
"Import/export": "Importar/Exportar",
"unsubscribe": "Anular subscrição",
"revoke": "revogar",
"Subscriptions": "Subscrições",
"`x` unseen notifications.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas.",
"": "`x` notificações não vistas."
},
"search": "Pesquisar",
"Log out": "Terminar sessão",
"Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.",
"Source available here.": "Código-fonte disponível aqui.",
"View JavaScript license information.": "Ver informações da licença do JavaScript.",
"View privacy policy.": "Ver a política de privacidade.",
"Trending": "Tendências",
"Public": "Público",
"Unlisted": "Não listado",
"Private": "Privado",
"View all playlists": "Ver todas as listas de reprodução",
"Updated `x` ago": "Atualizado `x` atrás",
"Delete playlist `x`?": "Eliminar a lista de reprodução 'x'?",
"Delete playlist": "Eliminar lista de reprodução",
"Create playlist": "Criar lista de reprodução",
"Title": "Título",
"Playlist privacy": "Privacidade da lista de reprodução",
"Editing playlist `x`": "A editar lista de reprodução 'x'",
"Watch on YouTube": "Ver no YouTube",
"Hide annotations": "Ocultar anotações",
"Show annotations": "Mostrar anotações",
"Genre: ": "Género: ",
"License: ": "Licença: ",
"Family friendly? ": "Filtrar conteúdo impróprio: ",
"Wilson score: ": "Pontuação de Wilson: ",
"Engagement: ": "Compromisso: ",
"Whitelisted regions: ": "Regiões permitidas: ",
"Blacklisted regions: ": "Regiões bloqueadas: ",
"Shared `x`": "Partilhado `x`",
"`x` views.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações.",
"": "`x` visualizações."
},
"Premieres in `x`": "Estreias em 'x'",
"Premieres `x`": "Estreias '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.": "Oi! Parece que JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.",
"View YouTube comments": "Ver comentários do YouTube",
"View more comments on Reddit": "Ver mais comentários no Reddit",
"View `x` comments.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários.",
"": "Ver `x` comentários."
},
"View Reddit comments": "Ver comentários do Reddit",
"Hide replies": "Ocultar respostas",
"Show replies": "Mostrar respostas",
"Incorrect password": "Palavra-chave incorreta",
"Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar sessão, certifique-se de que a autenticação de dois fatores (Autenticador ou SMS) está ativada.",
"Invalid TFA code": "Código TFA inválido",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a dois fatores de autenticação não está ativado para sua conta.",
"Wrong answer": "Resposta errada",
"Erroneous CAPTCHA": "CAPTCHA inválido",
"CAPTCHA is a required field": "CAPTCHA é um campo obrigatório",
"User ID is a required field": "O nome de utilizador é um campo obrigatório",
"Password is a required field": "Palavra-chave é um campo obrigatório",
"Wrong username or password": "Nome de utilizador ou palavra-chave incorreto",
"Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'",
"Password cannot be empty": "A palavra-chave não pode estar vazia",
"Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres",
"Please log in": "Por favor, inicie sessão",
"Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`",
"channel:`x`": "canal:'x'",
"Deleted or invalid channel": "Canal apagado ou inválido",
"This channel does not exist.": "Este canal não existe.",
"Could not get channel info.": "Não foi possível obter as informações do canal.",
"Could not fetch comments": "Não foi possível obter os comentários",
"View `x` replies.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas.",
"": "Ver `x` respostas."
},
"`x` ago": "`x` atrás",
"Load more": "Carregar mais",
"`x` points.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "'x' pontos.",
"": "'x' pontos."
},
"Could not create mix.": "Não foi possível criar mistura.",
"Empty playlist": "Lista de reprodução vazia",
"Not a playlist.": "Não é uma lista de reprodução.",
"Playlist does not exist.": "A lista de reprodução não existe.",
"Could not pull trending pages.": "Não foi possível obter páginas de tendências.",
"Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório",
"Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório",
"Erroneous challenge": "Desafio inválido",
"Erroneous token": "Token inválido",
"No such user": "Utilizador inválido",
"Token is expired, please try again": "Token expirou, tente novamente",
"English": "Inglês",
"English (auto-generated)": "Inglês (auto-gerado)",
"Afrikaans": "Africano",
"Albanian": "Albanês",
"Amharic": "Amárico",
"Arabic": "Árabe",
"Armenian": "Arménio",
"Azerbaijani": "Azerbaijano",
"Bangla": "Bangla",
"Basque": "Basco",
"Belarusian": "Bielorrusso",
"Bosnian": "Bósnio",
"Bulgarian": "Búlgaro",
"Burmese": "Birmanês",
"Catalan": "Catalão",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Chinês (Simplificado)",
"Chinese (Traditional)": "Chinês (Tradicional)",
"Corsican": "Corso",
"Croatian": "Croata",
"Czech": "Checo",
"Danish": "Dinamarquês",
"Dutch": "Holandês",
"Esperanto": "Esperanto",
"Estonian": "Estónio",
"Filipino": "Filipino",
"Finnish": "Finlandês",
"French": "Francês",
"Galician": "Galego",
"Georgian": "Georgiano",
"German": "Alemão",
"Greek": "Grego",
"Gujarati": "Guzerate",
"Haitian Creole": "Crioulo haitiano",
"Hausa": "Hauçá",
"Hawaiian": "Havaiano",
"Hebrew": "Hebraico",
"Hindi": "Hindi",
"Hmong": "Hmong",
"Hungarian": "Húngaro",
"Icelandic": "Islandês",
"Igbo": "Igbo",
"Indonesian": "Indonésio",
"Irish": "Irlandês",
"Italian": "Italiano",
"Japanese": "Japonês",
"Javanese": "Javanês",
"Kannada": "Canarim",
"Kazakh": "Cazaque",
"Khmer": "Khmer",
"Korean": "Coreano",
"Kurdish": "Curdo",
"Kyrgyz": "Quirguiz",
"Lao": "Laosiano",
"Latin": "Latim",
"Latvian": "Letão",
"Lithuanian": "Lituano",
"Luxembourgish": "Luxemburguês",
"Macedonian": "Macedónio",
"Malagasy": "Malgaxe",
"Malay": "Malaio",
"Malayalam": "Malaiala",
"Maltese": "Maltês",
"Maori": "Maori",
"Marathi": "Marathi",
"Mongolian": "Mongol",
"Nepali": "Nepalês",
"Norwegian Bokmål": "Bokmål norueguês",
"Nyanja": "Nyanja",
"Pashto": "Pashto",
"Persian": "Persa",
"Polish": "Polaco",
"Portuguese": "Português",
"Punjabi": "Punjabi",
"Romanian": "Romeno",
"Russian": "Russo",
"Samoan": "Samoano",
"Scottish Gaelic": "Gaélico escocês",
"Serbian": "Sérvio",
"Shona": "Shona",
"Sindhi": "Sindhi",
"Sinhala": "Cingalês",
"Slovak": "Eslovaco",
"Slovenian": "Esloveno",
"Somali": "Somali",
"Southern Sotho": "Sotho do Sul",
"Spanish": "Espanhol",
"Spanish (Latin America)": "Espanhol (América Latina)",
"Sundanese": "Sudanês",
"Swahili": "Suaíli",
"Swedish": "Sueco",
"Tajik": "Tajique",
"Tamil": "Tâmil",
"Telugu": "Telugu",
"Thai": "Tailandês",
"Turkish": "Turco",
"Ukrainian": "Ucraniano",
"Urdu": "Urdu",
"Uzbek": "Uzbeque",
"Vietnamese": "Vietnamita",
"Welsh": "Galês",
"Western Frisian": "Frísio Ocidental",
"Xhosa": "Xhosa",
"Yiddish": "Iídiche",
"Yoruba": "Ioruba",
"Zulu": "Zulu",
"`x` years.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos.",
"": "`x` anos."
},
"`x` months.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses.",
"": "`x` meses."
},
"`x` weeks.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas.",
"": "`x` semanas."
},
"`x` days.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias.",
"": "`x` dias."
},
"`x` hours.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas.",
"": "`x` horas."
},
"`x` minutes.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos.",
"": "`x` minutos."
},
"`x` seconds.": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos.",
"": "`x` segundos."
},
"Fallback comments: ": "Comentários alternativos: ",
"Popular": "Popular",
"Top": "Top",
"About": "Sobre",
"Rating: ": "Avaliação: ",
"Language: ": "Idioma: ",
"View as playlist": "Ver como lista de reprodução",
"Default": "Predefinição",
"Music": "Música",
"Gaming": "Jogos",
"News": "Notícias",
"Movies": "Filmes",
"Download": "Transferir",
"Download as: ": "Transferir como: ",
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Link permanente do comentário do YouTube",
"permalink": "ligação permanente",
"`x` marked it with a ❤": "`x` foi marcado como ❤",
"Audio mode": "Modo de áudio",
"Video mode": "Modo de vídeo",
"Videos": "Vídeos",
"Playlists": "Listas de reprodução",
"Community": "Comunidade",
"Current version: ": "Versão atual: "
}

336
locales/ro.json Normal file
View File

@@ -0,0 +1,336 @@
{
"`x` subscribers": "`x` abonați",
"`x` videos": "`x` videoclipuri",
"`x` playlists": "`x` liste de redare",
"LIVE": "ÎN DIRECT",
"Shared `x` ago": "Adăugat acum `x`",
"Unsubscribe": "Dezabonați-vă",
"Subscribe": "Abonați-vă",
"View channel on YouTube": "Vedeți canalul pe YouTube",
"View playlist on YouTube": "Vedeți lista de redare pe YouTube",
"newest": "Data adăugării (cea mai recentă)",
"oldest": "Data adăugării (cea mai veche)",
"popular": "Cele mai populare",
"last": "Ultimele",
"Next page": "Pagina următoare",
"Previous page": "Pagina precedentă",
"Clear watch history?": "Doriți să ștergeți istoricul?",
"New password": "Parola nouă",
"New passwords must match": "Câmpurile \"Parolă nouă\" trebuie să fie identice",
"Cannot change password for Google accounts": "Parola pentru un cont Google nu poate fi schimbată de pe Invidious",
"Authorize token?": "Autorizați token-ul?",
"Authorize token for `x`?": "Autorizați token-ul pentru `x` ?",
"Yes": "Da",
"No": "Nu",
"Import and Export Data": "Importați și Exportați Datele",
"Import": "Importați",
"Import Invidious data": "Importați Datele de pe Invidious",
"Import YouTube subscriptions": "Importați abonamentele de pe YouTube",
"Import FreeTube subscriptions (.db)": "Importați abonamentele de pe FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importați abonamentele de pe NewPipe (.json)",
"Import NewPipe data (.zip)": "Importați datele de pe NewPipe (.zip)",
"Export": "Exportați",
"Export subscriptions as OPML": "Exportați abonamentele în format OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportați abonamentele în format OPML (pentru NewPipe și FreeTube)",
"Export data as JSON": "Exportați datele în format JSON",
"Delete account?": "Sunteți siguri că doriți să vă ștergeți contul?",
"History": "Istoric",
"An alternative front-end to YouTube": "O alternativă front-end pentru YouTube",
"JavaScript license information": "Informații despre licențele JavaScript",
"source": "sursă",
"Log in": "Conectați-vă",
"Log in/register": "Conectați-vă/Creați-vă un cont",
"Log in with Google": "Conectați-vă cu Google",
"User ID": "ID Utilizator",
"Password": "Parolă",
"Time (h:mm:ss):": "Ora (h:mm:ss) :",
"Text CAPTCHA": "Text CAPTCHA",
"Image CAPTCHA": "Imagine CAPTCHA",
"Sign In": "Conectați-vă",
"Register": "Înregistrați-vă",
"E-mail": "E-mail",
"Google verification code": "Cod de verificare Google",
"Preferences": "Preferințe",
"Player preferences": "Setări de redare",
"Always loop: ": "Reluați videoclipul la nesfârșit: ",
"Autoplay: ": "Porniți videoclipurile automat: ",
"Play next by default: ": "Vizionați următoarele videoclipuri în mod implicit: ",
"Autoplay next video: ": "Porniți următorul videoclip automat: ",
"Listen by default: ": "Numai audio: ",
"Proxy videos: ": "Redați videoclipurile printr-un proxy: ",
"Default speed: ": "Viteza de redare implicită: ",
"Preferred video quality: ": "Calitatea videoclipurilor: ",
"Player volume: ": "Volumul videoclipurilor: ",
"Default comments: ": "Sursa comentariilor: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Default captions: ": "Subtitrări implicite: ",
"Fallback captions: ": "Subtitrări alternative: ",
"Show related videos: ": "Afișați videoclipurile asemănătoare: ",
"Show annotations by default: ": "Afișați adnotările în mod implicit: ",
"Visual preferences": "Preferințele site-ului",
"Player style: ": "Stilul player-ului : ",
"Dark mode: ": "Modul întunecat : ",
"Theme: ": "Tema : ",
"dark": "întunecat",
"light": "luminos",
"Thin mode: ": "Mod lejer: ",
"Subscription preferences": "Preferințele paginii de abonamente",
"Show annotations by default for subscribed channels: ": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ",
"Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ",
"Number of videos shown in feed: ": "Numărul de videoclipuri afișate pe pagina de abonamente: ",
"Sort videos by: ": "Sortați videoclipurile în funcție de: ",
"published": "data publicării",
"published - reverse": "data publicării - inversată",
"alphabetically": "în ordine alfabetică",
"alphabetically - reverse": "în ordine alfabetică - inversată",
"channel name": "numele canalului",
"channel name - reverse": "numele canalului - inversat",
"Only show latest video from channel: ": "Afișați numai cel mai recent videoclip publicat de canalele la care v-ați abonat: ",
"Only show latest unwatched video from channel: ": "Afișați numai cel mai recent videoclip nevizionat publicat de canalele la care v-ați abonat: ",
"Only show unwatched: ": "Afișați numai videoclipurile nevizionate: ",
"Only show notifications (if there are any): ": "Afișați numai notificările (dacă există): ",
"Enable web notifications": "Activați notificările web",
"`x` uploaded a video": "`x` a publicat un videoclip",
"`x` is live": "`x` este în direct",
"Data preferences": "Preferințe legate de date",
"Clear watch history": "Ștergeți istoricul videoclipurilor vizionate",
"Import/export data": "Importați/exportați datele",
"Change password": "Schimbați parola",
"Manage subscriptions": "Gestionați abonamentele",
"Manage tokens": "Gestionați tokenele",
"Watch history": "Istoricul videoclipurilor vizionate",
"Delete account": "Ștergeți contul",
"Administrator preferences": "Preferințele Administratorului",
"Default homepage: ": "Pagina principală implicită: ",
"Feed menu: ": "Preferințe legate de pagina de abonamente: ",
"Top enabled: ": "Top activat: ",
"CAPTCHA enabled: ": "CAPTCHA activat : ",
"Login enabled: ": "Autentificare activată : ",
"Registration enabled: ": "Înregistrate activată: ",
"Report statistics: ": "Raportarea statisticilor: ",
"Save preferences": "Salvați preferințele",
"Subscription manager": "Gestionați abonamentele",
"Token manager": "Manager de Tokene",
"Token": "Token",
"`x` subscriptions": "`x` abonamente",
"`x` tokens": "`x` tokens",
"Import/export": "Importați/Exportați",
"unsubscribe": "dezabonați-vă",
"revoke": "revocați",
"Subscriptions": "Abonamente",
"`x` unseen notifications": "`x` notificări nevăzute",
"search": "căutați",
"Log out": "Deconectați-vă",
"Released under the AGPLv3 by Omar Roth.": "Publicat sub licența AGPLv3 de Omar Roth.",
"Source available here.": "Codul sursă este disponibil aici.",
"View JavaScript license information.": "Informații legate de licența JavaScript.",
"View privacy policy.": "Politica de confidențialitate.",
"Trending": "Tendințe",
"Public": "Public",
"Unlisted": "Necatalogat",
"Private": "Privat",
"View all playlists": "Afișați toate listele de redare",
"Updated `x` ago": "Actualizat acum `x`",
"Delete playlist `x`?": "Sigur doriți să ștergeți lista de redare?",
"Delete playlist": "Ștergeți lista de redare",
"Create playlist": "Creați o listă de redare",
"Title": "Titlu",
"Playlist privacy": "Parametrii de confidențialitate ai listei de redare",
"Editing playlist `x`": "Modificați lista de redare `x`",
"Watch on YouTube": "Urmăriți videoclipul pe YouTube",
"Hide annotations": "Ascundeți adnotările",
"Show annotations": "Afișați adnotările",
"Genre: ": "Categorie: ",
"License: ": "Licență: ",
"Family friendly? ": "Adecvat pentru întreaga familie? ",
"Wilson score: ": "Scor Wilson: ",
"Engagement: ": "Procentul celor care au apăsat pe \"Îmi place\" sau \"Nu îmi place\" : ",
"Whitelisted regions: ": "Regiunile de pe lista albă: ",
"Blacklisted regions: ": "Regiunile de pe lista neagră: ",
"Shared `x`": "Publicat pe `x`",
"`x` views": "`x` vizionări",
"Premieres in `x`": "Premiera în `x`",
"Premieres `x`": "Premiera pe `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.": "Se pare că ați dezactivat JavaScript. Apăsați aici pentru a vizualiza comentariile. Țineți minte faptul că încărcarea lor ar putea să dureze puțin mai mult.",
"View YouTube comments": "Vedeți comentariile de pe YouTube",
"View more comments on Reddit": "Vedeți mai multe comentarii pe Reddit",
"View `x` comments": "Afișați `x` comentarii",
"View Reddit comments": "Afișați comentariile de pe Reddit",
"Hide replies": "Ascundeți replicile",
"Show replies": "Afișați replicile",
"Incorrect password": "Parolă incorectă",
"Quota exceeded, try again in a few hours": "Numărul de tentative de conectare a fost depășit. Va rugăm să încercați din nou în câteva ore.",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Conectare eșuată. Dacă nu reușiți să vă conectați, verificați dacă ați activat autentificarea cu doi factori (Autentificator sau SMS).",
"Invalid TFA code": "Codul de autentificare cu doi factori este invalid",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Conectare eșuată. Acest lucru ar putea fi cauzat de faptul că nu ați activat autentificarea cu doi factori.",
"Wrong answer": "Răspuns invalid",
"Erroneous CAPTCHA": "CAPTCHA invalid",
"CAPTCHA is a required field": "Câmpul CAPTCHA este obligatoriu",
"User ID is a required field": "Câmpul ID Utilizator este obligatoriu",
"Password is a required field": "Câmpul Parolă este obligatoriu",
"Wrong username or password": "Nume de utilizator sau parolă invalidă",
"Please sign in using 'Log in with Google'": "Vă rog conectați-vă folosind \"Conectați-vă cu Google\"",
"Password cannot be empty": "Parola nu poate fi goală",
"Password cannot be longer than 55 characters": "Parola nu poate să conțină mai mult de 55 de caractere",
"Please log in": "Vă rog conectați-vă",
"Invidious Private Feed for `x`": "Feed RSS privat pentru `x`",
"channel:`x`": "canal:`x`",
"Deleted or invalid channel": "Canal șters sau invalid",
"This channel does not exist.": "Acest canal nu există.",
"Could not get channel info.": "Nu am putut primi informații despre acest canal.",
"Could not fetch comments": "Încărcarea comentariilor a eșuat.",
"View `x` replies": "Afișați `x` replici",
"`x` ago": "acum `x`",
"Load more": "Vedeți mai mult",
"`x` points": "`x` puncte",
"Could not create mix.": "Nu am putut crea această listă de redare.",
"Empty playlist": "Lista de redare este goală",
"Not a playlist.": "Lista de redare este invalidă.",
"Playlist does not exist.": "Această listă de redare nu există.",
"Could not pull trending pages.": "Încărcarea paginilor de tendințe a eșuat.",
"Hidden field \"challenge\" is a required field": "Câmpul ascuns \"challenge\" este un câmp obligatoriu",
"Hidden field \"token\" is a required field": "Câmpul ascuns \"token\" este un câmp obligatoriu",
"Erroneous challenge": "Challenge invalid",
"Erroneous token": "Token invalid",
"No such user": "Acest utilizator nu există",
"Token is expired, please try again": "Token-ul este expirat, vă rugăm să reîncercați.",
"English": "Engleză",
"English (auto-generated)": "Engleză (generată automat)",
"Afrikaans": "Afrikaans",
"Albanian": "Albaneză",
"Amharic": "Amharică",
"Arabic": "Arabă",
"Armenian": "Arméniană",
"Azerbaijani": "Azeră",
"Bangla": "Bangla",
"Basque": "Basque",
"Belarusian": "Belarusă",
"Bosnian": "Bosniacă",
"Bulgarian": "Bulgară",
"Burmese": "Birmană",
"Catalan": "Catalană",
"Cebuano": "Cebuano",
"Chinese (Simplified)": "Chineză (Simplificată)",
"Chinese (Traditional)": "Chinois (Tradițională)",
"Corsican": "Corsicană",
"Croatian": "Croată",
"Czech": "Cehă",
"Danish": "Daneză",
"Dutch": "Olandeză",
"Esperanto": "Esperanto",
"Estonian": "Estoniană",
"Filipino": "Filipineză",
"Finnish": "Finlandeză",
"French": "Franceză",
"Galician": "Galiciană",
"Georgian": "Georgiană",
"German": "Germană",
"Greek": "Greacă",
"Gujarati": "Gujarati",
"Haitian Creole": "Creola Haitiană",
"Hausa": "Haousa",
"Hawaiian": "Hawaiană",
"Hebrew": "Ebraică",
"Hindi": "Hindi",
"Hmong": "Hmong",
"Hungarian": "Ungară",
"Icelandic": "Islandeză",
"Igbo": "Igbo",
"Indonesian": "Indoneziană",
"Irish": "Irlandeză",
"Italian": "Italiană",
"Japanese": "Japoneză",
"Javanese": "Javaneză",
"Kannada": "Kannada",
"Kazakh": "Kazakh",
"Khmer": "Khmer",
"Korean": "Coreană",
"Kurdish": "Kurdă",
"Kyrgyz": "Kirghize",
"Lao": "Lao",
"Latin": "Latină",
"Latvian": "Letonă",
"Lithuanian": "Lituaniană",
"Luxembourgish": "Luxemburgheză",
"Macedonian": "Macedoniană",
"Malagasy": "Malgașă",
"Malay": "Malaieză",
"Malayalam": "Malayalam",
"Maltese": "Malteză",
"Maori": "Maori",
"Marathi": "Marathi",
"Mongolian": "Mongoliană",
"Nepali": "Nepaleză",
"Norwegian Bokmål": "Norvegiană",
"Nyanja": "Nyanja",
"Pashto": "Pachtou",
"Persian": "Persană",
"Polish": "Poloneză",
"Portuguese": "Portugheză",
"Punjabi": "Punjabi",
"Romanian": "Română",
"Russian": "Rusă",
"Samoan": "Samoan",
"Scottish Gaelic": "Galic Scoțian",
"Serbian": "Sârbă",
"Shona": "Shona",
"Sindhi": "Sindhi",
"Sinhala": "Sinhala",
"Slovak": "Slovacă",
"Slovenian": "Slovenă",
"Somali": "Somaleză",
"Southern Sotho": "Sotho de Sud",
"Spanish": "Spaniolă",
"Spanish (Latin America)": "Spaniolă (America Latină)",
"Sundanese": "Sundaneză",
"Swahili": "Swahili",
"Swedish": "Suedeză",
"Tajik": "Tajik",
"Tamil": "Tamil",
"Telugu": "Telugu",
"Thai": "Tailandeză",
"Turkish": "Turcă",
"Ukrainian": "Ucrainiană",
"Urdu": "Urdu",
"Uzbek": "Uzbek",
"Vietnamese": "Vietnameză",
"Welsh": "Galeză",
"Western Frisian": "Frisiană de Vest",
"Xhosa": "Xhosa",
"Yiddish": "Yiddish",
"Yoruba": "Yoruba",
"Zulu": "Zoulou",
"`x` years": "`x` ani",
"`x` months": "`x` luni",
"`x` weeks": "`x` săptămâni",
"`x` days": "`x` zile",
"`x` hours": "`x` ore",
"`x` minutes": "`x` minute",
"`x` seconds": "`x` secunde",
"Fallback comments: ": "Comentarii alternative: ",
"Popular": "Popular",
"Top": "Top",
"About": "Despre",
"Rating: ": "Evaluare: ",
"Language: ": "Limbă: ",
"View as playlist": "Vizualizați ca listă de redare",
"Default": "Implicit",
"Music": "Muzică",
"Gaming": "Jocuri Video",
"News": "Noutăți",
"Movies": "Filme",
"Download": "Descărcați",
"Download as: ": "Descărcați ca: ",
"%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(editat)",
"YouTube comment permalink": "Permalink pentru comentariul de pe YouTube",
"permalink": "permalink",
"`x` marked it with a ❤": "`x` l-a marcat cu o ❤",
"Audio mode": "Mod audio",
"Video mode": "Mod video",
"Videos": "Videoclipuri",
"Playlists": "Liste de redare",
"Community": "Comunitate",
"Current version: ": "Versiunea actuală: "
}

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` подписчиков", "`x` subscribers": "`x` подписчиков",
"`x` videos": "`x` видео", "`x` videos": "`x` видео",
"`x` playlists": "", "`x` playlists": "`x` плейлистов",
"LIVE": "ПРЯМОЙ ЭФИР", "LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад", "Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться", "Unsubscribe": "Отписаться",
@@ -69,11 +69,11 @@
"Show related videos: ": "Показывать похожие видео? ", "Show related videos: ": "Показывать похожие видео? ",
"Show annotations by default: ": "Всегда показывать аннотации? ", "Show annotations by default: ": "Всегда показывать аннотации? ",
"Visual preferences": "Настройки сайта", "Visual preferences": "Настройки сайта",
"Player style: ": "", "Player style: ": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ", "Dark mode: ": "Тёмное оформление: ",
"Theme: ": "", "Theme: ": "Тема: ",
"dark": "", "dark": "темная",
"light": "", "light": "светлая",
"Thin mode: ": "Облегчённое оформление: ", "Thin mode: ": "Облегчённое оформление: ",
"Subscription preferences": "Настройки подписок", "Subscription preferences": "Настройки подписок",
"Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.",
"View privacy policy.": "Посмотреть политику конфиденциальности.", "View privacy policy.": "Посмотреть политику конфиденциальности.",
"Trending": "В тренде", "Trending": "В тренде",
"Public": "", "Public": "Публичный",
"Unlisted": "Нет в списке", "Unlisted": "Нет в списке",
"Private": "", "Private": "Приватный",
"View all playlists": "", "View all playlists": "Посмотреть все плейлисты",
"Updated `x` ago": "", "Updated `x` ago": "Обновлено `x` назад",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Удалить плейлист `x`?",
"Delete playlist": "", "Delete playlist": "Удалить плейлист",
"Create playlist": "", "Create playlist": "Создать плейлист",
"Title": "", "Title": "Заголовок",
"Playlist privacy": "", "Playlist privacy": "Конфиденциальность плейлиста",
"Editing playlist `x`": "", "Editing playlist `x`": "Редактирование плейлиста `x`",
"Watch on YouTube": "Смотреть на YouTube", "Watch on YouTube": "Смотреть на YouTube",
"Hide annotations": "Скрыть аннотации", "Hide annotations": "Скрыть аннотации",
"Show annotations": "Показать аннотации", "Show annotations": "Показать аннотации",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%-d %B %Y, %A", "%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(изменено)", "(edited)": "(изменено)",
"YouTube comment permalink": "Прямая ссылка на YouTube", "YouTube comment permalink": "Прямая ссылка на YouTube",
"permalink": "", "permalink": "постоянная ссылка",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим", "Audio mode": "Аудио режим",
"Video mode": "Видео режим", "Video mode": "Видео режим",
"Videos": "Видео", "Videos": "Видео",
"Playlists": "Плейлисты", "Playlists": "Плейлисты",
"Community": "", "Community": "Сообщество",
"Current version: ": "Текущая версия: " "Current version: ": "Текущая версия: "
} }

336
locales/sr_Cyrl.json Normal file
View File

@@ -0,0 +1,336 @@
{
"`x` subscribers.": "",
"`x` videos.": "",
"`x` playlists.": "",
"LIVE": "",
"Shared `x` ago": "",
"Unsubscribe": "",
"Subscribe": "Пратите",
"View channel on YouTube": "Погледајте канал на YouTube-у",
"View playlist on YouTube": "Погледајте плејлисту на YouTube-у",
"newest": "",
"oldest": "",
"popular": "",
"last": "",
"Next page": "",
"Previous page": "",
"Clear watch history?": "",
"New password": "",
"New passwords must match": "",
"Cannot change password for Google accounts": "",
"Authorize token?": "",
"Authorize token for `x`?": "",
"Yes": "",
"No": "",
"Import and Export Data": "",
"Import": "",
"Import Invidious data": "",
"Import YouTube subscriptions": "",
"Import FreeTube subscriptions (.db)": "",
"Import NewPipe subscriptions (.json)": "",
"Import NewPipe data (.zip)": "",
"Export": "",
"Export subscriptions as OPML": "",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "",
"Export data as JSON": "",
"Delete account?": "",
"History": "",
"An alternative front-end to YouTube": "",
"JavaScript license information": "",
"source": "",
"Log in": "",
"Log in/register": "",
"Log in with Google": "",
"User ID": "",
"Password": "",
"Time (h:mm:ss):": "",
"Text CAPTCHA": "",
"Image CAPTCHA": "",
"Sign In": "",
"Register": "",
"E-mail": "",
"Google verification code": "",
"Preferences": "",
"Player preferences": "",
"Always loop: ": "",
"Autoplay: ": "",
"Play next by default: ": "",
"Autoplay next video: ": "",
"Listen by default: ": "",
"Proxy videos: ": "",
"Default speed: ": "",
"Preferred video quality: ": "",
"Player volume: ": "",
"Default comments: ": "",
"youtube": "",
"reddit": "",
"Default captions: ": "",
"Fallback captions: ": "",
"Show related videos: ": "",
"Show annotations by default: ": "",
"Visual preferences": "",
"Player style: ": "",
"Dark mode: ": "",
"Theme: ": "",
"dark": "",
"light": "",
"Thin mode: ": "",
"Subscription preferences": "",
"Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "",
"Number of videos shown in feed: ": "",
"Sort videos by: ": "",
"published": "",
"published - reverse": "",
"alphabetically": "",
"alphabetically - reverse": "",
"channel name": "",
"channel name - reverse": "",
"Only show latest video from channel: ": "",
"Only show latest unwatched video from channel: ": "",
"Only show unwatched: ": "",
"Only show notifications (if there are any): ": "",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "",
"Clear watch history": "",
"Import/export data": "",
"Change password": "",
"Manage subscriptions": "",
"Manage tokens": "",
"Watch history": "",
"Delete account": "",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled: ": "",
"CAPTCHA enabled: ": "",
"Login enabled: ": "",
"Registration enabled: ": "",
"Report statistics: ": "",
"Save preferences": "",
"Subscription manager": "",
"Token manager": "",
"Token": "",
"`x` subscriptions.": "",
"`x` tokens.": "",
"Import/export": "",
"unsubscribe": "",
"revoke": "",
"Subscriptions": "",
"`x` unseen notifications.": "",
"search": "",
"Log out": "",
"Released under the AGPLv3 by Omar Roth.": "",
"Source available here.": "",
"View JavaScript license information.": "",
"View privacy policy.": "",
"Trending": "",
"Public": "",
"Unlisted": "",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Watch on YouTube": "",
"Hide annotations": "",
"Show annotations": "",
"Genre: ": "",
"License: ": "",
"Family friendly? ": "",
"Wilson score: ": "",
"Engagement: ": "",
"Whitelisted regions: ": "",
"Blacklisted regions: ": "",
"Shared `x`": "",
"`x` views.": "",
"Premieres in `x`": "",
"Premieres `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": "",
"View more comments on Reddit": "",
"View `x` comments.": "",
"View Reddit comments": "",
"Hide replies": "",
"Show replies": "",
"Incorrect password": "",
"Quota exceeded, try again in a few hours": "",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "",
"Invalid TFA code": "",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "",
"Wrong answer": "",
"Erroneous CAPTCHA": "",
"CAPTCHA is a required field": "",
"User ID is a required field": "",
"Password is a required field": "",
"Wrong username or password": "",
"Please sign in using 'Log in with Google'": "",
"Password cannot be empty": "",
"Password cannot be longer than 55 characters": "",
"Please log in": "",
"Invidious Private Feed for `x`": "",
"channel:`x`": "",
"Deleted or invalid channel": "",
"This channel does not exist.": "",
"Could not get channel info.": "",
"Could not fetch comments": "",
"View `x` replies.": "",
"`x` ago": "",
"Load more": "",
"`x` points.": "",
"Could not create mix.": "",
"Empty playlist": "",
"Not a playlist.": "",
"Playlist does not exist.": "",
"Could not pull trending pages.": "",
"Hidden field \"challenge\" is a required field": "",
"Hidden field \"token\" is a required field": "",
"Erroneous challenge": "",
"Erroneous token": "",
"No such user": "",
"Token is expired, please try again": "",
"English": "",
"English (auto-generated)": "",
"Afrikaans": "",
"Albanian": "",
"Amharic": "",
"Arabic": "",
"Armenian": "",
"Azerbaijani": "",
"Bangla": "",
"Basque": "",
"Belarusian": "",
"Bosnian": "",
"Bulgarian": "",
"Burmese": "",
"Catalan": "",
"Cebuano": "",
"Chinese (Simplified)": "",
"Chinese (Traditional)": "",
"Corsican": "",
"Croatian": "",
"Czech": "",
"Danish": "",
"Dutch": "",
"Esperanto": "",
"Estonian": "",
"Filipino": "",
"Finnish": "",
"French": "",
"Galician": "",
"Georgian": "",
"German": "",
"Greek": "",
"Gujarati": "",
"Haitian Creole": "",
"Hausa": "",
"Hawaiian": "",
"Hebrew": "",
"Hindi": "",
"Hmong": "",
"Hungarian": "",
"Icelandic": "",
"Igbo": "",
"Indonesian": "",
"Irish": "",
"Italian": "",
"Japanese": "",
"Javanese": "",
"Kannada": "",
"Kazakh": "",
"Khmer": "",
"Korean": "",
"Kurdish": "",
"Kyrgyz": "",
"Lao": "",
"Latin": "",
"Latvian": "",
"Lithuanian": "",
"Luxembourgish": "",
"Macedonian": "",
"Malagasy": "",
"Malay": "",
"Malayalam": "",
"Maltese": "",
"Maori": "",
"Marathi": "",
"Mongolian": "",
"Nepali": "",
"Norwegian Bokmål": "",
"Nyanja": "",
"Pashto": "",
"Persian": "",
"Polish": "",
"Portuguese": "",
"Punjabi": "",
"Romanian": "",
"Russian": "",
"Samoan": "",
"Scottish Gaelic": "",
"Serbian": "",
"Shona": "",
"Sindhi": "",
"Sinhala": "",
"Slovak": "",
"Slovenian": "",
"Somali": "",
"Southern Sotho": "",
"Spanish": "",
"Spanish (Latin America)": "",
"Sundanese": "",
"Swahili": "",
"Swedish": "",
"Tajik": "",
"Tamil": "",
"Telugu": "",
"Thai": "",
"Turkish": "",
"Ukrainian": "",
"Urdu": "",
"Uzbek": "",
"Vietnamese": "",
"Welsh": "",
"Western Frisian": "",
"Xhosa": "",
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
"`x` years.": "",
"`x` months.": "",
"`x` weeks.": "",
"`x` days.": "",
"`x` hours.": "",
"`x` minutes.": "",
"`x` seconds.": "",
"Fallback comments: ": "",
"Popular": "",
"Top": "",
"About": "",
"Rating: ": "",
"Language: ": "",
"View as playlist": "",
"Default": "",
"Music": "",
"Gaming": "",
"News": "",
"Movies": "",
"Download": "",
"Download as: ": "",
"%A %B %-d, %Y": "",
"(edited)": "",
"YouTube comment permalink": "",
"permalink": "",
"`x` marked it with a ❤": "",
"Audio mode": "",
"Video mode": "",
"Videos": "",
"Playlists": "",
"Community": "",
"Current version: ": "Тренутна верзија: "
}

336
locales/sv-SE.json Normal file
View File

@@ -0,0 +1,336 @@
{
"`x` subscribers": "`x` prenumeranter",
"`x` videos": "`x` videor",
"`x` playlists": "`x` spellistor",
"LIVE": "LIVE",
"Shared `x` ago": "Delad `x` sedan",
"Unsubscribe": "Avprenumerera",
"Subscribe": "Prenumerera",
"View channel on YouTube": "Visa kanalen på YouTube",
"View playlist on YouTube": "Visa spellistan på YouTube",
"newest": "nyaste",
"oldest": "äldsta",
"popular": "populärt",
"last": "sista",
"Next page": "Nästa sida",
"Previous page": "Tidigare sida",
"Clear watch history?": "Töm visningshistorik?",
"New password": "Nytt lösenord",
"New passwords must match": "Nya lösenord måste stämma överens",
"Cannot change password for Google accounts": "Kan inte ändra lösenord på Google-konton",
"Authorize token?": "Auktorisera åtkomsttoken?",
"Authorize token for `x`?": "Auktorisera åtkomsttoken för `x`?",
"Yes": "Ja",
"No": "Nej",
"Import and Export Data": "Importera och exportera data",
"Import": "Importera",
"Import Invidious data": "Importera Invidious-data",
"Import YouTube subscriptions": "Importera YouTube-prenumerationer",
"Import FreeTube subscriptions (.db)": "Importera FreeTube-prenumerationer (.db)",
"Import NewPipe subscriptions (.json)": "Importera NewPipe-prenumerationer (.json)",
"Import NewPipe data (.zip)": "Importera NewPipe-data (.zip)",
"Export": "Exportera",
"Export subscriptions as OPML": "Exportera prenumerationer som OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportera prenumerationer som OPML (för NewPipe och FreeTube)",
"Export data as JSON": "Exportera data som JSON",
"Delete account?": "Radera konto?",
"History": "Historik",
"An alternative front-end to YouTube": "Ett alternativt gränssnitt till YouTube",
"JavaScript license information": "JavaScript-licensinformation",
"source": "källa",
"Log in": "Logga in",
"Log in/register": "Logga in/registrera",
"Log in with Google": "Logga in med Google",
"User ID": "Användar-ID",
"Password": "Lösenord",
"Time (h:mm:ss):": "Tid (h:mm:ss):",
"Text CAPTCHA": "Text-CAPTCHA",
"Image CAPTCHA": "Bild-CAPTCHA",
"Sign In": "Inloggning",
"Register": "Registrera",
"E-mail": "E-post",
"Google verification code": "Google-bekräftelsekod",
"Preferences": "Inställningar",
"Player preferences": "Spelarinställningar",
"Always loop: ": "Loopa alltid: ",
"Autoplay: ": "Autouppspelning: ",
"Play next by default: ": "Spela nästa som förval: ",
"Autoplay next video: ": "Autouppspela nästa video: ",
"Listen by default: ": "Lyssna som förval: ",
"Proxy videos: ": "Proxy:a videor: ",
"Default speed: ": "Förvald hastighet: ",
"Preferred video quality: ": "Föredragen videokvalitet: ",
"Player volume: ": "Volym: ",
"Default comments: ": "Förvalda kommentarer: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Default captions: ": "Förvalda undertexter: ",
"Fallback captions: ": "Ersättningsundertexter: ",
"Show related videos: ": "Visa relaterade videor? ",
"Show annotations by default: ": "Visa länkar-i-videon som förval? ",
"Visual preferences": "Visuella inställningar",
"Player style: ": "Spelarstil: ",
"Dark mode: ": "Mörkt läge: ",
"Theme: ": "Tema: ",
"dark": "Mörkt",
"light": "Ljust",
"Thin mode: ": "Lättviktigt läge: ",
"Subscription preferences": "Prenumerationsinställningar",
"Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ",
"Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ",
"Number of videos shown in feed: ": "Antal videor att visa i flödet: ",
"Sort videos by: ": "Sortera videor: ",
"published": "publicering",
"published - reverse": "publicering - omvänd",
"alphabetically": "alfabetiskt",
"alphabetically - reverse": "alfabetiskt - omvänd",
"channel name": "kanalnamn",
"channel name - reverse": "kanalnamn - omvänd",
"Only show latest video from channel: ": "Visa bara senaste videon från kanal: ",
"Only show latest unwatched video from channel: ": "Visa bara senaste osedda videon från kanal: ",
"Only show unwatched: ": "Visa bara osedda: ",
"Only show notifications (if there are any): ": "Visa endast aviseringar (om det finns några): ",
"Enable web notifications": "Slå på aviseringar",
"`x` uploaded a video": "`x` laddade upp en video",
"`x` is live": "`x` sänder live",
"Data preferences": "Datainställningar",
"Clear watch history": "Töm visningshistorik",
"Import/export data": "Importera/Exportera data",
"Change password": "Byt lösenord",
"Manage subscriptions": "Hantera prenumerationer",
"Manage tokens": "Hantera åtkomst-tokens",
"Watch history": "Visningshistorik",
"Delete account": "Radera konto",
"Administrator preferences": "Administratörsinställningar",
"Default homepage: ": "Förvald hemsida: ",
"Feed menu: ": "Flödesmeny: ",
"Top enabled: ": "Topp påslaget? ",
"CAPTCHA enabled: ": "CAPTCHA påslaget? ",
"Login enabled: ": "Inloggning påslaget? ",
"Registration enabled: ": "Registrering påslaget? ",
"Report statistics: ": "Rapportera in statistik? ",
"Save preferences": "Spara inställningar",
"Subscription manager": "Prenumerationshanterare",
"Token manager": "Åtkomst-token-hanterare",
"Token": "Åtkomst-token",
"`x` subscriptions": "`x` prenumerationer",
"`x` tokens": "`x` åtkomst-token",
"Import/export": "Importera/exportera",
"unsubscribe": "avprenumerera",
"revoke": "återkalla",
"Subscriptions": "Prenumerationer",
"`x` unseen notifications": "`x` osedda aviseringar",
"search": "sök",
"Log out": "Logga ut",
"Released under the AGPLv3 by Omar Roth.": "Utgiven under AGPLv3-licens av Omar Roth.",
"Source available here.": "Källkod tillgänglig här.",
"View JavaScript license information.": "Visa JavaScript-licensinformation.",
"View privacy policy.": "Visa privatlivspolicy.",
"Trending": "Trendar",
"Public": "Offentlig",
"Unlisted": "Olistad",
"Private": "Privat",
"View all playlists": "Visa alla spellistor",
"Updated `x` ago": "Uppdaterad `x` sedan",
"Delete playlist `x`?": "Radera spellistan `x`?",
"Delete playlist": "Radera spellista",
"Create playlist": "Skapa spellista",
"Title": "Titel",
"Playlist privacy": "Privatläge på spellista",
"Editing playlist `x`": "Redigerer spellistan `x`",
"Watch on YouTube": "Titta på YouTube",
"Hide annotations": "Dölj länkar-i-video",
"Show annotations": "Visa länkar-i-video",
"Genre: ": "Genre: ",
"License: ": "Licens: ",
"Family friendly? ": "Familjevänlig? ",
"Wilson score: ": "Wilson-poängsumma: ",
"Engagement: ": "Engagement: ",
"Whitelisted regions: ": "Vitlistade regioner: ",
"Blacklisted regions: ": "Svartlistade regioner: ",
"Shared `x`": "Delade `x`",
"`x` views": "`x` visningar",
"Premieres in `x`": "Premiär om `x`",
"Premieres `x`": "Premiär av `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.": "Hej. Det ser ut som att du har JavaScript avstängt. Klicka här för att visa kommentarer, ha i åtanke att nedladdning tar längre tid.",
"View YouTube comments": "Visa YouTube-kommentarer",
"View more comments on Reddit": "Visa flera kommentarer på Reddit",
"View `x` comments": "Visa `x` kommentarer",
"View Reddit comments": "Visa Reddit-kommentarer",
"Hide replies": "Dölj svar",
"Show replies": "Visa svar",
"Incorrect password": "Fel lösenord",
"Quota exceeded, try again in a few hours": "Kvoten överskriden, försök igen om ett par timmar",
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kunde inte logga in, försäkra dig om att tvåfaktors-autentisering (Authenticator eller SMS) är påslagen.",
"Invalid TFA code": "Ogiltig tvåfaktor-kod",
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Inloggning misslyckades. Detta kan vara för att tvåfaktors-autentisering inte är påslaget på ditt konto.",
"Wrong answer": "Fel svar",
"Erroneous CAPTCHA": "Ogiltig CAPTCHA",
"CAPTCHA is a required field": "CAPTCHA är ett obligatoriskt fält",
"User ID is a required field": "Användar-ID är ett obligatoriskt fält",
"Password is a required field": "Lösenord är ett obligatoriskt fält",
"Wrong username or password": "Ogiltigt användarnamn eller lösenord",
"Please sign in using 'Log in with Google'": "Logga in genom \"Google-inloggning\"",
"Password cannot be empty": "Lösenordet kan inte vara tomt",
"Password cannot be longer than 55 characters": "Lösenordet kan inte vara längre än 55 tecken",
"Please log in": "Logga in",
"Invidious Private Feed for `x`": "Ogiltig privat flöde för `x`",
"channel:`x`": "kanal `x`",
"Deleted or invalid channel": "Raderad eller ogiltig kanal",
"This channel does not exist.": "Denna kanal finns inte.",
"Could not get channel info.": "Kunde inte hämta kanalinfo.",
"Could not fetch comments": "Kunde inte hämta kommentarer",
"View `x` replies": "Visa `x` svar",
"`x` ago": "`x` sedan",
"Load more": "Ladda fler",
"`x` points": "`x` poäng",
"Could not create mix.": "Kunde inte skapa mix.",
"Empty playlist": "Spellistan är tom",
"Not a playlist.": "Ogiltig spellista.",
"Playlist does not exist.": "Spellistan finns inte.",
"Could not pull trending pages.": "Kunde inte hämta trendande sidor.",
"Hidden field \"challenge\" is a required field": "Dolt fält \"challenge\" är ett obligatoriskt fält",
"Hidden field \"token\" is a required field": "Dolt fält \"token\" är ett obligatoriskt fält",
"Erroneous challenge": "Felaktig challenge",
"Erroneous token": "Felaktig token",
"No such user": "Ogiltig användare",
"Token is expired, please try again": "Token föråldrad, försök igen",
"English": "",
"English (auto-generated)": "English (auto-genererat)",
"Afrikaans": "",
"Albanian": "",
"Amharic": "",
"Arabic": "",
"Armenian": "",
"Azerbaijani": "",
"Bangla": "",
"Basque": "",
"Belarusian": "",
"Bosnian": "",
"Bulgarian": "",
"Burmese": "",
"Catalan": "",
"Cebuano": "",
"Chinese (Simplified)": "",
"Chinese (Traditional)": "",
"Corsican": "",
"Croatian": "",
"Czech": "",
"Danish": "",
"Dutch": "",
"Esperanto": "",
"Estonian": "",
"Filipino": "",
"Finnish": "",
"French": "",
"Galician": "",
"Georgian": "",
"German": "",
"Greek": "",
"Gujarati": "",
"Haitian Creole": "",
"Hausa": "",
"Hawaiian": "",
"Hebrew": "",
"Hindi": "",
"Hmong": "",
"Hungarian": "",
"Icelandic": "",
"Igbo": "",
"Indonesian": "",
"Irish": "",
"Italian": "",
"Japanese": "",
"Javanese": "",
"Kannada": "",
"Kazakh": "",
"Khmer": "",
"Korean": "",
"Kurdish": "",
"Kyrgyz": "",
"Lao": "",
"Latin": "",
"Latvian": "",
"Lithuanian": "",
"Luxembourgish": "",
"Macedonian": "",
"Malagasy": "",
"Malay": "",
"Malayalam": "",
"Maltese": "",
"Maori": "",
"Marathi": "",
"Mongolian": "",
"Nepali": "",
"Norwegian Bokmål": "",
"Nyanja": "",
"Pashto": "",
"Persian": "",
"Polish": "",
"Portuguese": "",
"Punjabi": "",
"Romanian": "",
"Russian": "",
"Samoan": "",
"Scottish Gaelic": "",
"Serbian": "",
"Shona": "",
"Sindhi": "",
"Sinhala": "",
"Slovak": "",
"Slovenian": "",
"Somali": "",
"Southern Sotho": "",
"Spanish": "",
"Spanish (Latin America)": "",
"Sundanese": "",
"Swahili": "",
"Swedish": "",
"Tajik": "",
"Tamil": "",
"Telugu": "",
"Thai": "",
"Turkish": "",
"Ukrainian": "",
"Urdu": "",
"Uzbek": "",
"Vietnamese": "",
"Welsh": "",
"Western Frisian": "",
"Xhosa": "",
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
"`x` years": "`x` år",
"`x` months": "`x` månader",
"`x` weeks": "`x` veckor",
"`x` days": "`x` dagar",
"`x` hours": "`x` timmar",
"`x` minutes": "`x` minuter",
"`x` seconds": "`x` sekunder",
"Fallback comments: ": "Fallback-kommentarer: ",
"Popular": "Populärt",
"Top": "Topp",
"About": "Om",
"Rating: ": "Betyg: ",
"Language: ": "Språk: ",
"View as playlist": "Visa som spellista",
"Default": "Förvalt",
"Music": "Musik",
"Gaming": "Spel",
"News": "Nyheter",
"Movies": "Filmer",
"Download": "Ladda ned",
"Download as: ": "Ladda ned som: ",
"%A %B %-d, %Y": "",
"(edited)": "(redigerad)",
"YouTube comment permalink": "Permanent YouTube-länk till innehållet",
"permalink": "permalänk",
"`x` marked it with a ❤": "`x` lämnade ett ❤",
"Audio mode": "Ljudläge",
"Video mode": "Videoläge",
"Videos": "Videor",
"Playlists": "Spellistor",
"Community": "Gemenskap",
"Current version: ": "Nuvarande version: "
}

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "", "`x` subscribers": "`x` abone",
"`x` videos": "", "`x` videos": "`x` video",
"`x` playlists": "", "`x` playlists": "`x` çalma listesi",
"`x` subscribers.": "`x` abone.", "`x` subscribers.": "`x` abone.",
"`x` videos.": "`x` video.", "`x` videos.": "`x` video.",
"LIVE": "CANLI", "LIVE": "CANLI",
@@ -56,20 +56,20 @@
"Player preferences": "Oynatıcı tercihleri", "Player preferences": "Oynatıcı tercihleri",
"Always loop: ": "Sürekli döngü: ", "Always loop: ": "Sürekli döngü: ",
"Autoplay: ": "Otomatik oynat: ", "Autoplay: ": "Otomatik oynat: ",
"Play next by default: ": "Varsayılan olarak sonrakini oynat: ", "Play next by default: ": "Öntanımlı olarak sonrakini oynat: ",
"Autoplay next video: ": "Sonraki videoyu otomatik oynat: ", "Autoplay next video: ": "Sonraki videoyu otomatik oynat: ",
"Listen by default: ": "Varsayılan olarak dinle: ", "Listen by default: ": "Öntanımlı olarak dinle: ",
"Proxy videos: ": "Videoları proxy'le: ", "Proxy videos: ": "Videoları proxy'le: ",
"Default speed: ": "Varsayılan hız: ", "Default speed: ": "Öntanımlı hız: ",
"Preferred video quality: ": "Tercih edilen video kalitesi: ", "Preferred video quality: ": "Tercih edilen video kalitesi: ",
"Player volume: ": "Oynatıcı ses seviyesi: ", "Player volume: ": "Oynatıcı ses seviyesi: ",
"Default comments: ": "Varsayılan yorumlar: ", "Default comments: ": "Öntanımlı yorumlar: ",
"youtube": "youtube", "youtube": "youtube",
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "Varsayılan altyazılar: ", "Default captions: ": "Öntanımlı altyazılar: ",
"Fallback captions: ": "Yedek altyazılar: ", "Fallback captions: ": "Yedek altyazılar: ",
"Show related videos: ": "İlgili videoları göster: ", "Show related videos: ": "İlgili videoları göster: ",
"Show annotations by default: ": "Varsayılan olarak ek açıklamaları göster: ", "Show annotations by default: ": "Öntanımlı olarak ek açıklamaları göster: ",
"Visual preferences": "Görsel tercihler", "Visual preferences": "Görsel tercihler",
"Player style: ": "Oynatıcı biçimi: ", "Player style: ": "Oynatıcı biçimi: ",
"Dark mode: ": "Karanlık mod: ", "Dark mode: ": "Karanlık mod: ",
@@ -78,7 +78,7 @@
"light": "aydınlık", "light": "aydınlık",
"Thin mode: ": "İnce mod: ", "Thin mode: ": "İnce mod: ",
"Subscription preferences": "Abonelik tercihleri", "Subscription preferences": "Abonelik tercihleri",
"Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları varsayılan olarak göster: ", "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ",
"Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ",
"Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ", "Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ",
"Sort videos by: ": "Videoları sıralama kriteri: ", "Sort videos by: ": "Videoları sıralama kriteri: ",
@@ -104,7 +104,7 @@
"Watch history": "İzleme geçmişi", "Watch history": "İzleme geçmişi",
"Delete account": "Hesap silme", "Delete account": "Hesap silme",
"Administrator preferences": "Yönetici tercihleri", "Administrator preferences": "Yönetici tercihleri",
"Default homepage: ": "Varsayılan ana sayfa: ", "Default homepage: ": "Öntanımlı ana sayfa: ",
"Feed menu: ": "Akış menüsü: ", "Feed menu: ": "Akış menüsü: ",
"Top enabled: ": "Top etkin: ", "Top enabled: ": "Top etkin: ",
"CAPTCHA enabled: ": "CAPTCHA etkin: ", "CAPTCHA enabled: ": "CAPTCHA etkin: ",
@@ -113,13 +113,13 @@
"Report statistics: ": "Rapor istatistikleri: ", "Report statistics: ": "Rapor istatistikleri: ",
"Save preferences": "Tercihleri kaydet", "Save preferences": "Tercihleri kaydet",
"Subscription manager": "Abonelik yöneticisi", "Subscription manager": "Abonelik yöneticisi",
"`x` subscriptions": "", "`x` subscriptions": "`x` abonelik",
"`x` tokens": "", "`x` tokens": "`x` belirteç",
"Token manager": "Jeton yöneticisi", "Token manager": "Jeton yöneticisi",
"Token": "Jeton", "Token": "Jeton",
"`x` subscriptions.": "`x` abonelik.", "`x` subscriptions.": "`x` abonelik.",
"`x` tokens.": "`x` jeton.", "`x` tokens.": "`x` jeton.",
"`x` unseen notifications": "", "`x` unseen notifications": "`x` okunmamış bildirim",
"Import/export": "İçe/dışa aktar", "Import/export": "İçe/dışa aktar",
"unsubscribe": "abonelikten çık", "unsubscribe": "abonelikten çık",
"revoke": "geri al", "revoke": "geri al",
@@ -127,18 +127,18 @@
"`x` unseen notifications.": "`x` okunmamış bildirim.", "`x` unseen notifications.": "`x` okunmamış bildirim.",
"search": "ara", "search": "ara",
"Log out": ıkış yap", "Log out": ıkış yap",
"Public": "", "Public": "Genel",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.",
"Private": "", "Private": "Özel",
"View all playlists": "", "View all playlists": "Tüm çalma listelerini görüntüle",
"Updated `x` ago": "", "Updated `x` ago": "`x` önce güncellendi",
"Delete playlist `x`?": "", "Delete playlist `x`?": "`x` çalma listesini sil?",
"Delete playlist": "", "Delete playlist": "Çalma listesini sil",
"Create playlist": "", "Create playlist": "Çalma listesi oluştur",
"Title": "", "Title": "Başlık",
"Playlist privacy": "", "Playlist privacy": "Çalma listesi gizliliği",
"Editing playlist `x`": "", "Editing playlist `x`": "`x` çalma listesi düzenleniyor",
"Source available here.": "Kaynak kodu burada mevcut.", "Source available here.": "Kaynak kodları burada bulunabilir.",
"View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.",
"View privacy policy.": "Gizlilik politikasını görüntüle.", "View privacy policy.": "Gizlilik politikasını görüntüle.",
"Trending": "Trendler", "Trending": "Trendler",
@@ -149,7 +149,7 @@
"Genre: ": "Tür: ", "Genre: ": "Tür: ",
"License: ": "Lisans: ", "License: ": "Lisans: ",
"Family friendly? ": "Aile için uygun? ", "Family friendly? ": "Aile için uygun? ",
"`x` views": "", "`x` views": "`x` görüntüleme",
"Wilson score: ": "Wilson puanı: ", "Wilson score: ": "Wilson puanı: ",
"Engagement: ": "İzleyenlerin oy verme oranı: ", "Engagement: ": "İzleyenlerin oy verme oranı: ",
"Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ",
@@ -180,10 +180,10 @@
"Password cannot be empty": "Parola boş olamaz", "Password cannot be empty": "Parola boş olamaz",
"Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz", "Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz",
"Please log in": "Lütfen oturum açın", "Please log in": "Lütfen oturum açın",
"View `x` replies": "", "View `x` replies": "`x` yanıtı görüntüle",
"Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı",
"channel:`x`": "kanal:`x`", "channel:`x`": "kanal:`x`",
"`x` points": "", "`x` points": "`x` puan",
"Deleted or invalid channel": "Silinmiş ya da geçersiz kanal", "Deleted or invalid channel": "Silinmiş ya da geçersiz kanal",
"This channel does not exist.": "Bu kanal mevcut değil.", "This channel does not exist.": "Bu kanal mevcut değil.",
"Could not get channel info.": "Kanal bilgisi alınamadı.", "Could not get channel info.": "Kanal bilgisi alınamadı.",
@@ -323,7 +323,7 @@
"Rating: ": "Değerlendirme: ", "Rating: ": "Değerlendirme: ",
"Language: ": "Dil: ", "Language: ": "Dil: ",
"View as playlist": "Oynatma listesi olarak görüntüle", "View as playlist": "Oynatma listesi olarak görüntüle",
"Default": "Varsayılan", "Default": "Öntanımlı",
"Music": "Müzik", "Music": "Müzik",
"Gaming": "Oyun", "Gaming": "Oyun",
"News": "Haberler", "News": "Haberler",
@@ -340,5 +340,5 @@
"Videos": "Videolar", "Videos": "Videolar",
"Playlists": "Oynatma listeleri", "Playlists": "Oynatma listeleri",
"Community": "Topluluk", "Community": "Topluluk",
"Current version: ": "Şu anki versiyon: " "Current version: ": "Şu anki sürüm: "
} }

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` підписників", "`x` subscribers": "`x` підписників",
"`x` videos": "`x` відео", "`x` videos": "`x` відео",
"`x` playlists": "", "`x` playlists": "списки відтворення \"x\"",
"LIVE": "ПРЯМИЙ ЕФІР", "LIVE": "ПРЯМИЙ ЕФІР",
"Shared `x` ago": "Розміщено `x` назад", "Shared `x` ago": "Розміщено `x` назад",
"Unsubscribe": "Відписатися", "Unsubscribe": "Відписатися",
@@ -69,11 +69,11 @@
"Show related videos: ": "Показувати схожі відео? ", "Show related videos: ": "Показувати схожі відео? ",
"Show annotations by default: ": "Завжди показувати анотації? ", "Show annotations by default: ": "Завжди показувати анотації? ",
"Visual preferences": "Налаштування сайту", "Visual preferences": "Налаштування сайту",
"Player style: ": "", "Player style: ": "Стиль програвача: ",
"Dark mode: ": "Темне оформлення: ", "Dark mode: ": "Темне оформлення: ",
"Theme: ": "", "Theme: ": "Тема: ",
"dark": "", "dark": "темна",
"light": "", "light": "Світла",
"Thin mode: ": "Полегшене оформлення: ", "Thin mode: ": "Полегшене оформлення: ",
"Subscription preferences": "Налаштування підписок", "Subscription preferences": "Налаштування підписок",
"Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.",
"View privacy policy.": "Переглянути політику приватності.", "View privacy policy.": "Переглянути політику приватності.",
"Trending": "У тренді", "Trending": "У тренді",
"Public": "", "Public": "Прилюдний",
"Unlisted": "Немає в списку", "Unlisted": "Немає в списку",
"Private": "", "Private": "Особистий",
"View all playlists": "", "View all playlists": "Переглянути всі списки відтворення",
"Updated `x` ago": "", "Updated `x` ago": "Оновлено `x` тому",
"Delete playlist `x`?": "", "Delete playlist `x`?": "Видалити список відтворення \"x\"?",
"Delete playlist": "", "Delete playlist": "Видалити список відтворення",
"Create playlist": "", "Create playlist": "Створити список відтворення",
"Title": "", "Title": "Заголовок",
"Playlist privacy": "", "Playlist privacy": "Конфіденційність списку відтворення",
"Editing playlist `x`": "", "Editing playlist `x`": "Редагування списку відтворення \"x\"",
"Watch on YouTube": "Дивитися на YouTube", "Watch on YouTube": "Дивитися на YouTube",
"Hide annotations": "Приховати анотації", "Hide annotations": "Приховати анотації",
"Show annotations": "Показати анотації", "Show annotations": "Показати анотації",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%-d %B %Y, %A", "%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(змінено)", "(edited)": "(змінено)",
"YouTube comment permalink": "Пряме посилання на коментар в YouTube", "YouTube comment permalink": "Пряме посилання на коментар в YouTube",
"permalink": "", "permalink": "постійне посилання",
"`x` marked it with a ❤": "❤ цьому від каналу `x`", "`x` marked it with a ❤": "❤ цьому від каналу `x`",
"Audio mode": "Аудіорежим", "Audio mode": "Аудіорежим",
"Video mode": "Відеорежим", "Video mode": "Відеорежим",
"Videos": "Відео", "Videos": "Відео",
"Playlists": "Плейлисти", "Playlists": "Плейлисти",
"Community": "", "Community": "Спільнота",
"Current version: ": "Поточна версія: " "Current version: ": "Поточна версія: "
} }

View File

@@ -1,7 +1,7 @@
{ {
"`x` subscribers": "`x` 订阅者", "`x` subscribers": "`x` 订阅者",
"`x` videos": "`x` 视频", "`x` videos": "`x` 视频",
"`x` playlists": "", "`x` playlists": "`x` 个播放列表",
"LIVE": "直播", "LIVE": "直播",
"Shared `x` ago": "`x` 前分享", "Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消订阅", "Unsubscribe": "取消订阅",
@@ -69,11 +69,11 @@
"Show related videos: ": "显示相关视频?", "Show related videos: ": "显示相关视频?",
"Show annotations by default: ": "默认显示视频注释?", "Show annotations by default: ": "默认显示视频注释?",
"Visual preferences": "视觉选项", "Visual preferences": "视觉选项",
"Player style: ": "", "Player style: ": "播放器样式:",
"Dark mode: ": "暗色模式:", "Dark mode: ": "暗色模式:",
"Theme: ": "", "Theme: ": "主题",
"dark": "", "dark": "暗色",
"light": "", "light": "亮色",
"Thin mode: ": "窄页模式:", "Thin mode: ": "窄页模式:",
"Subscription preferences": "订阅设置", "Subscription preferences": "订阅设置",
"Show annotations by default for subscribed channels: ": "在订阅频道的视频默认显示注释?", "Show annotations by default for subscribed channels: ": "在订阅频道的视频默认显示注释?",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "查看 JavaScript 协议信息。", "View JavaScript license information.": "查看 JavaScript 协议信息。",
"View privacy policy.": "查看隐私政策。", "View privacy policy.": "查看隐私政策。",
"Trending": "时下流行", "Trending": "时下流行",
"Public": "", "Public": "公开",
"Unlisted": "不公开", "Unlisted": "不公开",
"Private": "", "Private": "私享",
"View all playlists": "", "View all playlists": "查看所有播放列表",
"Updated `x` ago": "", "Updated `x` ago": "`x` 前更新",
"Delete playlist `x`?": "", "Delete playlist `x`?": "是否删除播放列表 `x`",
"Delete playlist": "", "Delete playlist": "删除播放列表",
"Create playlist": "", "Create playlist": "创建播放列表",
"Title": "", "Title": "标题",
"Playlist privacy": "", "Playlist privacy": "播放列表隐私设置",
"Editing playlist `x`": "", "Editing playlist `x`": "正在编辑播放列表 `x`",
"Watch on YouTube": "在 YouTube 观看", "Watch on YouTube": "在 YouTube 观看",
"Hide annotations": "隐藏注释", "Hide annotations": "隐藏注释",
"Show annotations": "显示注释", "Show annotations": "显示注释",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%Y年%-m月%-d日 %a", "%A %B %-d, %Y": "%Y年%-m月%-d日 %a",
"(edited)": "(已编辑)", "(edited)": "(已编辑)",
"YouTube comment permalink": "YouTube 评论永久链接", "YouTube comment permalink": "YouTube 评论永久链接",
"permalink": "", "permalink": "永久链接",
"`x` marked it with a ❤": "`x` 为此加 ❤", "`x` marked it with a ❤": "`x` 为此加 ❤",
"Audio mode": "音频模式", "Audio mode": "音频模式",
"Video mode": "视频模式", "Video mode": "视频模式",
"Videos": "视频", "Videos": "视频",
"Playlists": "播放列表", "Playlists": "播放列表",
"Community": "", "Community": "社区",
"Current version: ": "当前版本:" "Current version: ": "当前版本:"
} }

View File

@@ -1,13 +1,13 @@
{ {
"`x` subscribers": { "`x` subscribers": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者",
"": "`x` 個訂閱者" "": "`x` 個訂閱者"
}, },
"`x` videos": { "`x` videos": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片",
"": "`x` 部影片" "": "`x` 部影片"
}, },
"`x` playlists": "", "`x` playlists": "`x` 播放清單",
"LIVE": "直播", "LIVE": "直播",
"Shared `x` ago": "`x` 前分享", "Shared `x` ago": "`x` 前分享",
"Unsubscribe": "取消訂閱", "Unsubscribe": "取消訂閱",
@@ -58,44 +58,44 @@
"Google verification code": "Google 驗證碼", "Google verification code": "Google 驗證碼",
"Preferences": "偏好設定", "Preferences": "偏好設定",
"Player preferences": "播放器偏好設定", "Player preferences": "播放器偏好設定",
"Always loop: ": "總是循環播放:", "Always loop: ": "總是循環播放: ",
"Autoplay: ": "自動播放:", "Autoplay: ": "自動播放: ",
"Play next by default: ": "預設播放下一部:", "Play next by default: ": "預設播放下一部: ",
"Autoplay next video: ": "自動播放下一部影片:", "Autoplay next video: ": "自動播放下一部影片: ",
"Listen by default: ": "預設聆聽:", "Listen by default: ": "預設聆聽: ",
"Proxy videos: ": "代理影片:", "Proxy videos: ": "代理影片: ",
"Default speed: ": "預設速度:", "Default speed: ": "預設速度: ",
"Preferred video quality: ": "偏好的影片畫質:", "Preferred video quality: ": "偏好的影片畫質: ",
"Player volume: ": "播放器音量:", "Player volume: ": "播放器音量: ",
"Default comments: ": "預設留言:", "Default comments: ": "預設留言: ",
"youtube": "youtube", "youtube": "youtube",
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "預設字幕:", "Default captions: ": "預設字幕: ",
"Fallback captions: ": "汰退字幕:", "Fallback captions: ": "汰退字幕: ",
"Show related videos: ": "顯示相關的影片:", "Show related videos: ": "顯示相關的影片: ",
"Show annotations by default: ": "預設顯示註釋:", "Show annotations by default: ": "預設顯示註釋: ",
"Visual preferences": "視覺偏好設定", "Visual preferences": "視覺偏好設定",
"Player style: ": "播放器樣式", "Player style: ": "播放器樣式 ",
"Dark mode: ": "深色模式:", "Dark mode: ": "深色模式: ",
"Theme: ": "佈景主題", "Theme: ": "佈景主題 ",
"dark": "深色", "dark": "深色",
"light": "淺色", "light": "淺色",
"Thin mode: ": "精簡模式:", "Thin mode: ": "精簡模式: ",
"Subscription preferences": "訂閱偏好設定", "Subscription preferences": "訂閱偏好設定",
"Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋", "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋 ",
"Redirect homepage to feed: ": "重新導向首頁至 feed", "Redirect homepage to feed: ": "重新導向首頁至 feed ",
"Number of videos shown in feed: ": "顯示在 feed 中的影片數量:", "Number of videos shown in feed: ": "顯示在 feed 中的影片數量: ",
"Sort videos by: ": "以此種方式排序影片:", "Sort videos by: ": "以此種方式排序影片: ",
"published": "已發佈", "published": "已發佈",
"published - reverse": "已發佈 - 反向", "published - reverse": "已發佈 - 反向",
"alphabetically": "字母", "alphabetically": "字母",
"alphabetically - reverse": "字母 - 反向", "alphabetically - reverse": "字母 - 反向",
"channel name": "頻道名稱", "channel name": "頻道名稱",
"channel name - reverse": "頻道名稱 - 反向", "channel name - reverse": "頻道名稱 - 反向",
"Only show latest video from channel: ": "僅顯示從頻道而來的最新影片:", "Only show latest video from channel: ": "僅顯示從頻道而來的最新影片: ",
"Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片:", "Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片: ",
"Only show unwatched: ": "僅顯示未觀看的:", "Only show unwatched: ": "僅顯示未觀看的: ",
"Only show notifications (if there are any): ": "僅顯示通知(如果有的話):", "Only show notifications (if there are any): ": "僅顯示通知(如果有的話): ",
"Enable web notifications": "啟用網路通知", "Enable web notifications": "啟用網路通知",
"`x` uploaded a video": "`x` 上傳了一部影片", "`x` uploaded a video": "`x` 上傳了一部影片",
"`x` is live": "`x` 正在直播", "`x` is live": "`x` 正在直播",
@@ -108,24 +108,24 @@
"Watch history": "觀看歷史", "Watch history": "觀看歷史",
"Delete account": "刪除帳號", "Delete account": "刪除帳號",
"Administrator preferences": "管理員偏好設定", "Administrator preferences": "管理員偏好設定",
"Default homepage: ": "預設首頁:", "Default homepage: ": "預設首頁: ",
"Feed menu: ": "Feed 選單:", "Feed menu: ": "Feed 選單: ",
"Top enabled: ": "頂部啟用:", "Top enabled: ": "頂部啟用: ",
"CAPTCHA enabled: ": "CAPTCHA 啟用:", "CAPTCHA enabled: ": "CAPTCHA 啟用: ",
"Login enabled: ": "啟用登入", "Login enabled: ": "啟用登入 ",
"Registration enabled: ": "啟用註冊", "Registration enabled: ": "啟用註冊 ",
"Report statistics: ": "回報統計", "Report statistics: ": "回報統計 ",
"Save preferences": "儲存偏好設定", "Save preferences": "儲存偏好設定",
"Subscription manager": "訂閱管理員", "Subscription manager": "訂閱管理員",
"Token manager": "Token 管理員", "Token manager": "Token 管理員",
"Token": "Token", "Token": "Token",
"`x` subscriptions": { "`x` subscriptions": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱",
"": "`x` 個訂閱" "": "`x` 個訂閱"
}, },
"`x` tokens": { "`x` tokens": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token",
"": "`x` tokens" "": "`x` tokens"
}, },
"Import/export": "匯入/匯出", "Import/export": "匯入/匯出",
"unsubscribe": "取消訂閱", "unsubscribe": "取消訂閱",
@@ -133,7 +133,7 @@
"Subscriptions": "訂閱", "Subscriptions": "訂閱",
"`x` unseen notifications": { "`x` unseen notifications": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知",
"": "`x` 個未讀的通知" "": "`x` 個未讀的通知"
}, },
"search": "搜尋", "search": "搜尋",
"Log out": "登出", "Log out": "登出",
@@ -147,7 +147,7 @@
"Private": "私人", "Private": "私人",
"View all playlists": "檢視所有播放清單", "View all playlists": "檢視所有播放清單",
"Updated `x` ago": "更新於 `x` 之前", "Updated `x` ago": "更新於 `x` 之前",
"Delete playlist `x`?": "刪除播放清單", "Delete playlist `x`?": "刪除播放清單 `x`",
"Delete playlist": "刪除播放清單", "Delete playlist": "刪除播放清單",
"Create playlist": "建立播放清單", "Create playlist": "建立播放清單",
"Title": "標題", "Title": "標題",
@@ -156,17 +156,17 @@
"Watch on YouTube": "在 YouTube 上觀看", "Watch on YouTube": "在 YouTube 上觀看",
"Hide annotations": "隱藏註釋", "Hide annotations": "隱藏註釋",
"Show annotations": "顯示註釋", "Show annotations": "顯示註釋",
"Genre: ": "風格:", "Genre: ": "風格: ",
"License: ": "授權條款:", "License: ": "授權條款: ",
"Family friendly? ": "家庭友好?", "Family friendly? ": "家庭友好? ",
"Wilson score: ": "威爾遜分數:", "Wilson score: ": "威爾遜分數: ",
"Engagement: ": "參與度:", "Engagement: ": "參與度: ",
"Whitelisted regions: ": "白名單區域:", "Whitelisted regions: ": "白名單區域: ",
"Blacklisted regions: ": "黑名單區域:", "Blacklisted regions: ": "黑名單區域: ",
"Shared `x`": "`x` 發佈", "Shared `x`": "`x` 發佈",
"`x` views": { "`x` views": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視",
"": "`x` 次檢視" "": "`x` 次檢視"
}, },
"Premieres in `x`": "首映於 `x`", "Premieres in `x`": "首映於 `x`",
"Premieres `x`": "首映於 `x`", "Premieres `x`": "首映於 `x`",
@@ -200,13 +200,13 @@
"Could not fetch comments": "無法擷取留言", "Could not fetch comments": "無法擷取留言",
"View `x` replies": { "View `x` replies": {
"([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆", "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆",
"": "檢視 `x` 則回覆" "": "檢視 `x` 則回覆"
}, },
"`x` ago": "`x` 以前", "`x` ago": "`x` 以前",
"Load more": "載入更多", "Load more": "載入更多",
"`x` points": { "`x` points": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點",
"": "`x` 點" "": "`x` 點"
}, },
"Could not create mix.": "無法建立混合。", "Could not create mix.": "無法建立混合。",
"Empty playlist": "空的播放清單", "Empty playlist": "空的播放清單",
@@ -327,38 +327,38 @@
"Zulu": "祖魯語", "Zulu": "祖魯語",
"`x` years": { "`x` years": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年",
"": "`x` 年" "": "`x` 年"
}, },
"`x` months": { "`x` months": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月",
"": "`x` 月" "": "`x` 月"
}, },
"`x` weeks": { "`x` weeks": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週",
"": "`x` 週" "": "`x` 週"
}, },
"`x` days": { "`x` days": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
"": "`x` 天" "": "`x` 天"
}, },
"`x` hours": { "`x` hours": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時",
"": "`x` 小時" "": "`x` 小時"
}, },
"`x` minutes": { "`x` minutes": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天",
"": "`x` " "": "`x` 分鐘。"
}, },
"`x` seconds": { "`x` seconds": {
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒",
"": "`x` 秒" "": "`x` 秒"
}, },
"Fallback comments: ": "汰退留言:", "Fallback comments: ": "汰退留言: ",
"Popular": "熱門頻道", "Popular": "熱門頻道",
"Top": "熱門影片", "Top": "熱門影片",
"About": "關於", "About": "關於",
"Rating: ": "評分:", "Rating: ": "評分: ",
"Language: ": "語言:", "Language: ": "語言: ",
"View as playlist": "以播放清單檢視", "View as playlist": "以播放清單檢視",
"Default": "預設值", "Default": "預設值",
"Music": "音樂", "Music": "音樂",
@@ -366,16 +366,16 @@
"News": "新聞", "News": "新聞",
"Movies": "電影", "Movies": "電影",
"Download": "下載", "Download": "下載",
"Download as: ": "下載為:", "Download as: ": "下載為: ",
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(已編輯)", "(edited)": "(已編輯)",
"YouTube comment permalink": "YouTube 留言永久連結", "YouTube comment permalink": "YouTube 留言永久連結",
"permalink": "", "permalink": "永久連結",
"`x` marked it with a ❤": "`x` 為此標記 ❤", "`x` marked it with a ❤": "`x` 為此標記 ❤",
"Audio mode": "音訊模式", "Audio mode": "音訊模式",
"Video mode": "視訊模式", "Video mode": "視訊模式",
"Videos": "影片", "Videos": "影片",
"Playlists": "播放清單", "Playlists": "播放清單",
"Community": "社群", "Community": "社群",
"Current version: ": "目前版本:" "Current version: ": "目前版本: "
} }

View File

@@ -11,13 +11,13 @@ targets:
dependencies: dependencies:
pg: pg:
github: will/crystal-pg github: will/crystal-pg
version: ~> 0.19.0 version: ~> 0.21.0
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: ~> 0.14.0 version: ~> 0.16.0
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: ~> 0.26.0 version: ~> 0.26.1
pool: pool:
github: ysbaddaden/pool github: ysbaddaden/pool
version: ~> 0.2.3 version: ~> 0.2.3
@@ -26,8 +26,8 @@ dependencies:
version: ~> 0.1.2 version: ~> 0.1.2
lsquic: lsquic:
github: omarroth/lsquic.cr github: omarroth/lsquic.cr
version: ~> 0.1.3 branch: dev
crystal: 0.31.1 crystal: 0.34.0
license: AGPLv3 license: AGPLv3

View File

@@ -9,6 +9,7 @@ require "../src/invidious/channels"
require "../src/invidious/comments" require "../src/invidious/comments"
require "../src/invidious/playlists" require "../src/invidious/playlists"
require "../src/invidious/search" require "../src/invidious/search"
require "../src/invidious/trending"
require "../src/invidious/users" require "../src/invidious/users"
describe "Helper" do describe "Helper" do
@@ -124,6 +125,15 @@ describe "Helper" do
end end
end end
describe "#extract_plid" do
it "correctly extracts playlist ID from trending URL" do
extract_plid("/feed/trending?bp=4gIuCggvbS8wNHJsZhIiUExGZ3F1TG5MNTlhbVBud2pLbmNhZUp3MDYzZlU1M3Q0cA%3D%3D").should eq("PLFgquLnL59amPnwjKncaeJw063fU53t4p")
extract_plid("/feed/trending?bp=4gIvCgkvbS8wYnp2bTISIlBMaUN2Vkp6QnVwS2tDaFNnUDdGWFhDclo2aEp4NmtlTm0%3D").should eq("PLiCvVJzBupKkChSgP7FXXCrZ6hJx6keNm")
extract_plid("/feed/trending?bp=4gIuCggvbS8wNWpoZxIiUEwzWlE1Q3BOdWxRbUtPUDNJekdsYWN0V1c4dklYX0hFUA%3D%3D").should eq("PL3ZQ5CpNulQmKOP3IzGlactWW8vIX_HEP")
extract_plid("/feed/trending?bp=4gIuCggvbS8wMnZ4bhIiUEx6akZiYUZ6c21NUnFhdEJnVTdPeGNGTkZhQ2hqTkVERA%3D%3D").should eq("PLzjFbaFzsmMRqatBgU7OxcFNFaChjNEDD")
end
end
describe "#sign_token" do describe "#sign_token" do
it "correctly signs a given hash" do it "correctly signs a given hash" do
token = { token = {

View File

@@ -28,8 +28,11 @@ require "protodec/utils"
require "./invidious/helpers/*" require "./invidious/helpers/*"
require "./invidious/*" require "./invidious/*"
CONFIG = Config.from_yaml(File.read("config/config.yml")) ENV_CONFIG_NAME = "INVIDIOUS_CONFIG"
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
CONFIG_STR = ENV.has_key?(ENV_CONFIG_NAME) ? ENV.fetch(ENV_CONFIG_NAME) : File.read("config/config.yml")
CONFIG = Config.from_yaml(CONFIG_STR)
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
PG_URL = URI.new( PG_URL = URI.new(
scheme: "postgres", scheme: "postgres",
@@ -45,19 +48,18 @@ ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com") LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com") TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
YT_URL = URI.parse("https://www.youtube.com") YT_URL = URI.parse("https://www.youtube.com")
YT_IMG_URL = URI.parse("https://i.ytimg.com")
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
MAX_ITEMS_PER_PAGE = 1500 MAX_ITEMS_PER_PAGE = 1500
REQUEST_HEADERS_WHITELIST = {"Accept", "Accept-Encoding", "Cache-Control", "Connection", "Content-Length", "If-None-Match", "Range"} REQUEST_HEADERS_WHITELIST = {"accept", "accept-encoding", "cache-control", "content-length", "if-none-match", "range"}
RESPONSE_HEADERS_BLACKLIST = {"Access-Control-Allow-Origin", "Alt-Svc", "Server"} RESPONSE_HEADERS_BLACKLIST = {"access-control-allow-origin", "alt-svc", "server"}
HTTP_CHUNK_SIZE = 10485760 # ~10MB HTTP_CHUNK_SIZE = 10485760 # ~10MB
CURRENT_BRANCH = {{ "#{`git branch | sed -n '/\* /s///p'`.strip}" }} CURRENT_BRANCH = {{ "#{`git branch | sed -n '/* /s///p'`.strip}" }}
CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }} CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }} CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }}
@@ -81,21 +83,25 @@ LOCALES = {
"es" => load_locale("es"), "es" => load_locale("es"),
"eu" => load_locale("eu"), "eu" => load_locale("eu"),
"fr" => load_locale("fr"), "fr" => load_locale("fr"),
"hu" => load_locale("hu-HU"),
"is" => load_locale("is"), "is" => load_locale("is"),
"it" => load_locale("it"), "it" => load_locale("it"),
"ja" => load_locale("ja"), "ja" => load_locale("ja"),
"nb_NO" => load_locale("nb_NO"), "nb-NO" => load_locale("nb-NO"),
"nl" => load_locale("nl"), "nl" => load_locale("nl"),
"pl" => load_locale("pl"), "pl" => load_locale("pl"),
"pt-BR" => load_locale("pt-BR"),
"pt-PT" => load_locale("pt-PT"),
"ro" => load_locale("ro"),
"ru" => load_locale("ru"), "ru" => load_locale("ru"),
"sv" => load_locale("sv-SE"),
"tr" => load_locale("tr"), "tr" => load_locale("tr"),
"uk" => load_locale("uk"), "uk" => load_locale("uk"),
"zh-CN" => load_locale("zh-CN"), "zh-CN" => load_locale("zh-CN"),
"zh-TW" => load_locale("zh-TW"), "zh-TW" => load_locale("zh-TW"),
} }
YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.05) YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.1)
YT_IMG_POOL = HTTPPool.new(YT_IMG_URL, capacity: CONFIG.pool_size, timeout: 0.05)
config = CONFIG config = CONFIG
logger = Invidious::LogHandler.new logger = Invidious::LogHandler.new
@@ -205,7 +211,7 @@ spawn do
end end
end end
decrypt_function = [] of {name: String, value: Int32} decrypt_function = [] of {SigProc, Int32}
spawn do spawn do
update_decrypt_function do |function| update_decrypt_function do |function|
decrypt_function = function decrypt_function = function
@@ -245,21 +251,36 @@ spawn do
end end
before_all do |env| before_all do |env|
host_url = make_host_url(config, Kemal.config) begin
preferences = Preferences.from_json(env.request.cookies["PREFS"]?.try &.value || "{}")
rescue
preferences = Preferences.from_json("{}")
end
env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff" env.response.headers["X-Content-Type-Options"] = "nosniff"
env.response.headers["Content-Security-Policy"] = "default-src blob: data: 'self' #{host_url} 'unsafe-inline' 'unsafe-eval'; media-src blob: 'self' #{host_url} https://*.googlevideo.com:443" extra_media_csp = ""
if CONFIG.disabled?("local") || !preferences.local
extra_media_csp += " https://*.googlevideo.com:443"
end
# TODO: Remove style-src's 'unsafe-inline', requires to remove all inline styles (<style> [..] </style>, style=" [..] ")
env.response.headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; manifest-src 'self'; media-src 'self' blob:#{extra_media_csp}"
env.response.headers["Referrer-Policy"] = "same-origin" env.response.headers["Referrer-Policy"] = "same-origin"
if (Kemal.config.ssl || config.https_only) && config.hsts if (Kemal.config.ssl || config.https_only) && config.hsts
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
end end
begin next if {
preferences = Preferences.from_json(env.request.cookies["PREFS"]?.try &.value || "{}") "/sb/",
rescue "/vi/",
preferences = Preferences.from_json("{}") "/s_p/",
end "/yts/",
"/ggpht/",
"/api/manifest/",
"/videoplayback",
"/latest_version",
}.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID" if env.request.cookies.has_key? "SID"
sid = env.request.cookies["SID"].value sid = env.request.cookies["SID"].value
@@ -366,6 +387,8 @@ get "/" do |env|
else else
templated "popular" templated "popular"
end end
else
templated "empty"
end end
end end
@@ -412,7 +435,7 @@ get "/watch" do |env|
next env.redirect "/" next env.redirect "/"
end end
plid = env.params.query["list"]? plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
continuation = process_continuation(PG_DB, env.params.query, plid, id) continuation = process_continuation(PG_DB, env.params.query, plid, id)
nojs = env.params.query["nojs"]? nojs = env.params.query["nojs"]?
@@ -452,7 +475,7 @@ get "/watch" do |env|
env.params.query.delete_all("iv_load_policy") env.params.query.delete_all("iv_load_policy")
if watched && !watched.includes? id if watched && !watched.includes? id
PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email) PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email)
end end
if notifications && notifications.includes? id if notifications && notifications.includes? id
@@ -597,7 +620,7 @@ end
get "/embed/" do |env| get "/embed/" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
if plid = env.params.query["list"]? if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
begin begin
playlist = get_playlist(PG_DB, plid, locale: locale) playlist = get_playlist(PG_DB, plid, locale: locale)
offset = env.params.query["index"]?.try &.to_i? || 0 offset = env.params.query["index"]?.try &.to_i? || 0
@@ -624,7 +647,7 @@ get "/embed/:id" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
id = env.params.url["id"] id = env.params.url["id"]
plid = env.params.query["list"]? plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
continuation = process_continuation(PG_DB, env.params.query, plid, id) continuation = process_continuation(PG_DB, env.params.query, plid, id)
if md = env.params.query["playlist"]? if md = env.params.query["playlist"]?
@@ -703,6 +726,7 @@ get "/embed/:id" do |env|
end end
next env.redirect url next env.redirect url
else nil # Continue
end end
params = process_video_params(env.params.query, preferences) params = process_video_params(env.params.query, preferences)
@@ -732,7 +756,7 @@ get "/embed/:id" do |env|
end end
# if watched && !watched.includes? id # if watched && !watched.includes? id
# PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email) # PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email)
# end # end
if notifications && notifications.includes? id if notifications && notifications.includes? id
@@ -1227,13 +1251,17 @@ post "/playlist_ajax" do |env|
args = arg_array(video_array) args = arg_array(video_array)
PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index), updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id)
when "action_remove_video" when "action_remove_video"
index = env.params.query["set_video_id"] index = env.params.query["set_video_id"]
PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = video_count - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index), updated = $2 WHERE id = $3", index, Time.utc, playlist_id)
when "action_move_video_before" when "action_move_video_before"
# TODO: Playlist stub # TODO: Playlist stub
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end end
if redirect if redirect
@@ -1248,9 +1276,9 @@ get "/playlist" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
user = env.get?("user").try &.as(User) user = env.get?("user").try &.as(User)
plid = env.params.query["list"]?
referer = get_referer(env) referer = get_referer(env)
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
if !plid if !plid
next env.redirect "/" next env.redirect "/"
end end
@@ -1437,7 +1465,7 @@ post "/login" do |env|
# See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
# TODO: Convert to QUIC # TODO: Convert to QUIC
begin begin
client = make_client(LOGIN_URL) client = QUIC::Client.new(LOGIN_URL)
headers = HTTP::Headers.new headers = HTTP::Headers.new
login_page = client.get("/ServiceLogin") login_page = client.get("/ServiceLogin")
@@ -1460,7 +1488,6 @@ post "/login" do |env|
headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8"
headers["Google-Accounts-XSRF"] = "1" headers["Google-Accounts-XSRF"] = "1"
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req))
lookup_results = JSON.parse(response.body[5..-1]) lookup_results = JSON.parse(response.body[5..-1])
@@ -1529,7 +1556,7 @@ post "/login" do |env|
case prompt_type case prompt_type
when "TWO_STEP_VERIFICATION" when "TWO_STEP_VERIFICATION"
prompt_type = 2 prompt_type = 2
when "LOGIN_CHALLENGE" else # "LOGIN_CHALLENGE"
prompt_type = 4 prompt_type = 4
end end
@@ -1634,28 +1661,31 @@ post "/login" do |env|
traceback << "Logging in..." traceback << "Logging in..."
location = challenge_results[0][-1][2].to_s location = URI.parse(challenge_results[0][-1][2].to_s)
cookies = HTTP::Cookies.from_headers(headers) cookies = HTTP::Cookies.from_headers(headers)
headers.delete("Content-Type")
headers.delete("Google-Accounts-XSRF")
loop do loop do
if !location || location.includes? "/ManageAccount" if !location || location.path == "/ManageAccount"
break break
end end
# Occasionally there will be a second page after login confirming # Occasionally there will be a second page after login confirming
# the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle.
if location.includes? "/b/0/SmsAuthInterstitial" if location.path.starts_with? "/b/0/SmsAuthInterstitial"
traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." traceback << "Unhandled dialog /b/0/SmsAuthInterstitial."
end end
login = client.get(location, headers) login = client.get(location.full_path, headers)
headers = login.cookies.add_request_headers(headers)
cookies = HTTP::Cookies.from_headers(headers) headers = login.cookies.add_request_headers(headers)
location = login.headers["Location"]? location = login.headers["Location"]?.try { |u| URI.parse(u) }
end end
cookies = HTTP::Cookies.from_headers(headers)
sid = cookies["SID"]?.try &.value sid = cookies["SID"]?.try &.value
if !sid if !sid
raise "Couldn't get SID." raise "Couldn't get SID."
@@ -1819,7 +1849,7 @@ post "/login" do |env|
env.response.status_code = 400 env.response.status_code = 400
next templated "error" next templated "error"
end end
when "text" else # "text"
answer = Digest::MD5.hexdigest(answer.downcase.strip) answer = Digest::MD5.hexdigest(answer.downcase.strip)
found_valid_captcha = false found_valid_captcha = false
@@ -2226,10 +2256,14 @@ post "/watch_ajax" do |env|
case action case action
when "action_mark_watched" when "action_mark_watched"
if !user.watched.includes? id if !user.watched.includes? id
PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.email) PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.email)
end end
when "action_mark_unwatched" when "action_mark_unwatched"
PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email) PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email)
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end end
if redirect if redirect
@@ -2384,6 +2418,10 @@ post "/subscription_ajax" do |env|
end end
when "action_remove_subscriptions" when "action_remove_subscriptions"
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email)
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end end
if redirect if redirect
@@ -2538,6 +2576,7 @@ post "/data_control" do |env|
next next
end end
# TODO: Unify into single import based on content-type
case part.name case part.name
when "import_invidious" when "import_invidious"
body = JSON.parse(body) body = JSON.parse(body)
@@ -2587,13 +2626,9 @@ post "/data_control" do |env|
next match["channel"] next match["channel"]
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/) elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
document = XML.parse_html(response.body) html = XML.parse_html(response.body)
canonical = document.xpath_node(%q(//link[@rel="canonical"])) ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next ucid if ucid
if canonical
ucid = canonical["href"].split("/")[-1]
next ucid
end
end end
nil nil
@@ -2628,6 +2663,7 @@ post "/data_control" do |env|
end end
end end
end end
else nil # Ignore
end end
end end
end end
@@ -2969,6 +3005,10 @@ post "/token_ajax" do |env|
case action case action
when .starts_with? "action_revoke_token" when .starts_with? "action_revoke_token"
PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email)
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end end
if redirect if redirect
@@ -3111,12 +3151,10 @@ get "/feed/channel/:ucid" do |env|
next error_message next error_message
end end
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}").body response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
rss = XML.parse_html(rss) rss = XML.parse_html(response.body)
videos = [] of SearchVideo videos = rss.xpath_nodes("//feed/entry").map do |entry|
rss.xpath_nodes("//feed/entry").each do |entry|
video_id = entry.xpath_node("videoid").not_nil!.content video_id = entry.xpath_node("videoid").not_nil!.content
title = entry.xpath_node("title").not_nil!.content title = entry.xpath_node("title").not_nil!.content
@@ -3128,7 +3166,7 @@ get "/feed/channel/:ucid" do |env|
description_html = entry.xpath_node("group/description").not_nil!.to_s description_html = entry.xpath_node("group/description").not_nil!.to_s
views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64
videos << SearchVideo.new( SearchVideo.new(
title: title, title: title,
id: video_id, id: video_id,
author: author, author: author,
@@ -3265,6 +3303,7 @@ get "/feed/playlist/:plid" do |env|
full_path = URI.parse(node[attribute.name]).full_path full_path = URI.parse(node[attribute.name]).full_path
query_string_opt = full_path.starts_with?("/watch?v=") ? "&#{params}" : "" query_string_opt = full_path.starts_with?("/watch?v=") ? "&#{params}" : ""
node[attribute.name] = "#{host_url}#{full_path}#{query_string_opt}" node[attribute.name] = "#{host_url}#{full_path}#{query_string_opt}"
else nil # Skip
end end
end end
end end
@@ -3388,8 +3427,8 @@ post "/feed/webhook/:token" do |env|
views: video.views, views: video.views,
) )
emails = PG_DB.query_all("UPDATE users SET notifications = notifications || $1 \ PG_DB.query_all("UPDATE users SET feed_needs_update = true, notifications = array_append(notifications, $1) \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)",
video.id, video.published, video.ucid, as: String) video.id, video.published, video.ucid, as: String)
video_array = video.to_a video_array = video.to_a
@@ -3399,15 +3438,6 @@ post "/feed/webhook/:token" do |env|
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
updated = $4, ucid = $5, author = $6, length_seconds = $7, \ updated = $4, ucid = $5, author = $6, length_seconds = $7, \
live_now = $8, premiere_timestamp = $9, views = $10", args: video_array) live_now = $8, premiere_timestamp = $9, views = $10", args: video_array)
# Update all users affected by insert
if emails.empty?
values = "'{}'"
else
values = "VALUES #{emails.map { |email| %((E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}')) }.join(",")}"
end
PG_DB.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
end end
end end
@@ -3460,14 +3490,12 @@ get "/c/:user" do |env|
user = env.params.url["user"] user = env.params.url["user"]
response = YT_POOL.client &.get("/c/#{user}") response = YT_POOL.client &.get("/c/#{user}")
document = XML.parse_html(response.body) html = XML.parse_html(response.body)
anchor = document.xpath_node(%q(//a[contains(@class,"branded-page-header-title-link")])) ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
if !anchor next env.redirect "/" if !ucid
next env.redirect "/"
end
env.redirect anchor["href"] env.redirect "/channel/#{ucid}"
end end
# Legacy endpoint for /user/:username # Legacy endpoint for /user/:username
@@ -3826,10 +3854,10 @@ get "/api/v1/captions/:id" do |env|
env.response.content_type = "text/vtt; charset=UTF-8" env.response.content_type = "text/vtt; charset=UTF-8"
caption = captions.select { |caption| caption.name.simpleText == label }
if lang if lang
caption = captions.select { |caption| caption.languageCode == lang } caption = captions.select { |caption| caption.languageCode == lang }
else
caption = captions.select { |caption| caption.name.simpleText == label }
end end
if caption.empty? if caption.empty?
@@ -3839,7 +3867,7 @@ get "/api/v1/captions/:id" do |env|
caption = caption[0] caption = caption[0]
end end
url = "#{caption.baseUrl}&tlang=#{tlang}" url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").full_path
# Auto-generated captions often have cues that aren't aligned properly with the video, # Auto-generated captions often have cues that aren't aligned properly with the video,
# as well as some other markup that makes it cumbersome, so we try to fix that here # as well as some other markup that makes it cumbersome, so we try to fix that here
@@ -4033,7 +4061,7 @@ get "/api/v1/annotations/:id" do |env|
cache_annotation(PG_DB, id, annotations) cache_annotation(PG_DB, id, annotations)
end end
when "youtube" else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
if response.status_code != 200 if response.status_code != 200
@@ -4233,7 +4261,7 @@ get "/api/v1/channels/:ucid" do |env|
qualities.each do |quality| qualities.each do |quality|
json.object do json.object do
json.field "url", channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
json.field "width", quality json.field "width", quality
json.field "height", quality json.field "height", quality
end end
@@ -4510,9 +4538,8 @@ get "/api/v1/search/suggestions" do |env|
query ||= "" query ||= ""
begin begin
response = QUIC::Client.get( headers = HTTP::Headers{":authority" => "suggestqueries.google.com"}
"https://suggestqueries.google.com/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback" response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body
).body
body = response[35..-2] body = response[35..-2]
body = JSON.parse(body).as_a body = JSON.parse(body).as_a
@@ -5139,7 +5166,7 @@ get "/api/manifest/dash/id/:id" do |env|
# 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"]? && (env.params.query["unique_res"] == "true" || env.params.query["unique_res"] == "1") unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
begin begin
video = get_video(id, PG_DB, region: region) video = get_video(id, PG_DB, region: region)
@@ -5151,7 +5178,7 @@ get "/api/manifest/dash/id/:id" do |env|
end end
if dashmpd = video.player_response["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s if dashmpd = video.player_response["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s
manifest = YT_POOL.client &.get(dashmpd).body manifest = YT_POOL.client &.get(URI.parse(dashmpd).full_path).body
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
url = baseurl.lchop("<BaseURL>") url = baseurl.lchop("<BaseURL>")
@@ -5176,7 +5203,7 @@ get "/api/manifest/dash/id/:id" do |env|
end end
audio_streams = video.audio_streams(adaptive_fmts) audio_streams = video.audio_streams(adaptive_fmts)
video_streams = video.video_streams(adaptive_fmts).sort_by { |stream| stream["fps"].to_i }.reverse video_streams = video.video_streams(adaptive_fmts).sort_by { |stream| {stream["size"].split("x")[0].to_i, stream["fps"].to_i} }.reverse
XML.build(indent: " ", encoding: "UTF-8") do |xml| XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
@@ -5214,9 +5241,7 @@ get "/api/manifest/dash/id/:id" do |env|
{"video/mp4", "video/webm"}.each do |mime_type| {"video/mp4", "video/webm"}.each do |mime_type|
mime_streams = video_streams.select { |stream| stream["type"].starts_with? mime_type } mime_streams = video_streams.select { |stream| stream["type"].starts_with? mime_type }
if mime_streams.empty? next if mime_streams.empty?
next
end
heights = [] of Int32 heights = [] of Int32
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do
@@ -5478,8 +5503,8 @@ get "/videoplayback" do |env|
end end
client = make_client(URI.parse(host), region) client = make_client(URI.parse(host), region)
response = HTTP::Client::Response.new(500) response = HTTP::Client::Response.new(500)
error = ""
5.times do 5.times do
begin begin
response = client.head(url, headers) response = client.head(url, headers)
@@ -5504,12 +5529,14 @@ get "/videoplayback" do |env|
host = "https://r#{fvip}---#{mn}.googlevideo.com" host = "https://r#{fvip}---#{mn}.googlevideo.com"
client = make_client(URI.parse(host), region) client = make_client(URI.parse(host), region)
rescue ex rescue ex
error = ex.message
end end
end end
if response.status_code >= 400 if response.status_code >= 400
env.response.status_code = response.status_code env.response.status_code = response.status_code
next env.response.content_type = "text/plain"
next error
end end
if url.includes? "&file=seg.ts" if url.includes? "&file=seg.ts"
@@ -5523,7 +5550,7 @@ get "/videoplayback" do |env|
client = make_client(URI.parse(host), region) client = make_client(URI.parse(host), region)
client.get(url, headers) do |response| client.get(url, headers) do |response|
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key) if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5541,7 +5568,7 @@ get "/videoplayback" do |env|
next env.redirect location next env.redirect location
end end
IO.copy(response.body_io, env.response) IO.copy response.body_io, env.response
end end
rescue ex rescue ex
end end
@@ -5591,7 +5618,7 @@ get "/videoplayback" do |env|
end end
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key) && key != "Content-Range" if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range"
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5640,11 +5667,9 @@ get "/videoplayback" do |env|
end end
get "/ggpht/*" do |env| get "/ggpht/*" do |env|
host = "https://yt3.ggpht.com"
client = make_client(URI.parse(host))
url = env.request.path.lchop("/ggpht") url = env.request.path.lchop("/ggpht")
headers = HTTP::Headers.new headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
REQUEST_HEADERS_WHITELIST.each do |header| REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]? if env.request.headers[header]?
headers[header] = env.request.headers[header] headers[header] = env.request.headers[header]
@@ -5652,10 +5677,10 @@ get "/ggpht/*" do |env|
end end
begin begin
client.get(url, headers) do |response| YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5685,16 +5710,16 @@ get "/sb/:id/:storyboard/:index" do |env|
storyboard = env.params.url["storyboard"] storyboard = env.params.url["storyboard"]
index = env.params.url["index"] index = env.params.url["index"]
if storyboard.starts_with? "storyboard_live"
host = "https://i.ytimg.com"
else
host = "https://i9.ytimg.com"
end
client = make_client(URI.parse(host))
url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
headers = HTTP::Headers.new headers = HTTP::Headers.new
if storyboard.starts_with? "storyboard_live"
headers[":authority"] = "i.ytimg.com"
else
headers[":authority"] = "i9.ytimg.com"
end
REQUEST_HEADERS_WHITELIST.each do |header| REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]? if env.request.headers[header]?
headers[header] = env.request.headers[header] headers[header] = env.request.headers[header]
@@ -5702,14 +5727,15 @@ get "/sb/:id/:storyboard/:index" do |env|
end end
begin begin
client.get(url, headers) do |response| YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
env.response.headers["Connection"] = "close"
env.response.headers["Access-Control-Allow-Origin"] = "*" env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300 if response.status_code >= 300
@@ -5727,11 +5753,9 @@ get "/s_p/:id/:name" do |env|
id = env.params.url["id"] id = env.params.url["id"]
name = env.params.url["name"] name = env.params.url["name"]
host = "https://i9.ytimg.com"
client = make_client(URI.parse(host))
url = env.request.resource url = env.request.resource
headers = HTTP::Headers.new headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
REQUEST_HEADERS_WHITELIST.each do |header| REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]? if env.request.headers[header]?
headers[header] = env.request.headers[header] headers[header] = env.request.headers[header]
@@ -5739,10 +5763,10 @@ get "/s_p/:id/:name" do |env|
end end
begin begin
client.get(url, headers) do |response| YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5772,7 +5796,7 @@ get "/yts/img/:name" do |env|
YT_POOL.client &.get(env.request.resource, headers) do |response| YT_POOL.client &.get(env.request.resource, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5794,9 +5818,11 @@ get "/vi/:id/:name" do |env|
id = env.params.url["id"] id = env.params.url["id"]
name = env.params.url["name"] name = env.params.url["name"]
headers = HTTP::Headers{":authority" => "i.ytimg.com"}
if name == "maxres.jpg" if name == "maxres.jpg"
build_thumbnails(id, config, Kemal.config).each do |thumb| build_thumbnails(id, config, Kemal.config).each do |thumb|
if YT_IMG_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200 if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
name = thumb[:url] + ".jpg" name = thumb[:url] + ".jpg"
break break
end end
@@ -5804,7 +5830,6 @@ get "/vi/:id/:name" do |env|
end end
url = "/vi/#{id}/#{name}" url = "/vi/#{id}/#{name}"
headers = HTTP::Headers.new
REQUEST_HEADERS_WHITELIST.each do |header| REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]? if env.request.headers[header]?
headers[header] = env.request.headers[header] headers[header] = env.request.headers[header]
@@ -5812,10 +5837,10 @@ get "/vi/:id/:name" do |env|
end end
begin begin
YT_IMG_POOL.client &.get(url, headers) do |response| YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
response.headers.each do |key, value| response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value env.response.headers[key] = value
end end
end end
@@ -5834,12 +5859,75 @@ get "/vi/:id/:name" do |env|
end end
get "/Captcha" do |env| get "/Captcha" do |env|
client = make_client(LOGIN_URL) headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = client.get(env.request.resource) response = YT_POOL.client &.get(env.request.resource, headers)
env.response.headers["Content-Type"] = response.headers["Content-Type"] env.response.headers["Content-Type"] = response.headers["Content-Type"]
response.body response.body
end end
connect "*" do |env|
if CONFIG.proxy_address.empty?
env.response.status_code = 400
next
end
url = env.request.headers["Host"]?.try { |u| u.split(":") }
host = url.try &.[0]?
port = url.try &.[1]?
host = "www.google.com" if !host || host.empty?
port = "443" if !port || port.empty?
# if env.request.internal_uri
# env.request.internal_uri.not_nil!.path = "#{host}:#{port}"
# end
user, pass = env.request.headers["Proxy-Authorization"]?
.try { |i| i.lchop("Basic ") }
.try { |i| Base64.decode_string(i) }
.try &.split(":", 2) || {nil, nil}
if CONFIG.proxy_user != user || CONFIG.proxy_pass != pass
env.response.status_code = 403
next
end
begin
upstream = TCPSocket.new(host, port)
rescue ex
logger.puts("Exception: #{ex.message}")
env.response.status_code = 400
next
end
env.response.reset
env.response.upgrade do |downstream|
downstream = downstream.as(TCPSocket)
downstream.sync = true
spawn do
begin
bytes = 1
while bytes != 0
bytes = IO.copy upstream, downstream
end
rescue ex
end
end
begin
bytes = 1
while bytes != 0
bytes = IO.copy downstream, upstream
end
rescue ex
ensure
upstream.close
downstream.close
end
end
end
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos # Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
get "/watch_videos" do |env| get "/watch_videos" do |env|
response = YT_POOL.client &.get(env.request.resource) response = YT_POOL.client &.get(env.request.resource)
@@ -5859,7 +5947,7 @@ error 404 do |env|
response = YT_POOL.client &.get("/#{item}") response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301 if response.status_code == 301
response = YT_POOL.client &.get(response.headers["Location"]) response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).full_path)
end end
if response.body.empty? if response.body.empty?
@@ -5868,10 +5956,10 @@ error 404 do |env|
end end
html = XML.parse_html(response.body) html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//meta[@itemprop="channelId"])) ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
if ucid if ucid
env.response.headers["Location"] = "/channel/#{ucid["content"]}" env.response.headers["Location"] = "/channel/#{ucid}"
halt env, status_code: 302 halt env, status_code: 302
end end
@@ -5914,6 +6002,7 @@ end
public_folder "assets" public_folder "assets"
Kemal.config.powered_by_header = false Kemal.config.powered_by_header = false
add_handler ProxyHandler.new
add_handler FilteredCompressHandler.new add_handler FilteredCompressHandler.new
add_handler APIHandler.new add_handler APIHandler.new
add_handler AuthHandler.new add_handler AuthHandler.new

View File

@@ -215,7 +215,17 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated) url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated)
response = YT_POOL.client &.get(url) response = YT_POOL.client &.get(url)
json = JSON.parse(response.body)
begin
json = JSON.parse(response.body)
rescue ex
if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") ||
response.body.includes?("https://www.google.com/sorry/index")
raise "Could not extract channel info. Instance is likely blocked."
end
raise "Could not extract JSON"
end
if json["content_html"]? && !json["content_html"].as_s.empty? if json["content_html"]? && !json["content_html"].as_s.empty?
document = XML.parse_html(json["content_html"].as_s) document = XML.parse_html(json["content_html"].as_s)
@@ -263,7 +273,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
views: views, views: views,
) )
emails = db.query_all("UPDATE users SET notifications = notifications || $1 \ emails = db.query_all("UPDATE users SET notifications = array_append(notifications, $1) \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
video.id, video.published, ucid, as: String) video.id, video.published, ucid, as: String)
@@ -332,7 +342,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
# so since they don't provide a published date here we can safely ignore them. # so since they don't provide a published date here we can safely ignore them.
if Time.utc - video.published > 1.minute if Time.utc - video.published > 1.minute
emails = db.query_all("UPDATE users SET notifications = notifications || $1 \ emails = db.query_all("UPDATE users SET notifications = array_append(notifications, $1) \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
video.id, video.published, video.ucid, as: String) video.id, video.published, video.ucid, as: String)
@@ -373,7 +383,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
end end
def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
if continuation if continuation || auto_generated
url = produce_channel_playlists_url(ucid, continuation, sort_by, auto_generated) url = produce_channel_playlists_url(ucid, continuation, sort_by, auto_generated)
response = YT_POOL.client &.get(url) response = YT_POOL.client &.get(url)
@@ -392,13 +402,6 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
html = XML.parse_html(json["content_html"].as_s) html = XML.parse_html(json["content_html"].as_s)
nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
elsif auto_generated
url = "/channel/#{ucid}"
response = YT_POOL.client &.get(url)
html = XML.parse_html(response.body)
nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")]))
else else
url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1" url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1"
@@ -409,6 +412,7 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
url += "&sort=da" url += "&sort=da"
when "newest", "newest_created" when "newest", "newest_created"
url += "&sort=dd" url += "&sort=dd"
else nil # Ignore
end end
response = YT_POOL.client &.get(url) response = YT_POOL.client &.get(url)
@@ -466,6 +470,7 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64
when "oldest" when "oldest"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64
else nil # Ignore
end end
object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
@@ -494,10 +499,10 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
}, },
} }
if !auto_generated if cursor
cursor = Base64.urlsafe_encode(cursor, false) cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
end end
object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
if auto_generated if auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64
@@ -510,6 +515,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64
when "last", "last_added" when "last", "last_added"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64
else nil # Ignore
end end
end end
@@ -530,8 +536,17 @@ def extract_channel_playlists_cursor(url, auto_generated)
.try { |i| Base64.decode(i) } .try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) } .try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) } .try { |i| Protodec::Any.parse(i) }
.try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h.find { |k, v| k.starts_with?("15:") } } .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h.find { |k, v| k.starts_with? "15:" } }
.try &.[1].as_s || "" .try &.[1]
if cursor.try &.as_h?
cursor = cursor.try { |i| Protodec::Any.cast_json(i.as_h) }
.try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) } || ""
else
cursor = cursor.try &.as_s || ""
end
if !auto_generated if !auto_generated
cursor = URI.decode_www_form(cursor) cursor = URI.decode_www_form(cursor)
@@ -544,11 +559,11 @@ end
# TODO: Add "sort_by" # TODO: Add "sort_by"
def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode) def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode)
response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en")
if response.status_code == 404 if response.status_code != 200
response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en")
end end
if response.status_code == 404 if response.status_code != 200
error_message = translate(locale, "This channel does not exist.") error_message = translate(locale, "This channel does not exist.")
raise error_message raise error_message
end end
@@ -616,15 +631,13 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config, fo
post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
if !post next if !post
next
end
if !post["contentText"]? if !post["contentText"]?
content_html = "" content_html = ""
else else
content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s || content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s ||
content_to_comment_html(post["contentText"]["runs"].as_a).try &.to_s || "" post["contentText"]["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || ""
end end
author = post["authorText"]?.try &.["simpleText"]? || "" author = post["authorText"]?.try &.["simpleText"]? || ""
@@ -797,7 +810,7 @@ def produce_channel_community_continuation(ucid, cursor)
object = { object = {
"80226972:embedded" => { "80226972:embedded" => {
"2:string" => ucid, "2:string" => ucid,
"3:string" => cursor, "3:string" => cursor || "",
}, },
} }
@@ -835,7 +848,7 @@ end
def get_about_info(ucid, locale) def get_about_info(ucid, locale)
about = YT_POOL.client &.get("/channel/#{ucid}/about?disable_polymer=1&gl=US&hl=en") about = YT_POOL.client &.get("/channel/#{ucid}/about?disable_polymer=1&gl=US&hl=en")
if about.status_code == 404 if about.status_code != 200
about = YT_POOL.client &.get("/user/#{ucid}/about?disable_polymer=1&gl=US&hl=en") about = YT_POOL.client &.get("/user/#{ucid}/about?disable_polymer=1&gl=US&hl=en")
end end
@@ -843,6 +856,11 @@ def get_about_info(ucid, locale)
raise ChannelRedirect.new(channel_id: md["ucid"]) raise ChannelRedirect.new(channel_id: md["ucid"])
end end
if about.status_code != 200
error_message = translate(locale, "This channel does not exist.")
raise error_message
end
about = XML.parse_html(about.body) about = XML.parse_html(about.body)
if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))

View File

@@ -150,8 +150,8 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so
node_comment = node["commentRenderer"] node_comment = node["commentRenderer"]
end end
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s || content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s ||
content_to_comment_html(node_comment["contentText"]["runs"].as_a).try &.to_s || "" node_comment["contentText"]["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || ""
author = node_comment["authorText"]?.try &.["simpleText"]? || "" author = node_comment["authorText"]?.try &.["simpleText"]? || ""
json.field "author", author json.field "author", author
@@ -294,7 +294,7 @@ def template_youtube_comments(comments, locale, thin_mode)
<div class="pure-u-23-24"> <div class="pure-u-23-24">
<p> <p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}" <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a> data-onclick="get_youtube_replies">#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a>
</p> </p>
</div> </div>
</div> </div>
@@ -347,7 +347,7 @@ def template_youtube_comments(comments, locale, thin_mode)
END_HTML END_HTML
else else
html << <<-END_HTML html << <<-END_HTML
<iframe id='ivplayer' type='text/html' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' frameborder='0'></iframe> <iframe id='ivplayer' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' style='border:none;'></iframe>
END_HTML END_HTML
end end
@@ -356,6 +356,7 @@ def template_youtube_comments(comments, locale, thin_mode)
</div> </div>
</div> </div>
END_HTML END_HTML
else nil # Ignore
end end
end end
@@ -413,7 +414,7 @@ def template_youtube_comments(comments, locale, thin_mode)
<div class="pure-u-1"> <div class="pure-u-1">
<p> <p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}" <a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
onclick="get_youtube_replies(this, true)">#{translate(locale, "Load more")}</a> data-onclick="get_youtube_replies" data-load-more>#{translate(locale, "Load more")}</a>
</p> </p>
</div> </div>
</div> </div>
@@ -451,7 +452,7 @@ def template_reddit_comments(root, locale)
html << <<-END_HTML html << <<-END_HTML
<p> <p>
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a> <a href="javascript:void(0)" data-onclick="toggle_parent">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b> <b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
#{translate(locale, "`x` points", number_with_separator(child.score))} #{translate(locale, "`x` points", number_with_separator(child.score))}
<span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span> <span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
@@ -556,7 +557,7 @@ def content_to_comment_html(content)
video_id = watch_endpoint["videoId"].as_s video_id = watch_endpoint["videoId"].as_s
if length_seconds if length_seconds
text = %(<a href="javascript:void(0)" onclick="player.currentTime(#{length_seconds})">#{text}</a>) text = %(<a href="javascript:void(0)" data-onclick="jump_to_time" data-jump-time="#{length_seconds}">#{text}</a>)
else else
text = %(<a href="/watch?v=#{video_id}">#{text}</a>) text = %(<a href="/watch?v=#{video_id}">#{text}</a>)
end end
@@ -609,6 +610,8 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
when "new", "newest" when "new", "newest"
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64
else # top
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
end end
continuation = object.try { |i| Protodec::Any.cast_json(object) } continuation = object.try { |i| Protodec::Any.cast_json(object) }

View File

@@ -213,28 +213,31 @@ class DenyFrame < Kemal::Handler
end end
end end
# Temp fixes for https://github.com/crystal-lang/crystal/issues/7383 class ProxyHandler < Kemal::Handler
class HTTP::UnknownLengthContent def call(env)
def read_byte if env.request.headers["Proxy-Authorization"]? && env.request.method != "CONNECT"
ensure_send_continue user, pass = env.request.headers["Proxy-Authorization"]?
if @io.is_a?(OpenSSL::SSL::Socket::Client) .try { |i| i.lchop("Basic ") }
return if @io.as(OpenSSL::SSL::Socket::Client).@in_buffer_rem.empty? .try { |i| Base64.decode_string(i) }
end .try &.split(":", 2) || {nil, nil}
@io.read_byte
end
end
class HTTP::Client if CONFIG.proxy_user != user || CONFIG.proxy_pass != pass
private def handle_response(response) env.response.status_code = 403
if @socket.is_a?(OpenSSL::SSL::Socket::Client) && @host.ends_with?("googlevideo.com") return
close unless response.keep_alive? || @socket.as(OpenSSL::SSL::Socket::Client).@in_buffer_rem.empty?
if @socket.as(OpenSSL::SSL::Socket::Client).@in_buffer_rem.empty?
@socket = nil
end end
HTTP::Client.exec(env.request.method, "#{env.request.headers["Host"]?}#{env.request.resource}", env.request.headers, env.request.body) do |response|
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "transfer-encoding"
env.response.headers[key] = value
end
end
IO.copy response.body_io, env.response
end
env.response.close
return
else else
close unless response.keep_alive? call_next env
end end
response
end end
end end

View File

@@ -173,6 +173,8 @@ struct Config
yaml.scalar "ipv4" yaml.scalar "ipv4"
when Socket::Family::INET6 when Socket::Family::INET6
yaml.scalar "ipv6" yaml.scalar "ipv6"
when Socket::Family::UNIX
raise "Invalid socket family #{value}"
end end
end end
@@ -223,6 +225,8 @@ struct Config
else else
return false return false
end end
else
return false
end end
end end
@@ -259,6 +263,10 @@ struct Config
admin_email: {type: String, default: "omarroth@protonmail.com"}, # Email for bug reports admin_email: {type: String, default: "omarroth@protonmail.com"}, # Email for bug reports
cookies: {type: HTTP::Cookies, default: HTTP::Cookies.new, converter: StringToCookies}, # Saved cookies in "name1=value1; name2=value2..." format cookies: {type: HTTP::Cookies, default: HTTP::Cookies.new, converter: StringToCookies}, # Saved cookies in "name1=value1; name2=value2..." format
captcha_key: {type: String?, default: nil}, # Key for Anti-Captcha captcha_key: {type: String?, default: nil}, # Key for Anti-Captcha
proxy_address: {type: String, default: ""},
proxy_port: {type: Int32, default: 8080},
proxy_user: {type: String, default: ""},
proxy_pass: {type: String, default: ""},
}) })
end end
@@ -520,9 +528,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list") or contains(@class, "expanded-shelf-content-list")]/li)).each do |child_node| shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list") or contains(@class, "expanded-shelf-content-list")]/li)).each do |child_node|
type = child_node.xpath_node(%q(./div)) type = child_node.xpath_node(%q(./div))
if !type next if !type
next
end
case type["class"] case type["class"]
when .includes? "yt-lockup-video" when .includes? "yt-lockup-video"
@@ -599,6 +605,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
videos: videos, videos: videos,
thumbnail: playlist_thumbnail thumbnail: playlist_thumbnail
) )
else
next # Skip
end end
end end
@@ -732,9 +740,7 @@ def cache_annotation(db, id, annotations)
body = XML.parse(annotations) body = XML.parse(annotations)
nodeset = body.xpath_nodes(%q(/document/annotations/annotation)) nodeset = body.xpath_nodes(%q(/document/annotations/annotation))
if nodeset == 0 return if nodeset == 0
return
end
has_legacy_annotations = false has_legacy_annotations = false
nodeset.each do |node| nodeset.each do |node|
@@ -765,7 +771,7 @@ def create_notification_stream(env, config, kemal_config, decrypt_function, topi
loop do loop do
time_span = [0, 0, 0, 0] time_span = [0, 0, 0, 0]
time_span[rand(4)] = rand(30) + 5 time_span[rand(4)] = rand(30) + 5
published = Time.utc - Time::Span.new(time_span[0], time_span[1], time_span[2], time_span[3]) published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)] video_id = TEST_IDS[rand(TEST_IDS.size)]
video = get_video(video_id, PG_DB) video = get_video(video_id, PG_DB)

View File

@@ -24,6 +24,8 @@ def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text
if !locale[translation].as_s.empty? if !locale[translation].as_s.empty?
translation = locale[translation].as_s translation = locale[translation].as_s
end end
else
raise "Invalid translation #{translation}"
end end
end end

View File

@@ -127,8 +127,6 @@ def subscribe_to_feeds(db, logger, key, config)
end end
max_channel = Channel(Int32).new max_channel = Channel(Int32).new
client_pool = HTTPPool.new(PUBSUB_URL, capacity: max_threads, timeout: 0.05)
spawn do spawn do
max_threads = max_channel.receive max_threads = max_channel.receive
active_threads = 0 active_threads = 0
@@ -149,7 +147,7 @@ def subscribe_to_feeds(db, logger, key, config)
spawn do spawn do
begin begin
response = subscribe_pubsub(ucid, key, config, client_pool) response = subscribe_pubsub(ucid, key, config)
if response.status_code >= 400 if response.status_code >= 400
logger.puts("#{ucid} : #{response.body}") logger.puts("#{ucid} : #{response.body}")
@@ -238,60 +236,147 @@ end
def bypass_captcha(captcha_key, logger) def bypass_captcha(captcha_key, logger)
loop do loop do
begin begin
response = YT_POOL.client &.get("/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") {"/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")}.each do |path|
if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") response = YT_POOL.client &.get(path)
html = XML.parse_html(response.body) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.")
form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil! html = XML.parse_html(response.body)
site_key = form.xpath_node(%(.//div[@class="g-recaptcha"])).try &.["data-sitekey"] form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil!
site_key = form.xpath_node(%(.//div[@class="g-recaptcha"])).try &.["data-sitekey"]
inputs = {} of String => String inputs = {} of String => String
form.xpath_nodes(%(.//input[@name])).map do |node| form.xpath_nodes(%(.//input[@name])).map do |node|
inputs[node["name"]] = node["value"] inputs[node["name"]] = node["value"]
end
headers = response.cookies.add_request_headers(HTTP::Headers.new)
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: {
"clientKey" => CONFIG.captcha_key,
"task" => {
"type" => "NoCaptchaTaskProxyless",
# "type" => "NoCaptchaTask",
"websiteURL" => "https://www.youtube.com/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999",
"websiteKey" => site_key,
# "proxyType" => "http",
# "proxyAddress" => CONFIG.proxy_address,
# "proxyPort" => CONFIG.proxy_port,
# "proxyLogin" => CONFIG.proxy_user,
# "proxyPassword" => CONFIG.proxy_pass,
# "userAgent" => "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36",
},
}.to_json).body)
if response["error"]?
raise response["error"].as_s
end
task_id = response["taskId"].as_i
loop do
sleep 10.seconds
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: {
"clientKey" => CONFIG.captcha_key,
"taskId" => task_id,
}.to_json).body)
if response["status"]?.try &.== "ready"
break
elsif response["errorId"]?.try &.as_i != 0
raise response["errorDescription"].as_s
end end
headers = response.cookies.add_request_headers(HTTP::Headers.new)
captcha_client = HTTPClient.new(URI.parse("https://api.anti-captcha.com"))
captcha_client.family = CONFIG.force_resolve || Socket::Family::INET
if !CONFIG.proxy_address.empty?
response = JSON.parse(captcha_client.post("/createTask", body: {
"clientKey" => CONFIG.captcha_key,
"task" => {
"type" => "NoCaptchaTask",
"websiteURL" => "https://www.youtube.com#{path}",
"websiteKey" => site_key,
"proxyType" => "http",
"proxyAddress" => CONFIG.proxy_address,
"proxyPort" => CONFIG.proxy_port,
"proxyLogin" => CONFIG.proxy_user,
"proxyPassword" => CONFIG.proxy_pass,
"userAgent" => headers["user-agent"],
},
}.to_json).body)
else
response = JSON.parse(captcha_client.post("/createTask", body: {
"clientKey" => CONFIG.captcha_key,
"task" => {
"type" => "NoCaptchaTaskProxyless",
"websiteURL" => "https://www.youtube.com#{path}",
"websiteKey" => site_key,
"userAgent" => headers["user-agent"],
},
}.to_json).body)
end
raise response["error"].as_s if response["error"]?
task_id = response["taskId"].as_i
loop do
sleep 10.seconds
response = JSON.parse(captcha_client.post("/getTaskResult", body: {
"clientKey" => CONFIG.captcha_key,
"taskId" => task_id,
}.to_json).body)
if response["status"]?.try &.== "ready"
break
elsif response["errorId"]?.try &.as_i != 0
raise response["errorDescription"].as_s
end
end
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
response = YT_POOL.client &.post("/das_captcha", headers, form: inputs)
yield response.cookies.select { |cookie| cookie.name != "PREF" }
elsif response.headers["Location"]?.try &.includes?("/sorry/index")
location = response.headers["Location"].try { |u| URI.parse(u) }
headers = HTTP::Headers{
":authority" => location.host.not_nil!,
"origin" => "https://www.google.com",
"user-agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36",
}
response = YT_POOL.client &.get(location.full_path, headers)
html = XML.parse_html(response.body)
form = html.xpath_node(%(//form[@action="index"])).not_nil!
site_key = form.xpath_node(%(.//div[@class="g-recaptcha"])).try &.["data-sitekey"]
inputs = {} of String => String
form.xpath_nodes(%(.//input[@name])).map do |node|
inputs[node["name"]] = node["value"]
end
captcha_client = HTTPClient.new(URI.parse("https://api.anti-captcha.com"))
captcha_client.family = CONFIG.force_resolve || Socket::Family::INET
if !CONFIG.proxy_address.empty?
response = JSON.parse(captcha_client.post("/createTask", body: {
"clientKey" => CONFIG.captcha_key,
"task" => {
"type" => "NoCaptchaTask",
"websiteURL" => location.to_s,
"websiteKey" => site_key,
"proxyType" => "http",
"proxyAddress" => CONFIG.proxy_address,
"proxyPort" => CONFIG.proxy_port,
"proxyLogin" => CONFIG.proxy_user,
"proxyPassword" => CONFIG.proxy_pass,
"userAgent" => headers["user-agent"],
},
}.to_json).body)
else
response = JSON.parse(captcha_client.post("/createTask", body: {
"clientKey" => CONFIG.captcha_key,
"task" => {
"type" => "NoCaptchaTaskProxyless",
"websiteURL" => location.to_s,
"websiteKey" => site_key,
"userAgent" => headers["user-agent"],
},
}.to_json).body)
end
raise response["error"].as_s if response["error"]?
task_id = response["taskId"].as_i
loop do
sleep 10.seconds
response = JSON.parse(captcha_client.post("/getTaskResult", body: {
"clientKey" => CONFIG.captcha_key,
"taskId" => task_id,
}.to_json).body)
if response["status"]?.try &.== "ready"
break
elsif response["errorId"]?.try &.as_i != 0
raise response["errorDescription"].as_s
end
end
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
headers["content-type"] = "application/x-www-form-urlencoded"
headers["referer"] = location.to_s
response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs)
headers = HTTP::Headers{
"Cookie" => URI.parse(response.headers["location"]).query_params["google_abuse"].split(";")[0],
}
cookies = HTTP::Cookies.from_headers(headers)
yield cookies
end end
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
response = YT_POOL.client &.post("/das_captcha", headers, form: inputs)
yield response.cookies.select { |cookie| cookie.name != "PREF" }
end end
rescue ex rescue ex
logger.puts("Exception: #{ex.message}") logger.puts("Exception: #{ex.message}")

View File

@@ -1,3 +1,7 @@
def connect(path : String, &block : HTTP::Server::Context -> _)
Kemal::RouteHandler::INSTANCE.add_route("CONNECT", path, &block)
end
# See https://github.com/crystal-lang/crystal/issues/2963 # See https://github.com/crystal-lang/crystal/issues/2963
class HTTPProxy class HTTPProxy
getter proxy_host : String getter proxy_host : String
@@ -124,7 +128,7 @@ def get_nova_proxies(country_code = "US")
client.connect_timeout = 10.seconds client.connect_timeout = 10.seconds
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
headers["Host"] = "www.proxynova.com" headers["Host"] = "www.proxynova.com"
@@ -161,7 +165,7 @@ def get_spys_proxies(country_code = "US")
client.connect_timeout = 10.seconds client.connect_timeout = 10.seconds
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
headers["Host"] = "spys.one" headers["Host"] = "spys.one"

View File

@@ -1,69 +1,53 @@
alias SigProc = Proc(Array(String), Int32, Array(String))
def fetch_decrypt_function(id = "CvFH_6DNRCY") def fetch_decrypt_function(id = "CvFH_6DNRCY")
document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1").body document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1").body
url = document.match(/src="(?<url>\/yts\/jsbin\/player_ias-.{9}\/en_US\/base.js)"/).not_nil!["url"] url = document.match(/src="(?<url>.*player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"]
player = YT_POOL.client &.get(url).body player = YT_POOL.client &.get(url).body
function_name = player.match(/^(?<name>[^=]+)=function\(a\){a=a\.split\(""\)/m).not_nil!["name"] function_name = player.match(/^(?<name>[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"]
function_body = player.match(/^#{Regex.escape(function_name)}=function\(a\){(?<body>[^}]+)}/m).not_nil!["body"] function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?<body>[^}]+)}/m).not_nil!["body"]
function_body = function_body.split(";")[1..-2] function_body = function_body.split(";")[1..-2]
var_name = function_body[0][0, 2] var_name = function_body[0][0, 2]
var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?<body>(.*?))};/).not_nil!["body"] var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?<body>(.*?))};/).not_nil!["body"]
operations = {} of String => String operations = {} of String => SigProc
var_body.split("},").each do |operation| var_body.split("},").each do |operation|
op_name = operation.match(/^[^:]+/).not_nil![0] op_name = operation.match(/^[^:]+/).not_nil![0]
op_body = operation.match(/\{[^}]+/).not_nil![0] op_body = operation.match(/\{[^}]+/).not_nil![0]
case op_body case op_body
when "{a.reverse()" when "{a.reverse()"
operations[op_name] = "a" operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse }
when "{a.splice(0,b)" when "{a.splice(0,b)"
operations[op_name] = "b" operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a }
else else
operations[op_name] = "c" operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a }
end end
end end
decrypt_function = [] of {name: String, value: Int32} decrypt_function = [] of {SigProc, Int32}
function_body.each do |function| function_body.each do |function|
function = function.lchop(var_name).delete("[].") function = function.lchop(var_name).delete("[].")
op_name = function.match(/[^\(]+/).not_nil![0] op_name = function.match(/[^\(]+/).not_nil![0]
value = function.match(/\(a,(?<value>[\d]+)\)/).not_nil!["value"].to_i value = function.match(/\(\w,(?<value>[\d]+)\)/).not_nil!["value"].to_i
decrypt_function << {name: operations[op_name], value: value} decrypt_function << {operations[op_name], value}
end end
return decrypt_function return decrypt_function
end end
def decrypt_signature(fmt, code) def decrypt_signature(fmt, op)
if !fmt["s"]? return "" if !fmt["s"]? || !fmt["sp"]?
return ""
sp = fmt["sp"]
sig = fmt["s"].split("")
op.each do |proc, value|
sig = proc.call(sig, value)
end end
a = fmt["s"] return "&#{sp}=#{sig.join("")}"
a = a.split("")
code.each do |item|
case item[:name]
when "a"
a.reverse!
when "b"
a.delete_at(0..(item[:value] - 1))
when "c"
a = splice(a, item[:value])
end
end
signature = a.join("")
return "&#{fmt["sp"]?}=#{signature}"
end
def splice(a, b)
c = a[0]
a[0] = a[b % a.size]
a[b % a.size] = c
return a
end end

View File

@@ -1,3 +1,5 @@
require "crypto/subtle"
def generate_token(email, scopes, expire, key, db) def generate_token(email, scopes, expire, key, db)
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc)
@@ -41,15 +43,10 @@ def sign_token(key, hash)
string_to_sign = [] of String string_to_sign = [] of String
hash.each do |key, value| hash.each do |key, value|
if key == "signature" next if key == "signature"
next
end
if value.is_a?(JSON::Any) if value.is_a?(JSON::Any) && value.as_a?
case value value = value.as_a.map { |i| i.as_s }
when .as_a?
value = value.as_a.map { |item| item.as_s }
end
end end
case value case value
@@ -76,14 +73,25 @@ def validate_request(token, session, request, key, db, locale = nil)
raise translate(locale, "Hidden field \"token\" is a required field") raise translate(locale, "Hidden field \"token\" is a required field")
end end
if token["signature"] != sign_token(key, token) expire = token["expire"]?.try &.as_i
raise translate(locale, "Invalid signature") if expire.try &.< Time.utc.to_unix
raise translate(locale, "Token is expired, please try again")
end end
if token["session"] != session if token["session"] != session
raise translate(locale, "Erroneous token") raise translate(locale, "Erroneous token")
end end
scopes = token["scopes"].as_a.map { |v| v.as_s }
scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
if !scopes_include_scope(scopes, scope)
raise translate(locale, "Invalid scope")
end
if !Crypto::Subtle.constant_time_compare(token["signature"].to_s, sign_token(key, token))
raise translate(locale, "Invalid signature")
end
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
if nonce[1] > Time.utc if nonce[1] > Time.utc
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0]) db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
@@ -92,18 +100,6 @@ def validate_request(token, session, request, key, db, locale = nil)
end end
end end
scopes = token["scopes"].as_a.map { |v| v.as_s }
scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
if !scopes_include_scope(scopes, scope)
raise translate(locale, "Invalid scope")
end
expire = token["expire"]?.try &.as_i
if expire.try &.< Time.utc.to_unix
raise translate(locale, "Token is expired, please try again")
end
return {scopes, expire, token["signature"].as_s} return {scopes, expire, token["signature"].as_s}
end end

View File

@@ -2,69 +2,16 @@ require "lsquic"
require "pool/connection" require "pool/connection"
def add_yt_headers(request) def add_yt_headers(request)
request.headers["x-youtube-client-name"] ||= "1" return if request.resource.starts_with? "/sorry/index"
request.headers["x-youtube-client-version"] ||= "1.20180719"
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["accept-language"] ||= "en-us,en;q=0.5" request.headers["accept-language"] ||= "en-us,en;q=0.5"
request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" request.headers["x-youtube-client-name"] ||= "1"
end request.headers["x-youtube-client-version"] ||= "1.20180719"
if !CONFIG.cookies.empty?
struct HTTPPool request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
property! url : URI
property! capacity : Int32
property! timeout : Float64
property pool : ConnectionPool(HTTPClient)
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
@url = url
@pool = build_pool
end
def client(region = nil, &block)
conn = pool.checkout
begin
if region
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
begin
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
conn.set_proxy(proxy)
break
rescue ex
end
end
end
response = yield conn
if region
conn.unset_proxy
end
response
rescue ex
conn = HTTPClient.new(url)
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
conn.family = (url.host == "www.youtube.com" || url.host == "suggestqueries.google.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
conn.read_timeout = 10.seconds
conn.connect_timeout = 10.seconds
yield conn
ensure
pool.checkin(conn)
end
end
private def build_pool
ConnectionPool(HTTPClient).new(capacity: capacity, timeout: timeout) do
client = HTTPClient.new(url)
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
client.family = (url.host == "www.youtube.com" || url.host == "suggestqueries.google.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
client
end
end end
end end
@@ -72,38 +19,43 @@ struct QUICPool
property! url : URI property! url : URI
property! capacity : Int32 property! capacity : Int32
property! timeout : Float64 property! timeout : Float64
property pool : ConnectionPool(QUIC::Client)
def initialize(url : URI, @capacity = 5, @timeout = 5.0) def initialize(url : URI, @capacity = 5, @timeout = 5.0)
@url = url @url = url
@pool = build_pool
end end
def client(region = nil, &block) def client(region = nil, &block)
begin if region
if region conn = make_client(url, region)
client = HTTPClient.new(url) response = yield conn
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" else
client.read_timeout = 10.seconds conn = pool.checkout
client.connect_timeout = 10.seconds begin
response = yield conn
PROXY_LIST[region]?.try &.sample(40).each do |proxy| rescue ex
begin conn.close
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
client.set_proxy(proxy)
break
rescue ex
end
end
yield client
else
conn = QUIC::Client.new(url) conn = QUIC::Client.new(url)
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
yield conn response = yield conn
ensure
pool.checkin(conn)
end end
rescue ex end
response
end
private def build_pool
ConnectionPool(QUIC::Client).new(capacity: capacity, timeout: timeout) do
conn = QUIC::Client.new(url) conn = QUIC::Client.new(url)
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
yield conn conn
end end
end end
end end
@@ -129,7 +81,8 @@ def elapsed_text(elapsed)
end end
def make_client(url : URI, region = nil) def make_client(url : URI, region = nil)
client = HTTPClient.new(url) # TODO: Migrate any applicable endpoints to QUIC
client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
client.read_timeout = 10.seconds client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds client.connect_timeout = 10.seconds
@@ -151,7 +104,7 @@ end
def decode_length_seconds(string) def decode_length_seconds(string)
length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i
length_seconds = [0] * (3 - length_seconds.size) + length_seconds length_seconds = [0] * (3 - length_seconds.size) + length_seconds
length_seconds = Time::Span.new(length_seconds[0], length_seconds[1], length_seconds[2]) length_seconds = Time::Span.new hours: length_seconds[0], minutes: length_seconds[1], seconds: length_seconds[2]
length_seconds = length_seconds.total_seconds.to_i length_seconds = length_seconds.total_seconds.to_i
return length_seconds return length_seconds
@@ -213,6 +166,7 @@ def decode_date(string : String)
return Time.utc return Time.utc
when "yesterday" when "yesterday"
return Time.utc - 1.day return Time.utc - 1.day
else nil # Continue
end end
# String matches format "20 hours ago", "4 months ago"... # String matches format "20 hours ago", "4 months ago"...
@@ -367,7 +321,7 @@ def get_referer(env, fallback = "/", unroll = true)
end end
referer = referer.full_path referer = referer.full_path
referer = "/" + referer.lstrip("\/\\") referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\")
if referer == env.request.path if referer == env.request.path
referer = fallback referer = fallback
@@ -376,50 +330,13 @@ def get_referer(env, fallback = "/", unroll = true)
return referer return referer
end end
struct VarInt
def self.from_io(io : IO, format = IO::ByteFormat::NetworkEndian) : Int32
result = 0_u32
num_read = 0
loop do
byte = io.read_byte
raise "Invalid VarInt" if !byte
value = byte & 0x7f
result |= value.to_u32 << (7 * num_read)
num_read += 1
break if byte & 0x80 == 0
raise "Invalid VarInt" if num_read > 5
end
result.to_i32
end
def self.to_io(io : IO, value : Int32)
io.write_byte 0x00 if value == 0x00
value = value.to_u32
while value != 0
byte = (value & 0x7f).to_u8
value >>= 7
if value != 0
byte |= 0x80
end
io.write_byte byte
end
end
end
def sha256(text) def sha256(text)
digest = OpenSSL::Digest.new("SHA256") digest = OpenSSL::Digest.new("SHA256")
digest << text digest << text
return digest.hexdigest return digest.hexdigest
end end
def subscribe_pubsub(topic, key, config, client_pool) def subscribe_pubsub(topic, key, config)
case topic case topic
when .match(/^UC[A-Za-z0-9_-]{22}$/) when .match(/^UC[A-Za-z0-9_-]{22}$/)
topic = "channel_id=#{topic}" topic = "channel_id=#{topic}"
@@ -446,7 +363,7 @@ def subscribe_pubsub(topic, key, config, client_pool)
"hub.secret" => key.to_s, "hub.secret" => key.to_s,
} }
return client_pool.client &.post("/subscribe", form: body) return make_client(PUBSUB_URL).post("/subscribe", form: body)
end end
def parse_range(range) def parse_range(range)

View File

@@ -20,7 +20,7 @@ end
def fetch_mix(rdid, video_id, cookies = nil, locale = nil) def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36" headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
if cookies if cookies
headers = cookies.add_request_headers(headers) headers = cookies.add_request_headers(headers)

View File

@@ -310,6 +310,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
object["2:embedded"].as(Hash)["1:varint"] = 4_i64 object["2:embedded"].as(Hash)["1:varint"] = 4_i64
when "year" when "year"
object["2:embedded"].as(Hash)["1:varint"] = 5_i64 object["2:embedded"].as(Hash)["1:varint"] = 5_i64
else nil # Ignore
end end
case content_type case content_type
@@ -334,6 +335,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
object["2:embedded"].as(Hash)["3:varint"] = 1_i64 object["2:embedded"].as(Hash)["3:varint"] = 1_i64
when "long" when "long"
object["2:embedded"].as(Hash)["3:varint"] = 2_i64 object["2:embedded"].as(Hash)["3:varint"] = 2_i64
else nil # Ignore
end end
features.each do |feature| features.each do |feature|
@@ -358,6 +360,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
object["2:embedded"].as(Hash)["23:varint"] = 1_i64 object["2:embedded"].as(Hash)["23:varint"] = 1_i64
when "hdr" when "hdr"
object["2:embedded"].as(Hash)["25:varint"] = 1_i64 object["2:embedded"].as(Hash)["25:varint"] = 1_i64
else nil # Ignore
end end
end end

View File

@@ -1,6 +1,6 @@
def fetch_trending(trending_type, region, locale) def fetch_trending(trending_type, region, locale)
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
region ||= "US" region ||= "US"
region = region.upcase region = region.upcase
@@ -39,33 +39,13 @@ def fetch_trending(trending_type, region, locale)
end end
def extract_plid(url) def extract_plid(url)
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["bp"] plid = URI.parse(url)
.try { |i| HTTP::Params.parse(i.query.not_nil!)["bp"] }
wrapper = URI.decode_www_form(wrapper) .try { |i| URI.decode_www_form(i) }
wrapper = Base64.decode(wrapper) .try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
# 0xe2 0x02 0x2e .try { |i| Protodec::Any.parse(i) }
wrapper += 3 .try { |i| i["44:0:embedded"]["2:1:string"].as_s }
# 0x0a
wrapper += 1
# Looks like "/m/[a-z0-9]{5}", not sure what it does here
item_size = wrapper[0]
wrapper += 1
item = wrapper[0, item_size]
wrapper += item.size
# 0x12
wrapper += 1
plid_size = wrapper[0]
wrapper += 1
plid = wrapper[0, plid_size]
wrapper += plid.size
plid = String.new(plid)
return plid return plid
end end

View File

@@ -350,6 +350,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
notifications.sort_by! { |video| video.author } notifications.sort_by! { |video| video.author }
when "channel name - reverse" when "channel name - reverse"
notifications.sort_by! { |video| video.author }.reverse! notifications.sort_by! { |video| video.author }.reverse!
else nil # Ignore
end end
else else
if user.preferences.latest_only if user.preferences.latest_only
@@ -398,6 +399,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
videos.sort_by! { |video| video.author } videos.sort_by! { |video| video.author }
when "channel name - reverse" when "channel name - reverse"
videos.sort_by! { |video| video.author }.reverse! videos.sort_by! { |video| video.author }.reverse!
else nil # Ignore
end end
notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String)) notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String))

View File

@@ -562,8 +562,8 @@ struct Video
if fmt_stream["url"]? if fmt_stream["url"]?
fmt["url"] = fmt_stream["url"].as_s fmt["url"] = fmt_stream["url"].as_s
end end
if fmt_stream["cipher"]? if cipher = fmt_stream["cipher"]? || fmt_stream["signatureCipher"]?
HTTP::Params.parse(fmt_stream["cipher"].as_s).each do |key, value| HTTP::Params.parse(cipher.as_s).each do |key, value|
fmt[key] = value fmt[key] = value
end end
end end
@@ -621,10 +621,7 @@ struct Video
if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]? if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]?
fmts.as_a.each do |adaptive_fmt| fmts.as_a.each do |adaptive_fmt|
if !adaptive_fmt.as_h? next if !adaptive_fmt.as_h?
next
end
fmt = {} of String => String fmt = {} of String => String
if init = adaptive_fmt["initRange"]? if init = adaptive_fmt["initRange"]?
@@ -641,8 +638,8 @@ struct Video
if adaptive_fmt["url"]? if adaptive_fmt["url"]?
fmt["url"] = adaptive_fmt["url"].as_s fmt["url"] = adaptive_fmt["url"].as_s
end end
if adaptive_fmt["cipher"]? if cipher = adaptive_fmt["cipher"]? || adaptive_fmt["signatureCipher"]?
HTTP::Params.parse(adaptive_fmt["cipher"].as_s).each do |key, value| HTTP::Params.parse(cipher.as_s).each do |key, value|
fmt[key] = value fmt[key] = value
end end
end end
@@ -1253,6 +1250,7 @@ def fetch_video(id, region)
genre_url = "/channel/UCfFyYRYslvuhwMDnx6KjUvw" genre_url = "/channel/UCfFyYRYslvuhwMDnx6KjUvw"
when "Trailers" when "Trailers"
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g" genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
else nil # Ignore
end end
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || "" license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || ""

View File

@@ -20,12 +20,14 @@
<div class="pure-u-1 pure-u-lg-1-5"></div> <div class="pure-u-1 pure-u-lg-1-5"></div>
</div> </div>
<script> <script id="playlist_data" type="application/json">
var playlist_data = { <%=
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', {
} "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script> </script>
<script src="/js/playlist_widget.js"></script> <script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<div class="pure-g"> <div class="pure-g">
<% videos.each_slice(4) do |slice| %> <% videos.each_slice(4) do |slice| %>

View File

@@ -92,7 +92,7 @@
<div class="pure-g h-box"> <div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5"> <div class="pure-u-1 pure-u-lg-1-5">
<% if page > 1 %> <% if page > 1 %>
<a href="/channel/<%= channel.ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= sort_by %><% end %>"> <a href="/channel/<%= channel.ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
<%= translate(locale, "Previous page") %> <%= translate(locale, "Previous page") %>
</a> </a>
<% end %> <% end %>
@@ -100,7 +100,7 @@
<div class="pure-u-1 pure-u-lg-3-5"></div> <div class="pure-u-1 pure-u-lg-3-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
<% if count == 60 %> <% if count == 60 %>
<a href="/channel/<%= channel.ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= sort_by %><% end %>"> <a href="/channel/<%= channel.ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
<%= translate(locale, "Next page") %> <%= translate(locale, "Next page") %>
</a> </a>
<% end %> <% end %>

View File

@@ -71,14 +71,16 @@
</div> </div>
<% end %> <% end %>
<script> <script id="community_data" type="application/json">
var community_data = { <%=
ucid: '<%= channel.ucid %>', {
youtube_comments_text: '<%= HTML.escape(translate(locale, "View YouTube comments")) %>', "ucid" => channel.ucid,
comments_text: '<%= HTML.escape(translate(locale, "View `x` comments", "{commentCount}")) %>', "youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
hide_replies_text: '<%= HTML.escape(translate(locale, "Hide replies")) %>', "comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
show_replies_text: '<%= HTML.escape(translate(locale, "Show replies")) %>', "hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
preferences: <%= env.get("preferences").as(Preferences).to_json %>, "show_replies_text" => HTML.escape(translate(locale, "Show replies")),
} "preferences" => env.get("preferences").as(Preferences)
}.to_pretty_json
%>
</script> </script>
<script src="/js/community.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/community.js?v=<%= ASSET_COMMIT %>"></script>

View File

@@ -44,7 +44,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<p><%= item.title %></p> <p><%= HTML.escape(item.title) %></p>
</a> </a>
<p> <p>
<b> <b>
@@ -57,10 +57,10 @@
<div class="thumbnail"> <div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if plid = env.get?("remove_playlist_items") %> <% if plid = env.get?("remove_playlist_items") %>
<form onsubmit="return false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&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) || "") %>">
<p class="watched"> <p class="watched">
<a onclick="remove_playlist_item(this)" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)"> <a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
<i class="icon ion-md-trash"></i> <i class="icon ion-md-trash"></i>
</button> </button>
@@ -76,7 +76,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<p><a href="/watch?v=<%= item.id %>"><%= item.title %></a></p> <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p>
</a> </a>
<p> <p>
<b> <b>
@@ -103,13 +103,12 @@
<div class="thumbnail"> <div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %> <% if env.get? "show_watched" %>
<form onsubmit="return false" action="/watch_ajax?action_mark_watched=1&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="<%= 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) || "") %>">
<p class="watched"> <p class="watched">
<a onclick="mark_watched(this)" data-id="<%= item.id %>" href="javascript:void(0)"> <a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
<i onmouseenter='this.setAttribute("class", "icon ion-ios-eye-off")' <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye"
onmouseleave='this.setAttribute("class", "icon ion-ios-eye")'
class="icon ion-ios-eye"> class="icon ion-ios-eye">
</i> </i>
</button> </button>
@@ -117,10 +116,10 @@
</p> </p>
</form> </form>
<% elsif plid = env.get? "add_playlist_items" %> <% elsif plid = env.get? "add_playlist_items" %>
<form onsubmit="return false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&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) || "") %>">
<p class="watched"> <p class="watched">
<a onclick="add_playlist_item(this)" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)"> <a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
<i class="icon ion-md-add"></i> <i class="icon ion-md-add"></i>
</button> </button>
@@ -137,7 +136,7 @@
</div> </div>
</a> </a>
<% end %> <% end %>
<p><a href="/watch?v=<%= item.id %>"><%= item.title %></a></p> <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p>
<p> <p>
<b> <b>
<a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a>

View File

@@ -1,8 +1,5 @@
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>" title="<%= HTML.escape(video.title) %>" <video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>" title="<%= HTML.escape(video.title) %>"
id="player" class="video-js player-style-<%= params.player_style %>" id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
onmouseenter='this["data-title"]=this["title"];this["title"]=""'
onmouseleave='this["title"]=this["data-title"];this["data-title"]=""'
oncontextmenu='this["title"]=this["data-title"]'
<% 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 %>>
@@ -39,12 +36,14 @@
<% end %> <% end %>
</video> </video>
<script> <script id="player_data" type="application/json">
var player_data = { <%=
aspect_ratio: '<%= aspect_ratio %>', {
title: "<%= video.title.dump_unquoted %>", "aspect_ratio" => aspect_ratio,
description: "<%= HTML.escape(video.short_description) %>", "title" => video.title,
thumbnail: "<%= thumbnail %>" "description" => HTML.escape(video.short_description),
} "thumbnail" => thumbnail
}.to_pretty_json
%>
</script> </script>
<script src="/js/player.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/player.js?v=<%= ASSET_COMMIT %>"></script>

View File

@@ -3,6 +3,7 @@
<link rel="stylesheet" href="/css/videojs.markers.min.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/videojs.markers.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/videojs-share.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/videojs-share.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>">
<script src="/js/global.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/video.min.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/video.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>"></script>

View File

@@ -19,15 +19,17 @@
</p> </p>
<% end %> <% end %>
<script> <script id="subscribe_data" type="application/json">
var subscribe_data = { <%=
ucid: '<%= ucid %>', {
author: '<%= HTML.escape(author) %>', "ucid" => ucid,
sub_count_text: '<%= HTML.escape(sub_count_text) %>', "author" => HTML.escape(author),
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', "sub_count_text" => HTML.escape(sub_count_text),
subscribe_text: '<%= HTML.escape(translate(locale, "Subscribe")) %>', "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || ""),
unsubscribe_text: '<%= HTML.escape(translate(locale, "Unsubscribe")) %>' "subscribe_text" => HTML.escape(translate(locale, "Subscribe")),
} "unsubscribe_text" => HTML.escape(translate(locale, "Unsubscribe"))
}.to_pretty_json
%>
</script> </script>
<script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% else %> <% else %>

View File

@@ -10,33 +10,24 @@
<script src="/js/videojs-overlay.min.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/videojs-overlay.min.js?v=<%= ASSET_COMMIT %>"></script>
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title> <title><%= HTML.escape(video.title) %> - Invidious</title>
<style>
#player {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
z-index: -100;
}
</style>
</head> </head>
<body> <body>
<script> <script id="video_data" type="application/json">
var video_data = { <%=
id: '<%= video.id %>', {
index: '<%= continuation %>', "id" => video.id,
plid: '<%= plid %>', "index" => continuation,
length_seconds: '<%= video.length_seconds.to_f %>', "plid" => plid,
video_series: <%= video_series.to_json %>, "length_seconds" => video.length_seconds.to_f,
params: <%= params.to_json %>, "video_series" => video_series,
preferences: <%= preferences.to_json %>, "params" => params,
premiere_timestamp: <%= video.premiere_timestamp.try &.to_unix || "null" %> "preferences" => preferences,
} "premiere_timestamp" => video.premiere_timestamp.try &.to_unix
}.to_pretty_json
%>
</script> </script>
<%= rendered "components/player" %> <%= rendered "components/player" %>

View File

@@ -18,10 +18,12 @@
</div> </div>
</div> </div>
<script> <script id="watched_data" type="application/json">
var watched_data = { <%=
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', {
} "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script> </script>
<script src="/js/watched_widget.js"></script> <script src="/js/watched_widget.js"></script>
@@ -34,10 +36,10 @@ var watched_data = {
<% if !env.get("preferences").as(Preferences).thin_mode %> <% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail"> <div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/>
<form onsubmit="return false" action="/watch_ajax?action_mark_unwatched=1&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) || "") %>">
<p class="watched"> <p class="watched">
<a onclick="mark_unwatched(this)" data-id="<%= item %>" href="javascript:void(0)"> <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)">
<button type="submit" style="all:unset"> <button type="submit" style="all:unset">
<i class="icon ion-md-trash"></i> <i class="icon ion-md-trash"></i>
</button> </button>

View File

@@ -22,69 +22,6 @@
<hr> <hr>
<% case account_type when %> <% case account_type when %>
<% when "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset>
<% if email %>
<input name="email" type="hidden" value="<%= email %>">
<% else %>
<label for="email"><%= translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
<% end %>
<% if password %>
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
<% else %>
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
<% end %>
<% if captcha %>
<% case captcha_type when %>
<% when "image" %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="image">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<% when "text" %>
<% captcha = captcha.not_nil! %>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="text">
<label for="answer"><%= captcha[:question] %></label>
<input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
<% end %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Register") %>
</button>
<% case captcha_type when %>
<% when "image" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="text">
<%= translate(locale, "Text CAPTCHA") %>
</button>
</label>
<% when "text" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="image">
<%= translate(locale, "Image CAPTCHA") %>
</button>
</label>
<% end %>
<% else %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
</button>
<% end %>
</fieldset>
</form>
<% when "google" %> <% when "google" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post"> <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post">
<fieldset> <fieldset>
@@ -121,6 +58,69 @@
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button> <button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button>
</fieldset> </fieldset>
</form> </form>
<% else # "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset>
<% if email %>
<input name="email" type="hidden" value="<%= email %>">
<% else %>
<label for="email"><%= translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
<% end %>
<% if password %>
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
<% else %>
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
<% end %>
<% if captcha %>
<% case captcha_type when %>
<% when "image" %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="image">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<% else # "text" %>
<% captcha = captcha.not_nil! %>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="text">
<label for="answer"><%= captcha[:question] %></label>
<input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
<% end %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Register") %>
</button>
<% case captcha_type when %>
<% when "image" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="text">
<%= translate(locale, "Text CAPTCHA") %>
</button>
</label>
<% else # "text" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="image">
<%= translate(locale, "Image CAPTCHA") %>
</button>
</label>
<% end %>
<% else %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
</button>
<% end %>
</fieldset>
</form>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -69,12 +69,14 @@
</div> </div>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
<script> <script id="playlist_data" type="application/json">
var playlist_data = { <%=
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', {
} "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script> </script>
<script src="/js/playlist_widget.js"></script> <script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% end %> <% end %>
<div class="pure-g"> <div class="pure-g">

View File

@@ -90,7 +90,7 @@
<div class="pure-u-1 pure-u-md-4-5"></div> <div class="pure-u-1 pure-u-md-4-5"></div>
<div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
<% if continuation %> <% if continuation %>
<a href="/channel/<%= channel.ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= sort_by %><% end %>"> <a href="/channel/<%= channel.ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>">
<%= translate(locale, "Next page") %> <%= translate(locale, "Next page") %>
</a> </a>
<% end %> <% end %>

View File

@@ -2,12 +2,6 @@
<title><%= translate(locale, "Preferences") %> - Invidious</title> <title><%= translate(locale, "Preferences") %> - Invidious</title>
<% end %> <% end %>
<script>
function update_value(element) {
document.getElementById('volume-value').innerText = element.value;
}
</script>
<div class="h-box"> <div class="h-box">
<form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post"> <form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset> <fieldset>
@@ -65,7 +59,7 @@ function update_value(element) {
<div class="pure-control-group"> <div class="pure-control-group">
<label for="volume"><%= translate(locale, "Player volume: ") %></label> <label for="volume"><%= translate(locale, "Player volume: ") %></label>
<input name="volume" id="volume" oninput="update_value(this);" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>"> <input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
<span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span> <span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span>
</div> </div>
@@ -205,7 +199,7 @@ function update_value(element) {
<% # Web notifications are only supported over HTTPS %> <% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || config.https_only %> <% if Kemal.config.ssl || config.https_only %>
<div class="pure-control-group"> <div class="pure-control-group">
<a href="#" onclick="Notification.requestPermission()"><%= translate(locale, "Enable web notifications") %></a> <a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -37,9 +37,9 @@
<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 onsubmit="return false" action="/subscription_ajax?action_remove_subscriptions=1&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="<%= 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) || "") %>">
<a onclick="remove_subscription(this)" data-ucid="<%= channel.id %>" href="#"> <a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>"> <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
</a> </a>
</form> </form>
@@ -52,32 +52,3 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<script>
function remove_subscription(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.innerText = count.innerText - 1;
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&referer=<%= env.get("current_page") %>' +
'&c=' + target.getAttribute('data-ucid');
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status != 200) {
count.innerText = parseInt(count.innerText) + 1;
row.style.display = '';
}
}
}
xhr.send('csrf_token=<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>');
}
</script>

View File

@@ -45,10 +45,12 @@
<hr> <hr>
</div> </div>
<script> <script id="watched_data" type="application/json">
var watched_data = { <%=
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', {
} "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script> </script>
<script src="/js/watched_widget.js"></script> <script src="/js/watched_widget.js"></script>

View File

@@ -140,23 +140,24 @@
</div> </div>
<div class="pure-u-1 pure-u-md-1-3"> <div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-github"></i> <i class="icon ion-logo-github"></i>
<%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
<i class="icon ion-logo-github"></i>
<%= CURRENT_BRANCH %>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="pure-u-1 pure-u-md-2-24"></div> <div class="pure-u-1 pure-u-md-2-24"></div>
</div> </div>
<script src="/js/handlers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script>
<% if env.get? "user" %> <% if env.get? "user" %>
<script src="/js/sse.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sse.js?v=<%= ASSET_COMMIT %>"></script>
<script> <script id="notification_data" type="application/json">
var notification_data = { <%=
upload_text: '<%= HTML.escape(translate(locale, "`x` uploaded a video")) %>', {
live_upload_text: '<%= HTML.escape(translate(locale, "`x` is live")) %>', "upload_text" => HTML.escape(translate(locale, "`x` uploaded a video")),
} "live_upload_text" => HTML.escape(translate(locale, "`x` is live"))
}.to_pretty_json
%>
</script> </script>
<script src="/js/notifications.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/notifications.js?v=<%= ASSET_COMMIT %>"></script>
<% end %> <% end %>

View File

@@ -29,9 +29,9 @@
</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 onsubmit="return false" action="/token_ajax?action_revoke_token=1&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="<%= 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) || "") %>">
<a onclick="revoke_token(this)" data-session="<%= token[:session] %>" href="#"> <a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>"> <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
</a> </a>
</form> </form>
@@ -44,32 +44,3 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<script>
function revoke_token(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.innerText = count.innerText - 1;
var url = '/token_ajax?action_revoke_token=1&redirect=false' +
'&referer=<%= env.get("current_page") %>' +
'&session=' + target.getAttribute('data-session');
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 10000;
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status != 200) {
count.innerText = parseInt(count.innerText) + 1;
row.style.display = '';
}
}
}
xhr.send('csrf_token=<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>');
}
</script>

View File

@@ -26,24 +26,26 @@
<title><%= HTML.escape(video.title) %> - Invidious</title> <title><%= HTML.escape(video.title) %> - Invidious</title>
<% end %> <% end %>
<script> <script id="video_data" type="application/json">
var video_data = { <%=
id: '<%= video.id %>', {
index: '<%= continuation %>', "id" => video.id,
plid: '<%= plid %>', "index" => continuation,
length_seconds: <%= video.length_seconds.to_f %>, "plid" => plid,
play_next: <%= !rvs.empty? && !plid && params.continue %>, "length_seconds" => video.length_seconds.to_f,
next_video: '<%= rvs.select { |rv| rv["id"]? }[0]?.try &.["id"] %>', "play_next" => !rvs.empty? && !plid && params.continue,
youtube_comments_text: '<%= HTML.escape(translate(locale, "View YouTube comments")) %>', "next_video" => rvs.select { |rv| rv["id"]? }[0]?.try &.["id"],
reddit_comments_text: '<%= HTML.escape(translate(locale, "View Reddit comments")) %>', "youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
reddit_permalink_text: '<%= HTML.escape(translate(locale, "View more comments on Reddit")) %>', "reddit_comments_text" => HTML.escape(translate(locale, "View Reddit comments")),
comments_text: '<%= HTML.escape(translate(locale, "View `x` comments", "{commentCount}")) %>', "reddit_permalink_text" => HTML.escape(translate(locale, "View more comments on Reddit")),
hide_replies_text: '<%= HTML.escape(translate(locale, "Hide replies")) %>', "comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
show_replies_text: '<%= HTML.escape(translate(locale, "Show replies")) %>', "hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
params: <%= params.to_json %>, "show_replies_text" => HTML.escape(translate(locale, "Show replies")),
preferences: <%= preferences.to_json %>, "params" => params,
premiere_timestamp: <%= video.premiere_timestamp.try &.to_unix || "null" %> "preferences" => preferences,
} "premiere_timestamp" => video.premiere_timestamp.try &.to_unix
}.to_pretty_json
%>
</script> </script>
<div id="player-container" class="h-box"> <div id="player-container" class="h-box">
@@ -99,6 +101,34 @@ var video_data = {
<% end %> <% end %>
</p> </p>
<% if user %>
<% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1", user.email, as: {String, String}) %>
<% if !playlists.empty? %>
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post">
<div class="pure-control-group">
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
<select style="width:100%" name="playlist_id" id="playlist_id">
<% playlists.each do |plid, title| %>
<option data-plid="<%= plid %>" value="<%= plid %>"><%= title %></option>
<% end %>
</select>
</div>
<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>
</button>
</form>
<script id="playlist_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/playlist_widget.js?v=<%= Time.utc.to_unix_ms %>"></script>
<% end %>
<% end %>
<% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> <% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %>
<p><%= translate(locale, "Download is disabled.") %></p> <p><%= translate(locale, "Download is disabled.") %></p>
<% else %> <% else %>
@@ -135,9 +165,9 @@ var video_data = {
</form> </form>
<% end %> <% end %>
<p><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p> <p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p> <p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p> <p id="dislikes"><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p>
<p id="genre"><%= translate(locale, "Genre: ") %> <p id="genre"><%= translate(locale, "Genre: ") %>
<% if video.genre_url.empty? %> <% if video.genre_url.empty? %>
<%= video.genre %> <%= video.genre %>