Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bab26a3a | ||
|
|
ceb252986e | ||
|
|
750ef296c6 | ||
|
|
454ae8656a | ||
|
|
75450dcdbc | ||
|
|
bd2c7e3bb9 | ||
|
|
9d23cf33fd | ||
|
|
97eb01a28d | ||
|
|
9a2a636aed | ||
|
|
61c8256ef0 | ||
|
|
8e1791570e | ||
|
|
aa30d1f359 | ||
|
|
326f4bd681 | ||
|
|
7690c6c33d | ||
|
|
fece1077f2 | ||
|
|
75fc7db50d | ||
|
|
96da04576e | ||
|
|
001ec3663e | ||
|
|
21a00b77bd | ||
|
|
408f3852ec | ||
|
|
61150c74d2 | ||
|
|
7bb7003c9d | ||
|
|
920463f2ff | ||
|
|
ca1185d0be | ||
|
|
be655ee328 | ||
|
|
02d4186b11 | ||
|
|
3f97bebd69 | ||
|
|
2e378da922 | ||
|
|
b37f51bd7f | ||
|
|
eb8b0f72cc | ||
|
|
d8fe9a4d29 | ||
|
|
c97cdf551e | ||
|
|
80fc60b5e2 | ||
|
|
3b2e142542 | ||
|
|
0e58d99f4e | ||
|
|
92798abb5d | ||
|
|
bd7950b757 | ||
|
|
59a15ceef6 | ||
|
|
4011a113cc | ||
|
|
70cbe91776 | ||
|
|
f92027c44b | ||
|
|
1443335315 | ||
|
|
6ff2229a09 | ||
|
|
bb72672dd9 | ||
|
|
d96dee3aa6 | ||
|
|
bd0aaa343b | ||
|
|
3126e1ac94 | ||
|
|
a117d87f33 | ||
|
|
9dc4f8a1aa | ||
|
|
0d536d11e3 | ||
|
|
72a4962fd0 | ||
|
|
a3045a3953 | ||
|
|
c620a22017 | ||
|
|
856ec03cc7 | ||
|
|
c80c5631f0 | ||
|
|
ef70668a77 | ||
|
|
ebd4691462 | ||
|
|
28554235be | ||
|
|
efbbb6fd20 | ||
|
|
9de57021a3 | ||
|
|
e21f770485 | ||
|
|
697c00dccf | ||
|
|
1caf6a3298 | ||
|
|
02fd02d482 | ||
|
|
239fb0db94 | ||
|
|
fe1d73c3e5 | ||
|
|
43da06a354 | ||
|
|
fea6b67067 | ||
|
|
f065ae54d5 | ||
|
|
3cf417766d | ||
|
|
0fb41b10e9 | ||
|
|
bc9dc3bf1e | ||
|
|
3cde5e28a8 | ||
|
|
cb8e7181c4 | ||
|
|
9a3becdecc | ||
|
|
e3c10d779d | ||
|
|
dd9f1024f4 | ||
|
|
9841f74adc | ||
|
|
b56e493d92 | ||
|
|
a2c5211b20 | ||
|
|
b7a7abed48 | ||
|
|
72bfdfd925 | ||
|
|
b80d34612a | ||
|
|
648cc0f006 | ||
|
|
830692dd60 | ||
|
|
95a6759381 | ||
|
|
960b37b1c2 | ||
|
|
b1d17dea4f | ||
|
|
6b06471953 | ||
|
|
4ca957d3eb | ||
|
|
eb9b63477c | ||
|
|
80c01b055c | ||
|
|
50aec67069 | ||
|
|
7baced75e5 | ||
|
|
99743a94fb | ||
|
|
9bdfd6025b | ||
|
|
91400d2ce0 | ||
|
|
7b88d0efe3 | ||
|
|
4aada65dae | ||
|
|
0560d2cfb7 | ||
|
|
58c1a68ad9 | ||
|
|
588fc6df85 | ||
|
|
2c9e4ded40 | ||
|
|
88a538e71b | ||
|
|
513363504f | ||
|
|
0e844edacb | ||
|
|
5751bb2481 | ||
|
|
28669d940a | ||
|
|
3d87bdb6b4 | ||
|
|
1499ce43bf | ||
|
|
4d22b43d65 | ||
|
|
823603650f | ||
|
|
062867a38d | ||
|
|
f3e0c5d653 | ||
|
|
fc7f48b7db | ||
|
|
04d56420d1 | ||
|
|
a017574f74 | ||
|
|
ae24360c02 | ||
|
|
3fea1976c8 | ||
|
|
cf97dd9fcd | ||
|
|
0e3a48ff76 | ||
|
|
276bf09238 |
@@ -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
|
||||
|
||||
22
README.md
22
README.md
@@ -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
10
assets/css/embed.css
Normal 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;
|
||||
}
|
||||
2
assets/css/video-js.min.css
vendored
2
assets/css/video-js.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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];
|
||||
|
||||
@@ -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
3
assets/js/global.js
Normal 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
144
assets/js/handlers.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
@@ -1,3 +1,5 @@
|
||||
var notification_data = JSON.parse(document.getElementById('notification_data').innerHTML);
|
||||
|
||||
var notifications, delivered;
|
||||
|
||||
function get_subscriptions(callback, retries) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)';
|
||||
|
||||
|
||||
@@ -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' : '';
|
||||
|
||||
11
assets/js/video.min.js
vendored
11
assets/js/video.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Type: public.privacy
|
||||
|
||||
-- DROP TYPE public.privacy;
|
||||
|
||||
CREATE TYPE public.privacy AS ENUM
|
||||
(
|
||||
'Public',
|
||||
'Unlisted',
|
||||
'Private'
|
||||
);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
FROM alpine:edge
|
||||
RUN apk add --no-cache crystal shards libc-dev \
|
||||
yaml-dev libxml2-dev sqlite-dev zlib-dev curl && \
|
||||
curl -Lo /etc/apk/keys/omarroth.rsa.pub https://github.com/omarroth/boringssl-alpine/releases/download/1.1.0-r0/omarroth.rsa.pub && \
|
||||
FROM alpine:edge AS builder
|
||||
RUN apk add --no-cache curl crystal shards libc-dev \
|
||||
yaml-dev libxml2-dev sqlite-dev zlib-dev openssl-dev \
|
||||
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 update && \
|
||||
apk add boringssl-dev.apk lsquic.apk && \
|
||||
rm -rf /var/cache/apk/* boringssl-dev.apk lsquic.apk
|
||||
WORKDIR /invidious
|
||||
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 cp /usr/lib/libcrypto.a ./lib/lsquic/src/lsquic/ext/libcrypto.a && \
|
||||
cp /usr/lib/libssl.a ./lib/lsquic/src/lsquic/ext/libssl.a && \
|
||||
cp /usr/lib/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a
|
||||
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 --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
|
||||
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" ]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1
kubernetes/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/charts/*.tgz
|
||||
6
kubernetes/Chart.lock
Normal file
6
kubernetes/Chart.lock
Normal 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
22
kubernetes/Chart.yaml
Normal 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
41
kubernetes/README.md
Normal 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
|
||||
```
|
||||
16
kubernetes/templates/_helpers.tpl
Normal file
16
kubernetes/templates/_helpers.tpl
Normal 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 -}}
|
||||
11
kubernetes/templates/configmap.yaml
Normal file
11
kubernetes/templates/configmap.yaml
Normal 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 }}
|
||||
53
kubernetes/templates/deployment.yaml
Normal file
53
kubernetes/templates/deployment.yaml
Normal 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
|
||||
18
kubernetes/templates/hpa.yaml
Normal file
18
kubernetes/templates/hpa.yaml
Normal 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 }}
|
||||
16
kubernetes/templates/service.yaml
Normal file
16
kubernetes/templates/service.yaml
Normal 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
51
kubernetes/values.yaml
Normal 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:
|
||||
@@ -333,4 +333,4 @@
|
||||
"Playlists": "قوائم التشغيل",
|
||||
"Community": "المجتمع",
|
||||
"Current version: ": "الإصدار الحالي: "
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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: "
|
||||
}
|
||||
@@ -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: ": "",
|
||||
|
||||
@@ -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.",
|
||||
@@ -333,4 +333,4 @@
|
||||
"Playlists": "Listes de lecture",
|
||||
"Community": "Communauté",
|
||||
"Current version: ": "Version actuelle : "
|
||||
}
|
||||
}
|
||||
335
locales/hu-HU.json
Normal file
335
locales/hu-HU.json
Normal 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ó: "
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 のコメントを見る",
|
||||
@@ -384,4 +384,4 @@
|
||||
"Playlists": "プレイリスト",
|
||||
"Community": "コミュニティ",
|
||||
"Current version: ": "現在のバージョン: "
|
||||
}
|
||||
}
|
||||
@@ -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: ",
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
336
locales/pt-BR.json
Normal 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
387
locales/pt-PT.json
Normal 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
336
locales/ro.json
Normal 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ă: "
|
||||
}
|
||||
@@ -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
336
locales/sr_Cyrl.json
Normal 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
336
locales/sv-SE.json
Normal 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: "
|
||||
}
|
||||
@@ -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: "
|
||||
}
|
||||
@@ -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: ": "Поточна версія: "
|
||||
}
|
||||
@@ -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: ": "当前版本:"
|
||||
}
|
||||
@@ -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: ": "目前版本: "
|
||||
}
|
||||
10
shard.yml
10
shard.yml
@@ -11,13 +11,13 @@ 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
|
||||
@@ -26,8 +26,8 @@ dependencies:
|
||||
version: ~> 0.1.2
|
||||
lsquic:
|
||||
github: omarroth/lsquic.cr
|
||||
version: ~> 0.1.3
|
||||
branch: dev
|
||||
|
||||
crystal: 0.31.1
|
||||
crystal: 0.34.0
|
||||
|
||||
license: AGPLv3
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
321
src/invidious.cr
321
src/invidious.cr
@@ -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 = QUICPool.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,7 +211,7 @@ 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
|
||||
@@ -245,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
|
||||
@@ -366,6 +387,8 @@ get "/" do |env|
|
||||
else
|
||||
templated "popular"
|
||||
end
|
||||
else
|
||||
templated "empty"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -412,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"]?
|
||||
@@ -452,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
|
||||
@@ -597,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
|
||||
@@ -624,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"]?
|
||||
@@ -703,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)
|
||||
@@ -732,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
|
||||
@@ -1227,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
|
||||
@@ -1248,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
|
||||
@@ -1437,7 +1465,7 @@ post "/login" do |env|
|
||||
# See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
|
||||
# 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")
|
||||
@@ -1460,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"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
|
||||
|
||||
response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req))
|
||||
lookup_results = JSON.parse(response.body[5..-1])
|
||||
@@ -1529,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
|
||||
|
||||
@@ -1634,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."
|
||||
@@ -1819,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
|
||||
@@ -2226,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
|
||||
@@ -2384,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
|
||||
@@ -2538,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)
|
||||
@@ -2587,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
|
||||
@@ -2628,6 +2663,7 @@ post "/data_control" do |env|
|
||||
end
|
||||
end
|
||||
end
|
||||
else nil # Ignore
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2969,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
|
||||
@@ -3111,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
|
||||
|
||||
@@ -3128,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,
|
||||
@@ -3265,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
|
||||
@@ -3388,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
|
||||
@@ -3399,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
|
||||
|
||||
@@ -3460,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
|
||||
@@ -3826,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?
|
||||
@@ -3839,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
|
||||
@@ -4033,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
|
||||
@@ -4233,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
|
||||
@@ -4510,9 +4538,8 @@ get "/api/v1/search/suggestions" do |env|
|
||||
query ||= ""
|
||||
|
||||
begin
|
||||
response = QUIC::Client.get(
|
||||
"https://suggestqueries.google.com/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
|
||||
@@ -5139,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)
|
||||
@@ -5151,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>")
|
||||
@@ -5176,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",
|
||||
@@ -5214,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
|
||||
@@ -5478,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)
|
||||
@@ -5504,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"
|
||||
@@ -5523,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
|
||||
@@ -5541,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
|
||||
@@ -5591,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
|
||||
@@ -5640,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]
|
||||
@@ -5652,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
|
||||
@@ -5685,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]
|
||||
@@ -5702,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
|
||||
@@ -5727,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]
|
||||
@@ -5739,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
|
||||
@@ -5772,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
|
||||
@@ -5794,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
|
||||
@@ -5804,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]
|
||||
@@ -5812,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
|
||||
@@ -5834,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)
|
||||
@@ -5859,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?
|
||||
@@ -5868,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
|
||||
|
||||
@@ -5914,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
|
||||
|
||||
@@ -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"])))
|
||||
@@ -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")]))
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -223,6 +225,8 @@ struct Config
|
||||
else
|
||||
return false
|
||||
end
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -259,6 +263,10 @@ struct Config
|
||||
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
|
||||
|
||||
@@ -520,9 +528,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
|
||||
|
||||
shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list") or contains(@class, "expanded-shelf-content-list")]/li)).each do |child_node|
|
||||
type = child_node.xpath_node(%q(./div))
|
||||
if !type
|
||||
next
|
||||
end
|
||||
next if !type
|
||||
|
||||
case type["class"]
|
||||
when .includes? "yt-lockup-video"
|
||||
@@ -599,6 +605,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
|
||||
videos: videos,
|
||||
thumbnail: playlist_thumbnail
|
||||
)
|
||||
else
|
||||
next # Skip
|
||||
end
|
||||
end
|
||||
|
||||
@@ -732,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|
|
||||
@@ -765,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -238,60 +236,147 @@ end
|
||||
def bypass_captcha(captcha_key, logger)
|
||||
loop do
|
||||
begin
|
||||
response = YT_POOL.client &.get("/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
|
||||
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"]
|
||||
{"/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)
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: {
|
||||
"clientKey" => CONFIG.captcha_key,
|
||||
"task" => {
|
||||
"type" => "NoCaptchaTaskProxyless",
|
||||
# "type" => "NoCaptchaTask",
|
||||
"websiteURL" => "https://www.youtube.com/watch?v=CvFH_6DNRCY&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999",
|
||||
"websiteKey" => site_key,
|
||||
# "proxyType" => "http",
|
||||
# "proxyAddress" => CONFIG.proxy_address,
|
||||
# "proxyPort" => CONFIG.proxy_port,
|
||||
# "proxyLogin" => CONFIG.proxy_user,
|
||||
# "proxyPassword" => CONFIG.proxy_pass,
|
||||
# "userAgent" => "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36",
|
||||
},
|
||||
}.to_json).body)
|
||||
|
||||
if response["error"]?
|
||||
raise response["error"].as_s
|
||||
end
|
||||
|
||||
task_id = response["taskId"].as_i
|
||||
|
||||
loop do
|
||||
sleep 10.seconds
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: {
|
||||
"clientKey" => CONFIG.captcha_key,
|
||||
"taskId" => task_id,
|
||||
}.to_json).body)
|
||||
|
||||
if response["status"]?.try &.== "ready"
|
||||
break
|
||||
elsif response["errorId"]?.try &.as_i != 0
|
||||
raise response["errorDescription"].as_s
|
||||
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
|
||||
|
||||
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
|
||||
response = YT_POOL.client &.post("/das_captcha", headers, form: inputs)
|
||||
|
||||
yield response.cookies.select { |cookie| cookie.name != "PREF" }
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("Exception: #{ex.message}")
|
||||
|
||||
@@ -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
|
||||
@@ -124,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"
|
||||
@@ -161,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,69 +2,16 @@ require "lsquic"
|
||||
require "pool/connection"
|
||||
|
||||
def add_yt_headers(request)
|
||||
request.headers["x-youtube-client-name"] ||= "1"
|
||||
request.headers["x-youtube-client-version"] ||= "1.20180719"
|
||||
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36"
|
||||
return if request.resource.starts_with? "/sorry/index"
|
||||
|
||||
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
|
||||
request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
|
||||
request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
request.headers["accept-language"] ||= "en-us,en;q=0.5"
|
||||
request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
|
||||
end
|
||||
|
||||
struct HTTPPool
|
||||
property! url : URI
|
||||
property! capacity : Int32
|
||||
property! timeout : Float64
|
||||
property pool : ConnectionPool(HTTPClient)
|
||||
|
||||
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
|
||||
@url = url
|
||||
@pool = build_pool
|
||||
end
|
||||
|
||||
def client(region = nil, &block)
|
||||
conn = pool.checkout
|
||||
|
||||
begin
|
||||
if region
|
||||
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||
begin
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
conn.set_proxy(proxy)
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
response = yield conn
|
||||
|
||||
if region
|
||||
conn.unset_proxy
|
||||
end
|
||||
|
||||
response
|
||||
rescue ex
|
||||
conn = HTTPClient.new(url)
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
conn.family = (url.host == "www.youtube.com" || url.host == "suggestqueries.google.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
|
||||
conn.read_timeout = 10.seconds
|
||||
conn.connect_timeout = 10.seconds
|
||||
yield conn
|
||||
ensure
|
||||
pool.checkin(conn)
|
||||
end
|
||||
end
|
||||
|
||||
private def build_pool
|
||||
ConnectionPool(HTTPClient).new(capacity: capacity, timeout: timeout) do
|
||||
client = HTTPClient.new(url)
|
||||
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
client.family = (url.host == "www.youtube.com" || url.host == "suggestqueries.google.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
client
|
||||
end
|
||||
request.headers["x-youtube-client-name"] ||= "1"
|
||||
request.headers["x-youtube-client-version"] ||= "1.20180719"
|
||||
if !CONFIG.cookies.empty?
|
||||
request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,38 +19,43 @@ struct QUICPool
|
||||
property! url : URI
|
||||
property! capacity : Int32
|
||||
property! timeout : Float64
|
||||
property pool : ConnectionPool(QUIC::Client)
|
||||
|
||||
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
|
||||
@url = url
|
||||
@pool = build_pool
|
||||
end
|
||||
|
||||
def client(region = nil, &block)
|
||||
begin
|
||||
if region
|
||||
client = HTTPClient.new(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
|
||||
|
||||
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||
begin
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
yield client
|
||||
else
|
||||
if region
|
||||
conn = make_client(url, region)
|
||||
response = yield conn
|
||||
else
|
||||
conn = pool.checkout
|
||||
begin
|
||||
response = yield conn
|
||||
rescue ex
|
||||
conn.close
|
||||
conn = QUIC::Client.new(url)
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
yield conn
|
||||
response = yield conn
|
||||
ensure
|
||||
pool.checkin(conn)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private def build_pool
|
||||
ConnectionPool(QUIC::Client).new(capacity: capacity, timeout: timeout) do
|
||||
conn = QUIC::Client.new(url)
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
yield conn
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -129,7 +81,8 @@ def elapsed_text(elapsed)
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil)
|
||||
client = HTTPClient.new(url)
|
||||
# TODO: Migrate any applicable endpoints to QUIC
|
||||
client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
|
||||
client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
@@ -151,7 +104,7 @@ end
|
||||
def decode_length_seconds(string)
|
||||
length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i
|
||||
length_seconds = [0] * (3 - length_seconds.size) + length_seconds
|
||||
length_seconds = Time::Span.new(length_seconds[0], length_seconds[1], length_seconds[2])
|
||||
length_seconds = Time::Span.new hours: length_seconds[0], minutes: length_seconds[1], seconds: length_seconds[2]
|
||||
length_seconds = length_seconds.total_seconds.to_i
|
||||
|
||||
return length_seconds
|
||||
@@ -213,6 +166,7 @@ def decode_date(string : String)
|
||||
return Time.utc
|
||||
when "yesterday"
|
||||
return Time.utc - 1.day
|
||||
else nil # Continue
|
||||
end
|
||||
|
||||
# String matches format "20 hours ago", "4 months ago"...
|
||||
@@ -367,7 +321,7 @@ def get_referer(env, fallback = "/", unroll = true)
|
||||
end
|
||||
|
||||
referer = referer.full_path
|
||||
referer = "/" + referer.lstrip("\/\\")
|
||||
referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\")
|
||||
|
||||
if referer == env.request.path
|
||||
referer = fallback
|
||||
@@ -376,50 +330,13 @@ def get_referer(env, fallback = "/", unroll = true)
|
||||
return referer
|
||||
end
|
||||
|
||||
struct VarInt
|
||||
def self.from_io(io : IO, format = IO::ByteFormat::NetworkEndian) : Int32
|
||||
result = 0_u32
|
||||
num_read = 0
|
||||
|
||||
loop do
|
||||
byte = io.read_byte
|
||||
raise "Invalid VarInt" if !byte
|
||||
value = byte & 0x7f
|
||||
|
||||
result |= value.to_u32 << (7 * num_read)
|
||||
num_read += 1
|
||||
|
||||
break if byte & 0x80 == 0
|
||||
raise "Invalid VarInt" if num_read > 5
|
||||
end
|
||||
|
||||
result.to_i32
|
||||
end
|
||||
|
||||
def self.to_io(io : IO, value : Int32)
|
||||
io.write_byte 0x00 if value == 0x00
|
||||
value = value.to_u32
|
||||
|
||||
while value != 0
|
||||
byte = (value & 0x7f).to_u8
|
||||
value >>= 7
|
||||
|
||||
if value != 0
|
||||
byte |= 0x80
|
||||
end
|
||||
|
||||
io.write_byte byte
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sha256(text)
|
||||
digest = OpenSSL::Digest.new("SHA256")
|
||||
digest << text
|
||||
return digest.hexdigest
|
||||
end
|
||||
|
||||
def subscribe_pubsub(topic, key, config, client_pool)
|
||||
def subscribe_pubsub(topic, key, config)
|
||||
case topic
|
||||
when .match(/^UC[A-Za-z0-9_-]{22}$/)
|
||||
topic = "channel_id=#{topic}"
|
||||
@@ -446,7 +363,7 @@ def subscribe_pubsub(topic, key, config, client_pool)
|
||||
"hub.secret" => key.to_s,
|
||||
}
|
||||
|
||||
return client_pool.client &.post("/subscribe", form: body)
|
||||
return make_client(PUBSUB_URL).post("/subscribe", form: body)
|
||||
end
|
||||
|
||||
def parse_range(range)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -334,6 +335,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
|
||||
object["2:embedded"].as(Hash)["3:varint"] = 1_i64
|
||||
when "long"
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 || ""
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user