134 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
Omar Roth
05988c1c49 Bump version 2019-11-18 20:41:42 -05:00
Omar Roth
d46b26e3bc Use QUIC for connections to YouTube 2019-11-18 17:28:32 -05:00
Omar Roth
236c172c6f Merge pull request #896 from sh4dowb/master
Fixed double quotes in meta description
2019-11-14 10:38:38 -05:00
Omar Roth
59fcb56972 Merge pull request #907 from tleydxdy/patch-2
Fix docker build for now
2019-11-14 10:38:12 -05:00
Omar Roth
c07cd3a856 Fix typo in playlist url 2019-11-14 10:11:33 -05:00
tleydxdy
37766347a5 Fix docker build for now 2019-11-13 08:57:12 -05:00
sh4dowb
79da61782b Fixed double quotes in meta description 2019-11-11 19:00:23 +03:00
Omar Roth
8af87f1a8b Fix updating of cookies 2019-11-10 10:02:02 -05:00
Omar Roth
494c954cbb Add etag to /api/v1/annotations 2019-11-09 22:05:17 -05:00
Omar Roth
71bc9eea28 Add support for Anti-Captcha 2019-11-09 14:22:39 -05:00
Omar Roth
e3b2bcfd06 Fix ID for search duration 2019-11-08 09:29:33 -05:00
Omar Roth
142d974641 Use force_resolve for search suggestions 2019-11-07 12:25:34 -05:00
92 changed files with 3839 additions and 2631 deletions

View File

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

View File

@@ -79,7 +79,7 @@ $ docker-compose build
```bash
# Arch Linux
$ sudo pacman -S shards crystal librsvg postgresql
$ sudo pacman -S base-devel shards crystal librsvg postgresql
# Ubuntu or Debian
# 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/nonces.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
```
@@ -138,6 +140,20 @@ $ sudo systemctl enable 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:
```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/nonces.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
$ shards update && shards install
@@ -209,6 +228,7 @@ $ ./sentry
- [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.
- [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

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) {
return this.replace(/{([^{}]*)}/g, function (a, 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) {
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;
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 = {
preload: 'auto',
liveui: true,
@@ -35,7 +38,7 @@ var shareOptions = {
title: player_data.title,
description: player_data.description,
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);

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) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
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');
subscribe_button.parentNode['action'] = 'javascript:void(0)';

View File

@@ -28,6 +28,27 @@ window.addEventListener('load', function () {
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) {
document.getElementById('dark_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) {
return this.replace(/{([^{}]*)}/g, function (a, 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) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
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
-- 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
ports:
- "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:
- postgres

View File

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

View File

@@ -1,6 +1,9 @@
FROM postgres:10
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 ./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/playlists.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
echo "### invidious database setup finished"
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` videos": "`x` Videos",
"`x` playlists": "",
"`x` playlists": "`x` Wiedergabelisten",
"LIVE": "LIVE",
"Shared `x` ago": "Vor `x` geteilt",
"Unsubscribe": "Abbestellen",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "Datenschutzerklärung einsehen.",
"Trending": "Trending",
"Public": "",
"Public": "Öffentlich",
"Unlisted": "Nicht aufgeführt",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Private": "Privat",
"View all playlists": "Alle Wiedergabelisten anzeigen",
"Updated `x` ago": "Aktualisiert `x` vor",
"Delete playlist `x`?": "Wiedergabeliste löschen `x`?",
"Delete playlist": "Wiedergabeliste löschen",
"Create playlist": "Wiedergabeliste erstellen",
"Title": "Titel",
"Playlist privacy": "Vertrauliche Wiedergabeliste",
"Editing playlist `x`": "Wiedergabeliste bearbeiten `x`",
"Watch on YouTube": "Video auf YouTube ansehen",
"Hide annotations": "Anmerkungen ausblenden",
"Show annotations": "Anmerkungen anzeigen",

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"`x` subscribers": "`x` suscriptores",
"`x` videos": "`x` vídeos",
"`x` playlists": "",
"`x` playlists": "`x` listas de reproducción",
"LIVE": "DIRECTO",
"Shared `x` ago": "Compartido hace `x`",
"Unsubscribe": "Desuscribirse",
@@ -69,11 +69,11 @@
"Show related videos: ": "¿Mostrar vídeos relacionados? ",
"Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
"Visual preferences": "Preferencias visuales",
"Player style: ": "",
"Player style: ": "Estilo de reproductor: ",
"Dark mode: ": "Modo oscuro: ",
"Theme: ": "",
"dark": "",
"light": "",
"Theme: ": "Tema: ",
"dark": "oscuro",
"light": "claro",
"Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferencias de la suscripción",
"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 unwatched: ": "Mostrar solo los no vistos: ",
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Enable web notifications": "Habilitar notificaciones web",
"`x` uploaded a video": "`x` subió un video",
"`x` is live": "`x` esta en vivo",
"Data preferences": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Ver información de licencia de JavaScript.",
"View privacy policy.": "Ver la política de privacidad.",
"Trending": "Tendencias",
"Public": "",
"Public": "Público",
"Unlisted": "No listado",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Private": "Privado",
"View all playlists": "Ver todas las listas de reproducción",
"Updated `x` ago": "Actualizado hace `x`",
"Delete playlist `x`?": "¿Eliminar la lista de reproducción `x`?",
"Delete playlist": "Eliminar lista de reproducción",
"Create playlist": "Crear lista de reproducción",
"Title": "Título",
"Playlist privacy": "Privacidad de la lista de reproducción",
"Editing playlist `x`": "Editando la lista de reproducción 'x'",
"Watch on YouTube": "Ver el vídeo en Youtube",
"Hide annotations": "Ocultar anotaciones",
"Show annotations": "Mostrar anotaciones",
@@ -151,7 +151,7 @@
"Shared `x`": "Compartido `x`",
"`x` views": "`x` visualizaciones",
"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.",
"View YouTube comments": "Ver los comentarios de YouTube",
"View more comments on Reddit": "Ver más comentarios en Reddit",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)",
"YouTube comment permalink": "Enlace permanente de YouTube del comentario",
"permalink": "",
"permalink": "permalink",
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio",
"Video mode": "Modo de vídeo",
"Videos": "Vídeos",
"Playlists": "Listas de reproducción",
"Community": "",
"Community": "Comunidad",
"Current version: ": "Versión actual: "
}

View File

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

View File

@@ -87,8 +87,8 @@
"channel name": "nom de la chaîne",
"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 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 unwatched: ": "Afficher uniquement les vidéos qui n'ont pas étaient regardées : ",
"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 été regardées : ",
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "Activer les notifications web",
"`x` uploaded a video": "`x` a partagé(e) une vidéo",
@@ -152,7 +152,7 @@
"`x` views": "`x` vues",
"Premieres in `x`": "Première dans `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 more comments on Reddit": "Voir plus de commentaires sur Reddit",
"View `x` comments": "Voir `x` commentaires",
@@ -160,7 +160,7 @@
"Hide replies": "Masquer les réponses",
"Show replies": "Afficher les réponses",
"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.",
"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.",

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
{
"`x` subscribers": "`x` subskrybcji",
"`x` videos": "`x` filmów",
"`x` playlists": "",
"`x` playlists": "`x` playlist",
"LIVE": "NA ŻYWO",
"Shared `x` ago": "Udostępniono `x` temu",
"Unsubscribe": "Odsubskrybuj",
"Subscribe": "Subskrybuj",
"View channel on YouTube": "Wyświetl kanał na YouTube",
"View playlist on YouTube": "",
"View playlist on YouTube": "Zobacz playlistę na YouTube",
"newest": "najnowsze",
"oldest": "najstarsze",
"popular": "popularne",
@@ -15,11 +15,11 @@
"Next page": "Następna strona",
"Previous page": "Poprzednia strona",
"Clear watch history?": "Wyczyścić historię?",
"New password": "",
"New passwords must match": "",
"Cannot change password for Google accounts": "",
"Authorize token?": "",
"Authorize token for `x`?": "",
"New password": "Nowe hasło",
"New passwords must match": "Nowe hasła muszą być identyczne",
"Cannot change password for Google accounts": "Nie można zmienić hasła do konta Google",
"Authorize token?": "Autoryzować token?",
"Authorize token for `x`?": "Autoryzować token dla `x`?",
"Yes": "Tak",
"No": "Nie",
"Import and Export Data": "Import i eksport danych",
@@ -54,7 +54,7 @@
"Player preferences": "Ustawienia odtwarzacza",
"Always loop: ": "Zawsze zapętlaj: ",
"Autoplay: ": "Autoodtwarzanie: ",
"Play next by default: ": "",
"Play next by default: ": "Domyślnie odtwarzaj następny: ",
"Autoplay next video: ": "Odtwórz następny film: ",
"Listen by default: ": "Tryb dźwiękowy: ",
"Proxy videos: ": "Filmy przez proxy? ",
@@ -62,21 +62,21 @@
"Preferred video quality: ": "Preferowana jakość filmów: ",
"Player volume: ": "Głośność odtwarzacza: ",
"Default comments: ": "Domyślne komentarze: ",
"youtube": "",
"reddit": "",
"youtube": "youtube",
"reddit": "reddit",
"Default captions: ": "Domyślne napisy: ",
"Fallback captions: ": "Zastępcze napisy: ",
"Show related videos: ": "Pokaż powiązane filmy? ",
"Show annotations by default: ": "",
"Show annotations by default: ": "Domyślnie pokazuj adnotacje: ",
"Visual preferences": "Preferencje Wizualne",
"Player style: ": "",
"Player style: ": "Styl odtwarzacza: ",
"Dark mode: ": "Ciemny motyw: ",
"Theme: ": "",
"dark": "",
"light": "",
"Theme: ": "Motyw: ",
"dark": "ciemny",
"light": "jasny",
"Thin mode: ": "Tryb minimalny: ",
"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: ",
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
"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 unwatched: ": "Pokazuj tylko nie obejrzane: ",
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Enable web notifications": "Włącz powiadomienia",
"`x` uploaded a video": "`x` dodał film",
"`x` is live": "'x ' jest na żywo",
"Data preferences": "Preferencje danych",
"Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych",
"Change password": "",
"Change password": "Zmień hasło",
"Manage subscriptions": "Organizuj subskrybcje",
"Manage tokens": "",
"Manage tokens": "Zarządzaj tokenami",
"Watch history": "Historia",
"Delete account": "Usuń konto",
"Administrator preferences": "Preferencje administratora",
"Default homepage: ": "Domyślna strona główna: ",
"Feed menu: ": "",
"Top enabled: ": "",
"Top enabled: ": "\"Top\" aktywne: ",
"CAPTCHA enabled: ": "CAPTCHA aktywna? ",
"Login enabled: ": "Logowanie włączone? ",
"Registration enabled: ": "Rejestracja włączona? ",
"Report statistics: ": "Raportować statystyki? ",
"Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji",
"Token manager": "",
"Token": "",
"Token manager": "Menedżer tokenów",
"Token": "Token",
"`x` subscriptions": "`x` subskrybcji",
"`x` tokens": "",
"Import/export": "Import/Eksport",
"unsubscribe": "odsubskrybuj",
"revoke": "",
"revoke": "cofnij",
"Subscriptions": "Subskrybcje",
"`x` unseen notifications": "`x` nowych powiadomień",
"search": "szukaj",
@@ -127,20 +127,20 @@
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "Polityka prywatności.",
"Trending": "Na czasie",
"Public": "",
"Unlisted": "",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Public": "Publiczne",
"Unlisted": "Niewidoczne",
"Private": "Prywatne",
"View all playlists": "Pokaż wszystkie playlisty",
"Updated `x` ago": "Zaktualizowano `x` temu",
"Delete playlist `x`?": "Usunąć playlistę 'x '?",
"Delete playlist": "Usuń playlistę",
"Create playlist": "Utwórz playlistę",
"Title": "Tytuł",
"Playlist privacy": "Widoczność playlisty",
"Editing playlist `x`": "Edycja playlisty `x`",
"Watch on YouTube": "Zobacz film na YouTube",
"Hide annotations": "",
"Show annotations": "",
"Hide annotations": "Ukryj adnotacje",
"Show annotations": "Pokaż adnotacje",
"Genre: ": "Gatunek: ",
"License: ": "Licencja: ",
"Family friendly? ": "Przyjazny rodzinie? ",
@@ -151,7 +151,7 @@
"Shared `x`": "Udostępniono `x`",
"`x` views": "`x` wyświetleń",
"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.",
"View YouTube comments": "Wyświetl komentarze z YouTube",
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
@@ -310,7 +310,7 @@
"`x` seconds": "`x` sekund",
"Fallback comments: ": "Zastępcze komentarze: ",
"Popular": "Popularne",
"Top": "Najczęściej oglądane",
"Top": "Top",
"About": "Informacje",
"Rating: ": "Ocena: ",
"Language: ": "Język: ",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "",
"(edited)": "(edytowany)",
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
"permalink": "",
"permalink": "bezpośredni odnośnik",
"`x` marked it with a ❤": "`x` oznaczonych ❤",
"Audio mode": "Tryb audio",
"Video mode": "Tryb wideo",
"Videos": "Filmy",
"Playlists": "Playlisty",
"Community": "",
"Community": "Społeczność",
"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` videos": "`x` видео",
"`x` playlists": "",
"`x` playlists": "`x` плейлистов",
"LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться",
@@ -69,11 +69,11 @@
"Show related videos: ": "Показывать похожие видео? ",
"Show annotations by default: ": "Всегда показывать аннотации? ",
"Visual preferences": "Настройки сайта",
"Player style: ": "",
"Player style: ": "Стиль проигрывателя: ",
"Dark mode: ": "Тёмное оформление: ",
"Theme: ": "",
"dark": "",
"light": "",
"Theme: ": "Тема: ",
"dark": "темная",
"light": "светлая",
"Thin mode: ": "Облегчённое оформление: ",
"Subscription preferences": "Настройки подписок",
"Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
@@ -127,17 +127,17 @@
"View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.",
"View privacy policy.": "Посмотреть политику конфиденциальности.",
"Trending": "В тренде",
"Public": "",
"Public": "Публичный",
"Unlisted": "Нет в списке",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Private": "Приватный",
"View all playlists": "Посмотреть все плейлисты",
"Updated `x` ago": "Обновлено `x` назад",
"Delete playlist `x`?": "Удалить плейлист `x`?",
"Delete playlist": "Удалить плейлист",
"Create playlist": "Создать плейлист",
"Title": "Заголовок",
"Playlist privacy": "Конфиденциальность плейлиста",
"Editing playlist `x`": "Редактирование плейлиста `x`",
"Watch on YouTube": "Смотреть на YouTube",
"Hide annotations": "Скрыть аннотации",
"Show annotations": "Показать аннотации",
@@ -325,12 +325,12 @@
"%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(изменено)",
"YouTube comment permalink": "Прямая ссылка на YouTube",
"permalink": "",
"permalink": "постоянная ссылка",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим",
"Video mode": "Видео режим",
"Videos": "Видео",
"Playlists": "Плейлисты",
"Community": "",
"Community": "Сообщество",
"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` videos": "",
"`x` playlists": "",
"`x` subscribers": "`x` abone",
"`x` videos": "`x` video",
"`x` playlists": "`x` çalma listesi",
"`x` subscribers.": "`x` abone.",
"`x` videos.": "`x` video.",
"LIVE": "CANLI",
@@ -56,20 +56,20 @@
"Player preferences": "Oynatıcı tercihleri",
"Always loop: ": "Sürekli döngü: ",
"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: ",
"Listen by default: ": "Varsayılan olarak dinle: ",
"Listen by default: ": "Öntanımlı olarak dinle: ",
"Proxy videos: ": "Videoları proxy'le: ",
"Default speed: ": "Varsayılan hız: ",
"Default speed: ": "Öntanımlı hız: ",
"Preferred video quality: ": "Tercih edilen video kalitesi: ",
"Player volume: ": "Oynatıcı ses seviyesi: ",
"Default comments: ": "Varsayılan yorumlar: ",
"Default comments: ": "Öntanımlı yorumlar: ",
"youtube": "youtube",
"reddit": "reddit",
"Default captions: ": "Varsayılan altyazılar: ",
"Default captions: ": "Öntanımlı altyazılar: ",
"Fallback captions: ": "Yedek altyazılar: ",
"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",
"Player style: ": "Oynatıcı biçimi: ",
"Dark mode: ": "Karanlık mod: ",
@@ -78,7 +78,7 @@
"light": "aydınlık",
"Thin mode: ": "İnce mod: ",
"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: ",
"Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ",
"Sort videos by: ": "Videoları sıralama kriteri: ",
@@ -104,7 +104,7 @@
"Watch history": "İzleme geçmişi",
"Delete account": "Hesap silme",
"Administrator preferences": "Yönetici tercihleri",
"Default homepage: ": "Varsayılan ana sayfa: ",
"Default homepage: ": "Öntanımlı ana sayfa: ",
"Feed menu: ": "Akış menüsü: ",
"Top enabled: ": "Top etkin: ",
"CAPTCHA enabled: ": "CAPTCHA etkin: ",
@@ -113,13 +113,13 @@
"Report statistics: ": "Rapor istatistikleri: ",
"Save preferences": "Tercihleri kaydet",
"Subscription manager": "Abonelik yöneticisi",
"`x` subscriptions": "",
"`x` tokens": "",
"`x` subscriptions": "`x` abonelik",
"`x` tokens": "`x` belirteç",
"Token manager": "Jeton yöneticisi",
"Token": "Jeton",
"`x` subscriptions.": "`x` abonelik.",
"`x` tokens.": "`x` jeton.",
"`x` unseen notifications": "",
"`x` unseen notifications": "`x` okunmamış bildirim",
"Import/export": "İçe/dışa aktar",
"unsubscribe": "abonelikten çık",
"revoke": "geri al",
@@ -127,18 +127,18 @@
"`x` unseen notifications.": "`x` okunmamış bildirim.",
"search": "ara",
"Log out": ıkış yap",
"Public": "",
"Public": "Genel",
"Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.",
"Private": "",
"View all playlists": "",
"Updated `x` ago": "",
"Delete playlist `x`?": "",
"Delete playlist": "",
"Create playlist": "",
"Title": "",
"Playlist privacy": "",
"Editing playlist `x`": "",
"Source available here.": "Kaynak kodu burada mevcut.",
"Private": "Özel",
"View all playlists": "Tüm çalma listelerini görüntüle",
"Updated `x` ago": "`x` önce güncellendi",
"Delete playlist `x`?": "`x` çalma listesini sil?",
"Delete playlist": "Çalma listesini sil",
"Create playlist": "Çalma listesi oluştur",
"Title": "Başlık",
"Playlist privacy": "Çalma listesi gizliliği",
"Editing playlist `x`": "`x` çalma listesi düzenleniyor",
"Source available here.": "Kaynak kodları burada bulunabilir.",
"View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.",
"View privacy policy.": "Gizlilik politikasını görüntüle.",
"Trending": "Trendler",
@@ -149,7 +149,7 @@
"Genre: ": "Tür: ",
"License: ": "Lisans: ",
"Family friendly? ": "Aile için uygun? ",
"`x` views": "",
"`x` views": "`x` görüntüleme",
"Wilson score: ": "Wilson puanı: ",
"Engagement: ": "İzleyenlerin oy verme oranı: ",
"Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ",
@@ -180,10 +180,10 @@
"Password cannot be empty": "Parola boş olamaz",
"Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz",
"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ışı",
"channel:`x`": "kanal:`x`",
"`x` points": "",
"`x` points": "`x` puan",
"Deleted or invalid channel": "Silinmiş ya da geçersiz kanal",
"This channel does not exist.": "Bu kanal mevcut değil.",
"Could not get channel info.": "Kanal bilgisi alınamadı.",
@@ -323,7 +323,7 @@
"Rating: ": "Değerlendirme: ",
"Language: ": "Dil: ",
"View as playlist": "Oynatma listesi olarak görüntüle",
"Default": "Varsayılan",
"Default": "Öntanımlı",
"Music": "Müzik",
"Gaming": "Oyun",
"News": "Haberler",
@@ -340,5 +340,5 @@
"Videos": "Videolar",
"Playlists": "Oynatma listeleri",
"Community": "Topluluk",
"Current version: ": "Şu anki versiyon: "
"Current version: ": "Şu anki sürüm: "
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
name: invidious
version: 0.20.0
version: 0.20.1
authors:
- Omar Roth <omarroth@protonmail.com>
@@ -11,20 +11,23 @@ targets:
dependencies:
pg:
github: will/crystal-pg
version: ~> 0.19.0
version: ~> 0.21.0
sqlite3:
github: crystal-lang/crystal-sqlite3
version: ~> 0.14.0
version: ~> 0.16.0
kemal:
github: kemalcr/kemal
version: ~> 0.26.0
version: ~> 0.26.1
pool:
github: ysbaddaden/pool
version: ~> 0.2.3
protodec:
github: omarroth/protodec
version: ~> 0.1.2
lsquic:
github: omarroth/lsquic.cr
branch: dev
crystal: 0.31.1
crystal: 0.34.0
license: AGPLv3

View File

@@ -9,6 +9,7 @@ require "../src/invidious/channels"
require "../src/invidious/comments"
require "../src/invidious/playlists"
require "../src/invidious/search"
require "../src/invidious/trending"
require "../src/invidious/users"
describe "Helper" do
@@ -34,7 +35,7 @@ describe "Helper" do
describe "#produce_channel_playlists_url" do
it "correctly produces a /browse_ajax URL with the given UCID and cursor" do
produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW").should eq("/browse_ajax?continuation=4qmFsgLJARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GqwBRWdod2JHRjViR2x6ZERBQ09BRmdBV29BdUFFQWVtWlJWV3hRWVRGck5WSldSbmRoVmpsdVpWYzBlRmd4Um5sU2JsWmhUVmhLYkZScVozaFlNREZPWWxWcmVGZFhNWE5SYlVwelpIcG9jVTR3Y0VsVFdGSkdVbXRqTVdGRVpIaFpNSEJWVkcxUk1GWjZiRFJPVmtZeFlURTVSRlpzYjNkTmFtaHVWbmNnQVJnRA%3D%3D&gl=US&hl=en")
produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW").should eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en")
end
end
@@ -124,6 +125,15 @@ describe "Helper" do
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
it "correctly signs a given hash" do
token = {

View File

@@ -28,8 +28,11 @@ require "protodec/utils"
require "./invidious/helpers/*"
require "./invidious/*"
CONFIG = Config.from_yaml(File.read("config/config.yml"))
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
ENV_CONFIG_NAME = "INVIDIOUS_CONFIG"
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(
scheme: "postgres",
@@ -45,19 +48,18 @@ ARCHIVE_URL = URI.parse("https://archive.org")
LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.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_IMG_URL = URI.parse("https://i.ytimg.com")
CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
MAX_ITEMS_PER_PAGE = 1500
REQUEST_HEADERS_WHITELIST = {"Accept", "Accept-Encoding", "Cache-Control", "Connection", "Content-Length", "If-None-Match", "Range"}
RESPONSE_HEADERS_BLACKLIST = {"Access-Control-Allow-Origin", "Alt-Svc", "Server"}
REQUEST_HEADERS_WHITELIST = {"accept", "accept-encoding", "cache-control", "content-length", "if-none-match", "range"}
RESPONSE_HEADERS_BLACKLIST = {"access-control-allow-origin", "alt-svc", "server"}
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_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }}
@@ -81,21 +83,25 @@ LOCALES = {
"es" => load_locale("es"),
"eu" => load_locale("eu"),
"fr" => load_locale("fr"),
"hu" => load_locale("hu-HU"),
"is" => load_locale("is"),
"it" => load_locale("it"),
"ja" => load_locale("ja"),
"nb_NO" => load_locale("nb_NO"),
"nb-NO" => load_locale("nb-NO"),
"nl" => load_locale("nl"),
"pl" => load_locale("pl"),
"pt-BR" => load_locale("pt-BR"),
"pt-PT" => load_locale("pt-PT"),
"ro" => load_locale("ro"),
"ru" => load_locale("ru"),
"sv" => load_locale("sv-SE"),
"tr" => load_locale("tr"),
"uk" => load_locale("uk"),
"zh-CN" => load_locale("zh-CN"),
"zh-TW" => load_locale("zh-TW"),
}
YT_POOL = HTTPPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.05)
YT_IMG_POOL = HTTPPool.new(YT_IMG_URL, capacity: CONFIG.pool_size, timeout: 0.05)
YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.1)
config = CONFIG
logger = Invidious::LogHandler.new
@@ -205,13 +211,27 @@ spawn do
end
end
decrypt_function = [] of {name: String, value: Int32}
decrypt_function = [] of {SigProc, Int32}
spawn do
update_decrypt_function do |function|
decrypt_function = function
end
end
if CONFIG.captcha_key
spawn do
bypass_captcha(CONFIG.captcha_key, logger) do |cookies|
cookies.each do |cookie|
config.cookies << cookie
end
# Persist cookies between runs
CONFIG.cookies = config.cookies
File.write("config/config.yml", config.to_yaml)
end
end
end
connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32)
spawn do
connections = [] of Channel(PQ::Notification)
@@ -231,21 +251,36 @@ spawn do
end
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-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"
if (Kemal.config.ssl || config.https_only) && config.hsts
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
end
begin
preferences = Preferences.from_json(env.request.cookies["PREFS"]?.try &.value || "{}")
rescue
preferences = Preferences.from_json("{}")
end
next if {
"/sb/",
"/vi/",
"/s_p/",
"/yts/",
"/ggpht/",
"/api/manifest/",
"/videoplayback",
"/latest_version",
}.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID"
sid = env.request.cookies["SID"].value
@@ -352,6 +387,8 @@ get "/" do |env|
else
templated "popular"
end
else
templated "empty"
end
end
@@ -398,7 +435,7 @@ get "/watch" do |env|
next env.redirect "/"
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)
nojs = env.params.query["nojs"]?
@@ -438,7 +475,7 @@ get "/watch" do |env|
env.params.query.delete_all("iv_load_policy")
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
if notifications && notifications.includes? id
@@ -583,7 +620,7 @@ end
get "/embed/" do |env|
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
playlist = get_playlist(PG_DB, plid, locale: locale)
offset = env.params.query["index"]?.try &.to_i? || 0
@@ -610,7 +647,7 @@ get "/embed/:id" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
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)
if md = env.params.query["playlist"]?
@@ -689,6 +726,7 @@ get "/embed/:id" do |env|
end
next env.redirect url
else nil # Continue
end
params = process_video_params(env.params.query, preferences)
@@ -718,7 +756,7 @@ get "/embed/:id" do |env|
end
# 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
if notifications && notifications.includes? id
@@ -1213,13 +1251,17 @@ post "/playlist_ajax" do |env|
args = arg_array(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"
index = env.params.query["set_video_id"]
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"
# TODO: Playlist stub
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end
if redirect
@@ -1234,9 +1276,9 @@ get "/playlist" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
user = env.get?("user").try &.as(User)
plid = env.params.query["list"]?
referer = get_referer(env)
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
if !plid
next env.redirect "/"
end
@@ -1421,8 +1463,9 @@ post "/login" do |env|
traceback = IO::Memory.new
# See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
# TODO: Convert to QUIC
begin
client = make_client(LOGIN_URL)
client = QUIC::Client.new(LOGIN_URL)
headers = HTTP::Headers.new
login_page = client.get("/ServiceLogin")
@@ -1445,7 +1488,6 @@ post "/login" do |env|
headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8"
headers["Google-Accounts-XSRF"] = "1"
headers["User-Agent"] = random_user_agent
response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req))
lookup_results = JSON.parse(response.body[5..-1])
@@ -1514,7 +1556,7 @@ post "/login" do |env|
case prompt_type
when "TWO_STEP_VERIFICATION"
prompt_type = 2
when "LOGIN_CHALLENGE"
else # "LOGIN_CHALLENGE"
prompt_type = 4
end
@@ -1619,28 +1661,31 @@ post "/login" do |env|
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)
headers.delete("Content-Type")
headers.delete("Google-Accounts-XSRF")
loop do
if !location || location.includes? "/ManageAccount"
if !location || location.path == "/ManageAccount"
break
end
# Occasionally there will be a second page after login confirming
# 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."
end
login = client.get(location, headers)
headers = login.cookies.add_request_headers(headers)
login = client.get(location.full_path, headers)
cookies = HTTP::Cookies.from_headers(headers)
location = login.headers["Location"]?
headers = login.cookies.add_request_headers(headers)
location = login.headers["Location"]?.try { |u| URI.parse(u) }
end
cookies = HTTP::Cookies.from_headers(headers)
sid = cookies["SID"]?.try &.value
if !sid
raise "Couldn't get SID."
@@ -1804,7 +1849,7 @@ post "/login" do |env|
env.response.status_code = 400
next templated "error"
end
when "text"
else # "text"
answer = Digest::MD5.hexdigest(answer.downcase.strip)
found_valid_captcha = false
@@ -2211,10 +2256,14 @@ post "/watch_ajax" do |env|
case action
when "action_mark_watched"
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
when "action_mark_unwatched"
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
if redirect
@@ -2369,6 +2418,10 @@ post "/subscription_ajax" do |env|
end
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)
else
error_message = {"error" => "Unsupported action #{action}"}.to_json
env.response.status_code = 400
next error_message
end
if redirect
@@ -2523,6 +2576,7 @@ post "/data_control" do |env|
next
end
# TODO: Unify into single import based on content-type
case part.name
when "import_invidious"
body = JSON.parse(body)
@@ -2572,13 +2626,9 @@ post "/data_control" do |env|
next match["channel"]
elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
document = XML.parse_html(response.body)
canonical = document.xpath_node(%q(//link[@rel="canonical"]))
if canonical
ucid = canonical["href"].split("/")[-1]
next ucid
end
html = XML.parse_html(response.body)
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next ucid if ucid
end
nil
@@ -2613,6 +2663,7 @@ post "/data_control" do |env|
end
end
end
else nil # Ignore
end
end
end
@@ -2954,6 +3005,10 @@ post "/token_ajax" do |env|
case action
when .starts_with? "action_revoke_token"
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
if redirect
@@ -3096,12 +3151,10 @@ get "/feed/channel/:ucid" do |env|
next error_message
end
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}").body
rss = XML.parse_html(rss)
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
rss = XML.parse_html(response.body)
videos = [] of SearchVideo
rss.xpath_nodes("//feed/entry").each do |entry|
videos = rss.xpath_nodes("//feed/entry").map do |entry|
video_id = entry.xpath_node("videoid").not_nil!.content
title = entry.xpath_node("title").not_nil!.content
@@ -3113,7 +3166,7 @@ get "/feed/channel/:ucid" do |env|
description_html = entry.xpath_node("group/description").not_nil!.to_s
views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64
videos << SearchVideo.new(
SearchVideo.new(
title: title,
id: video_id,
author: author,
@@ -3250,6 +3303,7 @@ get "/feed/playlist/:plid" do |env|
full_path = URI.parse(node[attribute.name]).full_path
query_string_opt = full_path.starts_with?("/watch?v=") ? "&#{params}" : ""
node[attribute.name] = "#{host_url}#{full_path}#{query_string_opt}"
else nil # Skip
end
end
end
@@ -3373,8 +3427,8 @@ post "/feed/webhook/:token" do |env|
views: video.views,
)
emails = PG_DB.query_all("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
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)",
video.id, video.published, video.ucid, as: String)
video_array = video.to_a
@@ -3384,15 +3438,6 @@ post "/feed/webhook/:token" do |env|
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
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
@@ -3445,14 +3490,12 @@ get "/c/:user" do |env|
user = env.params.url["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")]))
if !anchor
next env.redirect "/"
end
ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
next env.redirect "/" if !ucid
env.redirect anchor["href"]
env.redirect "/channel/#{ucid}"
end
# Legacy endpoint for /user/:username
@@ -3811,10 +3854,10 @@ get "/api/v1/captions/:id" do |env|
env.response.content_type = "text/vtt; charset=UTF-8"
caption = captions.select { |caption| caption.name.simpleText == label }
if lang
caption = captions.select { |caption| caption.languageCode == lang }
else
caption = captions.select { |caption| caption.name.simpleText == label }
end
if caption.empty?
@@ -3824,7 +3867,7 @@ get "/api/v1/captions/:id" do |env|
caption = caption[0]
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,
# as well as some other markup that makes it cumbersome, so we try to fix that here
@@ -4018,7 +4061,7 @@ get "/api/v1/annotations/:id" do |env|
cache_annotation(PG_DB, id, annotations)
end
when "youtube"
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
if response.status_code != 200
@@ -4029,7 +4072,13 @@ get "/api/v1/annotations/:id" do |env|
annotations = response.body
end
annotations
etag = sha256(annotations)[0, 16]
if env.request.headers["If-None-Match"]?.try &.== etag
env.response.status_code = 304
else
env.response.headers["ETag"] = etag
annotations
end
end
get "/api/v1/videos/:id" do |env|
@@ -4212,7 +4261,7 @@ get "/api/v1/channels/:ucid" do |env|
qualities.each do |quality|
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 "height", quality
end
@@ -4489,8 +4538,8 @@ get "/api/v1/search/suggestions" do |env|
query ||= ""
begin
client = make_client(URI.parse("https://suggestqueries.google.com"))
response = client.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback").body
headers = HTTP::Headers{":authority" => "suggestqueries.google.com"}
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 = response[35..-2]
body = JSON.parse(body).as_a
@@ -5117,7 +5166,7 @@ get "/api/manifest/dash/id/:id" do |env|
# 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
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
video = get_video(id, PG_DB, region: region)
@@ -5129,7 +5178,7 @@ get "/api/manifest/dash/id/:id" do |env|
end
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|
url = baseurl.lchop("<BaseURL>")
@@ -5154,7 +5203,7 @@ get "/api/manifest/dash/id/:id" do |env|
end
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.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
@@ -5192,9 +5241,7 @@ get "/api/manifest/dash/id/:id" do |env|
{"video/mp4", "video/webm"}.each do |mime_type|
mime_streams = video_streams.select { |stream| stream["type"].starts_with? mime_type }
if mime_streams.empty?
next
end
next if mime_streams.empty?
heights = [] of Int32
xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do
@@ -5456,8 +5503,8 @@ get "/videoplayback" do |env|
end
client = make_client(URI.parse(host), region)
response = HTTP::Client::Response.new(500)
error = ""
5.times do
begin
response = client.head(url, headers)
@@ -5482,12 +5529,14 @@ get "/videoplayback" do |env|
host = "https://r#{fvip}---#{mn}.googlevideo.com"
client = make_client(URI.parse(host), region)
rescue ex
error = ex.message
end
end
if response.status_code >= 400
env.response.status_code = response.status_code
next
env.response.content_type = "text/plain"
next error
end
if url.includes? "&file=seg.ts"
@@ -5501,7 +5550,7 @@ get "/videoplayback" do |env|
client = make_client(URI.parse(host), region)
client.get(url, headers) do |response|
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key)
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
@@ -5519,7 +5568,7 @@ get "/videoplayback" do |env|
next env.redirect location
end
IO.copy(response.body_io, env.response)
IO.copy response.body_io, env.response
end
rescue ex
end
@@ -5569,7 +5618,7 @@ get "/videoplayback" do |env|
end
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
end
end
@@ -5618,11 +5667,9 @@ get "/videoplayback" do |env|
end
get "/ggpht/*" do |env|
host = "https://yt3.ggpht.com"
client = make_client(URI.parse(host))
url = env.request.path.lchop("/ggpht")
headers = HTTP::Headers.new
headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]?
headers[header] = env.request.headers[header]
@@ -5630,10 +5677,10 @@ get "/ggpht/*" do |env|
end
begin
client.get(url, headers) do |response|
YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
@@ -5663,16 +5710,16 @@ get "/sb/:id/:storyboard/:index" do |env|
storyboard = env.params.url["storyboard"]
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}"
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|
if env.request.headers[header]?
headers[header] = env.request.headers[header]
@@ -5680,14 +5727,15 @@ get "/sb/:id/:storyboard/:index" do |env|
end
begin
client.get(url, headers) do |response|
YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
env.response.headers["Connection"] = "close"
env.response.headers["Access-Control-Allow-Origin"] = "*"
if response.status_code >= 300
@@ -5705,11 +5753,9 @@ get "/s_p/:id/:name" do |env|
id = env.params.url["id"]
name = env.params.url["name"]
host = "https://i9.ytimg.com"
client = make_client(URI.parse(host))
url = env.request.resource
headers = HTTP::Headers.new
headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]?
headers[header] = env.request.headers[header]
@@ -5717,10 +5763,10 @@ get "/s_p/:id/:name" do |env|
end
begin
client.get(url, headers) do |response|
YT_POOL.client &.get(url, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
@@ -5750,7 +5796,7 @@ get "/yts/img/:name" do |env|
YT_POOL.client &.get(env.request.resource, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
@@ -5772,9 +5818,11 @@ get "/vi/:id/:name" do |env|
id = env.params.url["id"]
name = env.params.url["name"]
headers = HTTP::Headers{":authority" => "i.ytimg.com"}
if name == "maxres.jpg"
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"
break
end
@@ -5782,7 +5830,6 @@ get "/vi/:id/:name" do |env|
end
url = "/vi/#{id}/#{name}"
headers = HTTP::Headers.new
REQUEST_HEADERS_WHITELIST.each do |header|
if env.request.headers[header]?
headers[header] = env.request.headers[header]
@@ -5790,10 +5837,10 @@ get "/vi/:id/:name" do |env|
end
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
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes? key
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
env.response.headers[key] = value
end
end
@@ -5812,12 +5859,75 @@ get "/vi/:id/:name" do |env|
end
get "/Captcha" do |env|
client = make_client(LOGIN_URL)
response = client.get(env.request.resource)
headers = HTTP::Headers{":authority" => "accounts.google.com"}
response = YT_POOL.client &.get(env.request.resource, headers)
env.response.headers["Content-Type"] = response.headers["Content-Type"]
response.body
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
get "/watch_videos" do |env|
response = YT_POOL.client &.get(env.request.resource)
@@ -5837,7 +5947,7 @@ error 404 do |env|
response = YT_POOL.client &.get("/#{item}")
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
if response.body.empty?
@@ -5846,10 +5956,10 @@ error 404 do |env|
end
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
env.response.headers["Location"] = "/channel/#{ucid["content"]}"
env.response.headers["Location"] = "/channel/#{ucid}"
halt env, status_code: 302
end
@@ -5892,6 +6002,7 @@ end
public_folder "assets"
Kemal.config.powered_by_header = false
add_handler ProxyHandler.new
add_handler FilteredCompressHandler.new
add_handler APIHandler.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)
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?
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,
)
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",
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,
# so since they don't provide a published date here we can safely ignore them.
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",
video.id, video.published, video.ucid, as: String)
@@ -373,7 +383,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
end
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)
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)
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
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"
when "newest", "newest_created"
url += "&sort=dd"
else nil # Ignore
end
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
when "oldest"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64
else nil # Ignore
end
object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"])))
@@ -484,7 +489,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
"80226972:embedded" => {
"2:string" => ucid,
"3:base64" => {
"2:string" => "playlist",
"2:string" => "playlists",
"6:varint": 2_i64,
"7:varint": 1_i64,
"12:varint": 1_i64,
@@ -494,10 +499,10 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
},
}
if !auto_generated
cursor = Base64.urlsafe_encode(cursor, false)
if cursor
cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated
object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
end
object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor
if auto_generated
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
when "last", "last_added"
object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64
else nil # Ignore
end
end
@@ -530,8 +536,17 @@ def extract_channel_playlists_cursor(url, auto_generated)
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(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 &.[1].as_s || ""
.try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h.find { |k, v| k.starts_with? "15:" } }
.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
cursor = URI.decode_www_form(cursor)
@@ -544,11 +559,11 @@ end
# TODO: Add "sort_by"
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")
if response.status_code == 404
if response.status_code != 200
response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en")
end
if response.status_code == 404
if response.status_code != 200
error_message = translate(locale, "This channel does not exist.")
raise error_message
end
@@ -616,15 +631,13 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config, fo
post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
if !post
next
end
next if !post
if !post["contentText"]?
content_html = ""
else
content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
content_to_comment_html(post["contentText"]["runs"].as_a).try &.to_s || ""
content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s ||
post["contentText"]["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || ""
end
author = post["authorText"]?.try &.["simpleText"]? || ""
@@ -797,7 +810,7 @@ def produce_channel_community_continuation(ucid, cursor)
object = {
"80226972:embedded" => {
"2:string" => ucid,
"3:string" => cursor,
"3:string" => cursor || "",
},
}
@@ -835,7 +848,7 @@ end
def get_about_info(ucid, locale)
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")
end
@@ -843,6 +856,11 @@ def get_about_info(ucid, locale)
raise ChannelRedirect.new(channel_id: md["ucid"])
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)
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"]
end
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
content_to_comment_html(node_comment["contentText"]["runs"].as_a).try &.to_s || ""
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.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"]? || ""
json.field "author", author
@@ -294,7 +294,7 @@ def template_youtube_comments(comments, locale, thin_mode)
<div class="pure-u-23-24">
<p>
<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>
</div>
</div>
@@ -347,7 +347,7 @@ def template_youtube_comments(comments, locale, thin_mode)
END_HTML
else
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
@@ -356,6 +356,7 @@ def template_youtube_comments(comments, locale, thin_mode)
</div>
</div>
END_HTML
else nil # Ignore
end
end
@@ -413,7 +414,7 @@ def template_youtube_comments(comments, locale, thin_mode)
<div class="pure-u-1">
<p>
<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>
</div>
</div>
@@ -451,7 +452,7 @@ def template_reddit_comments(root, locale)
html << <<-END_HTML
<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>
#{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>
@@ -556,7 +557,7 @@ def content_to_comment_html(content)
video_id = watch_endpoint["videoId"].as_s
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
text = %(<a href="/watch?v=#{video_id}">#{text}</a>)
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
when "new", "newest"
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
continuation = object.try { |i| Protodec::Any.cast_json(object) }

View File

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

View File

@@ -173,6 +173,8 @@ struct Config
yaml.scalar "ipv4"
when Socket::Family::INET6
yaml.scalar "ipv6"
when Socket::Family::UNIX
raise "Invalid socket family #{value}"
end
end
@@ -192,6 +194,27 @@ struct Config
end
end
module StringToCookies
def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
(value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
unless node.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{node.class}"
end
cookies = HTTP::Cookies.new
node.value.split(";").each do |cookie|
next if cookie.strip.empty?
name, value = cookie.split("=", 2)
cookies << HTTP::Cookie.new(name.strip, value.strip)
end
cookies
end
end
def disabled?(option)
case disabled = CONFIG.disable_proxy
when Bool
@@ -202,6 +225,8 @@ struct Config
else
return false
end
else
return false
end
end
@@ -236,6 +261,12 @@ struct Config
host_binding: {type: String, default: "0.0.0.0"}, # Host to bind (overrided by command line argument)
pool_size: {type: Int32, default: 100}, # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
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
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
@@ -497,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|
type = child_node.xpath_node(%q(./div))
if !type
next
end
next if !type
case type["class"]
when .includes? "yt-lockup-video"
@@ -576,6 +605,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
videos: videos,
thumbnail: playlist_thumbnail
)
else
next # Skip
end
end
@@ -709,9 +740,7 @@ def cache_annotation(db, id, annotations)
body = XML.parse(annotations)
nodeset = body.xpath_nodes(%q(/document/annotations/annotation))
if nodeset == 0
return
end
return if nodeset == 0
has_legacy_annotations = false
nodeset.each do |node|
@@ -742,7 +771,7 @@ def create_notification_stream(env, config, kemal_config, decrypt_function, topi
loop do
time_span = [0, 0, 0, 0]
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 = 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?
translation = locale[translation].as_s
end
else
raise "Invalid translation #{translation}"
end
end

View File

@@ -127,8 +127,6 @@ def subscribe_to_feeds(db, logger, key, config)
end
max_channel = Channel(Int32).new
client_pool = HTTPPool.new(PUBSUB_URL, capacity: max_threads, timeout: 0.05)
spawn do
max_threads = max_channel.receive
active_threads = 0
@@ -149,7 +147,7 @@ def subscribe_to_feeds(db, logger, key, config)
spawn do
begin
response = subscribe_pubsub(ucid, key, config, client_pool)
response = subscribe_pubsub(ucid, key, config)
if response.status_code >= 400
logger.puts("#{ucid} : #{response.body}")
@@ -228,10 +226,164 @@ def update_decrypt_function
yield decrypt_function
rescue ex
next
ensure
sleep 1.minute
Fiber.yield
end
end
end
sleep 1.minute
Fiber.yield
def bypass_captcha(captcha_key, logger)
loop do
begin
{"/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|
response = YT_POOL.client &.get(path)
if response.body.includes?("To continue with your YouTube experience, please fill out the form below.")
html = XML.parse_html(response.body)
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
form.xpath_nodes(%(.//input[@name])).map do |node|
inputs[node["name"]] = node["value"]
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
rescue ex
logger.puts("Exception: #{ex.message}")
ensure
sleep 1.minute
Fiber.yield
end
end
end

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
class HTTPProxy
getter proxy_host : String
@@ -90,19 +94,6 @@ class HTTPClient < HTTP::Client
return opts
end
def exec(request)
if self.host == "www.youtube.com"
request.headers["x-youtube-client-name"] ||= "1"
request.headers["x-youtube-client-version"] ||= "1.20180719"
request.headers["user-agent"] ||= random_user_agent
request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["accept-language"] ||= "en-us,en;q=0.5"
end
super
end
end
def get_proxies(country_code = "US")
@@ -114,6 +105,7 @@ def filter_proxies(proxies)
proxies.select! do |proxy|
begin
client = HTTPClient.new(YT_URL)
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
@@ -136,7 +128,7 @@ def get_nova_proxies(country_code = "US")
client.connect_timeout = 10.seconds
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-Language"] = "Accept-Language: en-US,en;q=0.9"
headers["Host"] = "www.proxynova.com"
@@ -173,7 +165,7 @@ def get_spys_proxies(country_code = "US")
client.connect_timeout = 10.seconds
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-Language"] = "Accept-Language: en-US,en;q=0.9"
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")
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
function_name = player.match(/^(?<name>[^=]+)=function\(a\){a=a\.split\(""\)/m).not_nil!["name"]
function_body = player.match(/^#{Regex.escape(function_name)}=function\(a\){(?<body>[^}]+)}/m).not_nil!["body"]
function_name = player.match(/^(?<name>[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"]
function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?<body>[^}]+)}/m).not_nil!["body"]
function_body = function_body.split(";")[1..-2]
var_name = function_body[0][0, 2]
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|
op_name = operation.match(/^[^:]+/).not_nil![0]
op_body = operation.match(/\{[^}]+/).not_nil![0]
case op_body
when "{a.reverse()"
operations[op_name] = "a"
operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse }
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
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
decrypt_function = [] of {name: String, value: Int32}
decrypt_function = [] of {SigProc, Int32}
function_body.each do |function|
function = function.lchop(var_name).delete("[].")
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
return decrypt_function
end
def decrypt_signature(fmt, code)
if !fmt["s"]?
return ""
def decrypt_signature(fmt, op)
return "" if !fmt["s"]? || !fmt["sp"]?
sp = fmt["sp"]
sig = fmt["s"].split("")
op.each do |proc, value|
sig = proc.call(sig, value)
end
a = fmt["s"]
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
return "&#{sp}=#{sig.join("")}"
end

View File

@@ -1,3 +1,5 @@
require "crypto/subtle"
def generate_token(email, scopes, expire, key, db)
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)
@@ -41,15 +43,10 @@ def sign_token(key, hash)
string_to_sign = [] of String
hash.each do |key, value|
if key == "signature"
next
end
next if key == "signature"
if value.is_a?(JSON::Any)
case value
when .as_a?
value = value.as_a.map { |item| item.as_s }
end
if value.is_a?(JSON::Any) && value.as_a?
value = value.as_a.map { |i| i.as_s }
end
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")
end
if token["signature"] != sign_token(key, token)
raise translate(locale, "Invalid signature")
expire = token["expire"]?.try &.as_i
if expire.try &.< Time.utc.to_unix
raise translate(locale, "Token is expired, please try again")
end
if token["session"] != session
raise translate(locale, "Erroneous token")
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 nonce[1] > Time.utc
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
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}
end

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ end
def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
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
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
when "year"
object["2:embedded"].as(Hash)["1:varint"] = 5_i64
else nil # Ignore
end
case content_type
@@ -333,7 +334,8 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
when "short"
object["2:embedded"].as(Hash)["3:varint"] = 1_i64
when "long"
object["2:embedded"].as(Hash)["3:varint"] = 18_i64
object["2:embedded"].as(Hash)["3:varint"] = 2_i64
else nil # Ignore
end
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
when "hdr"
object["2:embedded"].as(Hash)["25:varint"] = 1_i64
else nil # Ignore
end
end

View File

@@ -1,6 +1,6 @@
def fetch_trending(trending_type, region, locale)
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 = region.upcase
@@ -39,33 +39,13 @@ def fetch_trending(trending_type, region, locale)
end
def extract_plid(url)
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["bp"]
wrapper = URI.decode_www_form(wrapper)
wrapper = Base64.decode(wrapper)
# 0xe2 0x02 0x2e
wrapper += 3
# 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)
plid = URI.parse(url)
.try { |i| HTTP::Params.parse(i.query.not_nil!)["bp"] }
.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) }
.try { |i| i["44:0:embedded"]["2:1:string"].as_s }
return plid
end

View File

@@ -350,6 +350,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
notifications.sort_by! { |video| video.author }
when "channel name - reverse"
notifications.sort_by! { |video| video.author }.reverse!
else nil # Ignore
end
else
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 }
when "channel name - reverse"
videos.sort_by! { |video| video.author }.reverse!
else nil # Ignore
end
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"]?
fmt["url"] = fmt_stream["url"].as_s
end
if fmt_stream["cipher"]?
HTTP::Params.parse(fmt_stream["cipher"].as_s).each do |key, value|
if cipher = fmt_stream["cipher"]? || fmt_stream["signatureCipher"]?
HTTP::Params.parse(cipher.as_s).each do |key, value|
fmt[key] = value
end
end
@@ -621,10 +621,7 @@ struct Video
if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]?
fmts.as_a.each do |adaptive_fmt|
if !adaptive_fmt.as_h?
next
end
next if !adaptive_fmt.as_h?
fmt = {} of String => String
if init = adaptive_fmt["initRange"]?
@@ -641,8 +638,8 @@ struct Video
if adaptive_fmt["url"]?
fmt["url"] = adaptive_fmt["url"].as_s
end
if adaptive_fmt["cipher"]?
HTTP::Params.parse(adaptive_fmt["cipher"].as_s).each do |key, value|
if cipher = adaptive_fmt["cipher"]? || adaptive_fmt["signatureCipher"]?
HTTP::Params.parse(cipher.as_s).each do |key, value|
fmt[key] = value
end
end
@@ -1253,6 +1250,7 @@ def fetch_video(id, region)
genre_url = "/channel/UCfFyYRYslvuhwMDnx6KjUvw"
when "Trailers"
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
else nil # Ignore
end
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>
<script>
var playlist_data = {
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>',
}
<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"></script>
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<div class="pure-g">
<% videos.each_slice(4) do |slice| %>

View File

@@ -92,7 +92,7 @@
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-lg-1-5">
<% 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") %>
</a>
<% end %>
@@ -100,7 +100,7 @@
<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">
<% 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") %>
</a>
<% end %>

View File

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

View File

@@ -44,7 +44,7 @@
<% end %>
</div>
<% end %>
<p><%= item.title %></p>
<p><%= HTML.escape(item.title) %></p>
</a>
<p>
<b>
@@ -57,10 +57,10 @@
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% 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) || "") %>">
<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">
<i class="icon ion-md-trash"></i>
</button>
@@ -76,7 +76,7 @@
<% end %>
</div>
<% 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>
<p>
<b>
@@ -103,13 +103,12 @@
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% 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) || "") %>">
<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">
<i onmouseenter='this.setAttribute("class", "icon ion-ios-eye-off")'
onmouseleave='this.setAttribute("class", "icon ion-ios-eye")'
<i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye"
class="icon ion-ios-eye">
</i>
</button>
@@ -117,10 +116,10 @@
</p>
</form>
<% 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) || "") %>">
<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">
<i class="icon ion-md-add"></i>
</button>
@@ -137,7 +136,7 @@
</div>
</a>
<% 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>
<b>
<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) %>"
id="player" class="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"]'
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
<% if params.autoplay %>autoplay<% end %>
<% if params.video_loop %>loop<% end %>
<% if params.controls %>controls<% end %>>
@@ -39,12 +36,14 @@
<% end %>
</video>
<script>
var player_data = {
aspect_ratio: '<%= aspect_ratio %>',
title: "<%= video.title.dump_unquoted %>",
description: "<%= HTML.escape(video.short_description) %>",
thumbnail: "<%= thumbnail %>"
}
<script id="player_data" type="application/json">
<%=
{
"aspect_ratio" => aspect_ratio,
"title" => video.title,
"description" => HTML.escape(video.short_description),
"thumbnail" => thumbnail
}.to_pretty_json
%>
</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-share.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/videojs-contrib-quality-levels.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>
<% end %>
<script>
var subscribe_data = {
ucid: '<%= ucid %>',
author: '<%= HTML.escape(author) %>',
sub_count_text: '<%= HTML.escape(sub_count_text) %>',
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>',
subscribe_text: '<%= HTML.escape(translate(locale, "Subscribe")) %>',
unsubscribe_text: '<%= HTML.escape(translate(locale, "Unsubscribe")) %>'
}
<script id="subscribe_data" type="application/json">
<%=
{
"ucid" => ucid,
"author" => HTML.escape(author),
"sub_count_text" => HTML.escape(sub_count_text),
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || ""),
"subscribe_text" => HTML.escape(translate(locale, "Subscribe")),
"unsubscribe_text" => HTML.escape(translate(locale, "Unsubscribe"))
}.to_pretty_json
%>
</script>
<script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% else %>

View File

@@ -10,33 +10,24 @@
<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/darktheme.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<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>
<body>
<script>
var video_data = {
id: '<%= video.id %>',
index: '<%= continuation %>',
plid: '<%= plid %>',
length_seconds: '<%= video.length_seconds.to_f %>',
video_series: <%= video_series.to_json %>,
params: <%= params.to_json %>,
preferences: <%= preferences.to_json %>,
premiere_timestamp: <%= video.premiere_timestamp.try &.to_unix || "null" %>
}
<script id="video_data" type="application/json">
<%=
{
"id" => video.id,
"index" => continuation,
"plid" => plid,
"length_seconds" => video.length_seconds.to_f,
"video_series" => video_series,
"params" => params,
"preferences" => preferences,
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix
}.to_pretty_json
%>
</script>
<%= rendered "components/player" %>

View File

@@ -18,10 +18,12 @@
</div>
</div>
<script>
var watched_data = {
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>',
}
<script id="watched_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/watched_widget.js"></script>
@@ -34,10 +36,10 @@ var watched_data = {
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
<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) || "") %>">
<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">
<i class="icon ion-md-trash"></i>
</button>

View File

@@ -22,69 +22,6 @@
<hr>
<% 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" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post">
<fieldset>
@@ -121,6 +58,69 @@
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button>
</fieldset>
</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 %>
</div>
</div>

View File

@@ -69,12 +69,14 @@
</div>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
<script>
var playlist_data = {
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>',
}
<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"></script>
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
<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-lg-1-5" style="text-align:right">
<% 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") %>
</a>
<% end %>

View File

@@ -2,12 +2,6 @@
<title><%= translate(locale, "Preferences") %> - Invidious</title>
<% end %>
<script>
function update_value(element) {
document.getElementById('volume-value').innerText = element.value;
}
</script>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
@@ -65,7 +59,7 @@ function update_value(element) {
<div class="pure-control-group">
<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>
</div>
@@ -205,7 +199,7 @@ function update_value(element) {
<% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || config.https_only %>
<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>
<% end %>
<% end %>

View File

@@ -37,9 +37,9 @@
<div class="pure-u-2-5"></div>
<div class="pure-u-1-5" style="text-align:right">
<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) || "") %>">
<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") %>">
</a>
</form>
@@ -52,32 +52,3 @@
<% end %>
</div>
<% 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>
</div>
<script>
var watched_data = {
csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>',
}
<script id="watched_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/watched_widget.js"></script>

View File

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

View File

@@ -29,9 +29,9 @@
</div>
<div class="pure-u-1-5" style="text-align:right">
<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) || "") %>">
<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") %>">
</a>
</form>
@@ -44,32 +44,3 @@
<% end %>
</div>
<% 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

@@ -1,12 +1,12 @@
<% content_for "header" do %>
<meta name="thumbnail" content="<%= thumbnail %>">
<meta name="description" content="<%= video.short_description %>">
<meta name="description" content="<%= HTML.escape(video.short_description) %>">
<meta name="keywords" content="<%= video.keywords.join(",") %>">
<meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= host_url %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= HTML.escape(video.title) %>">
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg">
<meta property="og:description" content="<%= video.short_description %>">
<meta property="og:description" content="<%= HTML.escape(video.short_description) %>">
<meta property="og:type" content="video.other">
<meta property="og:video:url" content="<%= host_url %>/embed/<%= video.id %>">
<meta property="og:video:secure_url" content="<%= host_url %>/embed/<%= video.id %>">
@@ -17,7 +17,7 @@
<meta name="twitter:site" content="@omarroth1">
<meta name="twitter:url" content="<%= host_url %>/watch?v=<%= video.id %>">
<meta name="twitter:title" content="<%= HTML.escape(video.title) %>">
<meta name="twitter:description" content="<%= video.short_description %>">
<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>">
<meta name="twitter:image" content="<%= host_url %>/vi/<%= video.id %>/maxres.jpg">
<meta name="twitter:player" content="<%= host_url %>/embed/<%= video.id %>">
<meta name="twitter:player:width" content="1280">
@@ -26,24 +26,26 @@
<title><%= HTML.escape(video.title) %> - Invidious</title>
<% end %>
<script>
var video_data = {
id: '<%= video.id %>',
index: '<%= continuation %>',
plid: '<%= plid %>',
length_seconds: <%= video.length_seconds.to_f %>,
play_next: <%= !rvs.empty? && !plid && params.continue %>,
next_video: '<%= rvs.select { |rv| rv["id"]? }[0]?.try &.["id"] %>',
youtube_comments_text: '<%= HTML.escape(translate(locale, "View YouTube comments")) %>',
reddit_comments_text: '<%= HTML.escape(translate(locale, "View Reddit comments")) %>',
reddit_permalink_text: '<%= HTML.escape(translate(locale, "View more comments on Reddit")) %>',
comments_text: '<%= HTML.escape(translate(locale, "View `x` comments", "{commentCount}")) %>',
hide_replies_text: '<%= HTML.escape(translate(locale, "Hide replies")) %>',
show_replies_text: '<%= HTML.escape(translate(locale, "Show replies")) %>',
params: <%= params.to_json %>,
preferences: <%= preferences.to_json %>,
premiere_timestamp: <%= video.premiere_timestamp.try &.to_unix || "null" %>
}
<script id="video_data" type="application/json">
<%=
{
"id" => video.id,
"index" => continuation,
"plid" => plid,
"length_seconds" => video.length_seconds.to_f,
"play_next" => !rvs.empty? && !plid && params.continue,
"next_video" => rvs.select { |rv| rv["id"]? }[0]?.try &.["id"],
"youtube_comments_text" => HTML.escape(translate(locale, "View YouTube comments")),
"reddit_comments_text" => HTML.escape(translate(locale, "View Reddit comments")),
"reddit_permalink_text" => HTML.escape(translate(locale, "View more comments on Reddit")),
"comments_text" => HTML.escape(translate(locale, "View `x` comments", "{commentCount}")),
"hide_replies_text" => HTML.escape(translate(locale, "Hide replies")),
"show_replies_text" => HTML.escape(translate(locale, "Show replies")),
"params" => params,
"preferences" => preferences,
"premiere_timestamp" => video.premiere_timestamp.try &.to_unix
}.to_pretty_json
%>
</script>
<div id="player-container" class="h-box">
@@ -99,6 +101,34 @@ var video_data = {
<% end %>
</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") %>
<p><%= translate(locale, "Download is disabled.") %></p>
<% else %>
@@ -135,9 +165,9 @@ var video_data = {
</form>
<% end %>
<p><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><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p>
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p id="dislikes"><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p>
<p id="genre"><%= translate(locale, "Genre: ") %>
<% if video.genre_url.empty? %>
<%= video.genre %>