Merge branch 'master' into patch-1
commit
bb7d8735cb
@ -0,0 +1 @@
|
||||
https://hosted.weblate.org/projects/invidious/
|
@ -0,0 +1,10 @@
|
||||
#player {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
z-index: -100;
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
.video-js .vjs-vtt-thumbnail-display {
|
||||
max-width: 158px;
|
||||
}
|
@ -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;
|
@ -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);
|
||||
}
|
||||
})();
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN title CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN views CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN likes CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN published CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN description CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN language CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN ucid CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN license CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE"
|
||||
psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE"
|
@ -1,10 +0,0 @@
|
||||
-- Type: public.privacy
|
||||
|
||||
-- DROP TYPE public.privacy;
|
||||
|
||||
CREATE TYPE public.privacy AS ENUM
|
||||
(
|
||||
'Public',
|
||||
'Unlisted',
|
||||
'Private'
|
||||
);
|
@ -1,9 +0,0 @@
|
||||
FROM postgres:10
|
||||
|
||||
ENV POSTGRES_USER postgres
|
||||
|
||||
ADD ./config/sql /config/sql
|
||||
ADD ./docker/entrypoint.postgres.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
CMD [ "postgres" ]
|
@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CMD="$@"
|
||||
if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
|
||||
echo "### first run - setting up invidious database"
|
||||
/usr/local/bin/docker-entrypoint.sh postgres &
|
||||
sleep 10
|
||||
until runuser -l postgres -c 'pg_isready' 2>/dev/null; do
|
||||
>&2 echo "### Postgres is unavailable - waiting"
|
||||
sleep 5
|
||||
done
|
||||
>&2 echo "### importing table schemas"
|
||||
su postgres -c 'createdb invidious'
|
||||
su postgres -c 'psql -c "CREATE USER kemal WITH PASSWORD '"'kemal'"'"'
|
||||
su postgres -c 'psql invidious kemal < config/sql/channels.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/videos.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/channel_videos.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/users.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/session_ids.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/nonces.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/annotations.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/playlists.sql'
|
||||
su postgres -c 'psql invidious kemal < config/sql/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
|
||||
fi
|
||||
|
||||
echo "running postgres /usr/local/bin/docker-entrypoint.sh $CMD"
|
||||
exec /usr/local/bin/docker-entrypoint.sh $CMD
|
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -eou pipefail
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE USER postgres;
|
||||
EOSQL
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
|
@ -0,0 +1 @@
|
||||
/charts/*.tgz
|
@ -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"
|
@ -0,0 +1,22 @@
|
||||
apiVersion: v2
|
||||
name: invidious
|
||||
description: Invidious is an alternative front-end to YouTube
|
||||
version: 1.1.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
|
@ -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
|
||||
```
|
@ -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 -}}
|
@ -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 }}
|
@ -0,0 +1,61 @@
|
||||
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 }}
|
||||
initContainers:
|
||||
- name: wait-for-postgresql
|
||||
image: postgres
|
||||
args:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- until pg_isready -h {{ .Values.config.db.host }} -p {{ .Values.config.db.port }} -U {{ .Values.config.db.user }}; do echo waiting for database; sleep 2; done;
|
||||
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: /
|
||||
initialDelaySeconds: 15
|
||||
restartPolicy: Always
|
@ -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 }}
|
@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ template "invidious.fullname" . }}
|
||||
labels:
|
||||
app: {{ template "invidious.name" . }}
|
||||
chart: {{ .Chart.Name }}
|
||||
release: {{ .Release.Name }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: {{ template "invidious.name" . }}
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
|
||||
{{- end }}
|
@ -0,0 +1,56 @@
|
||||
name: invidious
|
||||
|
||||
image:
|
||||
repository: omarroth/invidious
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 16
|
||||
targetCPUUtilizationPercentage: 50
|
||||
|
||||
service:
|
||||
type: clusterIP
|
||||
port: 3000
|
||||
#loadBalancerIP:
|
||||
|
||||
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:
|
@ -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ó: "
|
||||
}
|
@ -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: "
|
||||
}
|
@ -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: "
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 536 KiB |
File diff suppressed because it is too large
Load Diff
@ -1,370 +0,0 @@
|
||||
def refresh_channels(db, logger, config)
|
||||
max_channel = Channel(Int32).new
|
||||
|
||||
spawn do
|
||||
max_threads = max_channel.receive
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
loop do
|
||||
db.query("SELECT id FROM channels ORDER BY updated") do |rs|
|
||||
rs.each do
|
||||
id = rs.read(String)
|
||||
|
||||
if active_threads >= max_threads
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
spawn do
|
||||
begin
|
||||
channel = fetch_channel(id, db, config.full_refresh)
|
||||
|
||||
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
|
||||
rescue ex
|
||||
if ex.message == "Deleted or invalid channel"
|
||||
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
|
||||
end
|
||||
logger.puts("#{id} : #{ex.message}")
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
max_channel.send(config.channel_threads)
|
||||
end
|
||||
|
||||
def refresh_feeds(db, logger, config)
|
||||
max_channel = Channel(Int32).new
|
||||
spawn do
|
||||
max_threads = max_channel.receive
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
loop do
|
||||
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
|
||||
rs.each do
|
||||
email = rs.read(String)
|
||||
view_name = "subscriptions_#{sha256(email)}"
|
||||
|
||||
if active_threads >= max_threads
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
spawn do
|
||||
begin
|
||||
# Drop outdated views
|
||||
column_array = get_column_array(db, view_name)
|
||||
ChannelVideo.to_type_tuple.each_with_index do |name, i|
|
||||
if name != column_array[i]?
|
||||
logger.puts("DROP MATERIALIZED VIEW #{view_name}")
|
||||
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||
raise "view does not exist"
|
||||
end
|
||||
end
|
||||
|
||||
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
|
||||
logger.puts("Materialized view #{view_name} is out-of-date, recreating...")
|
||||
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||
end
|
||||
|
||||
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
rescue ex
|
||||
# Rename old views
|
||||
begin
|
||||
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
|
||||
|
||||
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
|
||||
logger.puts("RENAME MATERIALIZED VIEW #{legacy_view_name}")
|
||||
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
|
||||
rescue ex
|
||||
begin
|
||||
# While iterating through, we may have an email stored from a deleted account
|
||||
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
||||
logger.puts("CREATE #{view_name}")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
|
||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("REFRESH #{email} : #{ex.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 5.seconds
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
max_channel.send(config.feed_threads)
|
||||
end
|
||||
|
||||
def subscribe_to_feeds(db, logger, key, config)
|
||||
if config.use_pubsub_feeds
|
||||
case config.use_pubsub_feeds
|
||||
when Bool
|
||||
max_threads = config.use_pubsub_feeds.as(Bool).to_unsafe
|
||||
when Int32
|
||||
max_threads = config.use_pubsub_feeds.as(Int32)
|
||||
end
|
||||
max_channel = Channel(Int32).new
|
||||
|
||||
spawn do
|
||||
max_threads = max_channel.receive
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
loop do
|
||||
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs|
|
||||
rs.each do
|
||||
ucid = rs.read(String)
|
||||
|
||||
if active_threads >= max_threads.as(Int32)
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
|
||||
spawn do
|
||||
begin
|
||||
response = subscribe_pubsub(ucid, key, config)
|
||||
|
||||
if response.status_code >= 400
|
||||
logger.puts("#{ucid} : #{response.body}")
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("#{ucid} : #{ex.message}")
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
max_channel.send(max_threads.as(Int32))
|
||||
end
|
||||
end
|
||||
|
||||
def pull_top_videos(config, db)
|
||||
loop do
|
||||
begin
|
||||
top = rank_videos(db, 40)
|
||||
rescue ex
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
if top.size == 0
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
videos = [] of Video
|
||||
|
||||
top.each do |id|
|
||||
begin
|
||||
videos << get_video(id, db)
|
||||
rescue ex
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
yield videos
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
def pull_popular_videos(db)
|
||||
loop do
|
||||
videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE ucid IN \
|
||||
(SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) \
|
||||
ORDER BY ucid, published DESC", as: ChannelVideo).sort_by { |video| video.published }.reverse
|
||||
|
||||
yield videos
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
def update_decrypt_function
|
||||
loop do
|
||||
begin
|
||||
decrypt_function = fetch_decrypt_function
|
||||
yield decrypt_function
|
||||
rescue ex
|
||||
next
|
||||
ensure
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
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"]
|
||||
|
||||
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
|
||||
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) }
|
||||
client = QUIC::Client.new(location.host.not_nil!)
|
||||
response = client.get(location.full_path)
|
||||
|
||||
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
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: {
|
||||
"clientKey" => CONFIG.captcha_key,
|
||||
"task" => {
|
||||
"type" => "NoCaptchaTaskProxyless",
|
||||
"websiteURL" => location.to_s,
|
||||
"websiteKey" => site_key,
|
||||
},
|
||||
}.to_json).body)
|
||||
|
||||
if response["error"]?
|
||||
raise response["error"].as_s
|
||||
end
|
||||
|
||||
task_id = response["taskId"].as_i
|
||||
|
||||
loop do
|
||||
sleep 10.seconds
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: {
|
||||
"clientKey" => CONFIG.captcha_key,
|
||||
"taskId" => task_id,
|
||||
}.to_json).body)
|
||||
|
||||
if response["status"]?.try &.== "ready"
|
||||
break
|
||||
elsif response["errorId"]?.try &.as_i != 0
|
||||
raise response["errorDescription"].as_s
|
||||
end
|
||||
end
|
||||
|
||||
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
|
||||
client.close
|
||||
client = QUIC::Client.new("www.google.com")
|
||||
response = client.post(location.full_path, 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
|
||||
rescue ex
|
||||
logger.puts("Exception: #{ex.message}")
|
||||
ensure
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_working_proxies(regions)
|
||||
loop do
|
||||
regions.each do |region|
|
||||
proxies = get_proxies(region).first(20)
|
||||
proxies = proxies.map { |proxy| {ip: proxy[:ip], port: proxy[:port]} }
|
||||
# proxies = filter_proxies(proxies)
|
||||
|
||||
yield region, proxies
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
@ -1,166 +0,0 @@
|
||||
# Overloads https://github.com/crystal-lang/crystal/blob/0.28.0/src/json/from_json.cr#L24
|
||||
def Object.from_json(string_or_io, default) : self
|
||||
parser = JSON::PullParser.new(string_or_io)
|
||||
new parser, default
|
||||
end
|
||||
|
||||
# Adds configurable 'default'
|
||||
macro patched_json_mapping(_properties_, strict = false)
|
||||
{% for key, value in _properties_ %}
|
||||
{% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}
|
||||
{% end %}
|
||||
|
||||
{% for key, value in _properties_ %}
|
||||
{% _properties_[key][:key_id] = key.id.gsub(/\?$/, "") %}
|
||||
{% end %}
|
||||
|
||||
{% for key, value in _properties_ %}
|
||||
@{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }}
|
||||
|
||||
{% if value[:setter] == nil ? true : value[:setter] %}
|
||||
def {{value[:key_id]}}=(_{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }})
|
||||
@{{value[:key_id]}} = _{{value[:key_id]}}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% if value[:getter] == nil ? true : value[:getter] %}
|
||||
def {{key.id}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }}
|
||||
@{{value[:key_id]}}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% if value[:presence] %}
|
||||
@{{value[:key_id]}}_present : Bool = false
|
||||
|
||||
def {{value[:key_id]}}_present?
|
||||
@{{value[:key_id]}}_present
|
||||
end
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
def initialize(%pull : ::JSON::PullParser, default = nil)
|
||||
{% for key, value in _properties_ %}
|
||||
%var{key.id} = nil
|
||||
%found{key.id} = false
|
||||
{% end %}
|
||||
|
||||
%location = %pull.location
|
||||
begin
|
||||
%pull.read_begin_object
|
||||
rescue exc : ::JSON::ParseException
|
||||
raise ::JSON::MappingError.new(exc.message, self.class.to_s, nil, *%location, exc)
|
||||
end
|
||||
until %pull.kind.end_object?
|
||||
%key_location = %pull.location
|
||||
key = %pull.read_object_key
|
||||
case key
|
||||
{% for key, value in _properties_ %}
|
||||
when {{value[:key] || value[:key_id].stringify}}
|
||||
%found{key.id} = true
|
||||
begin
|
||||
%var{key.id} =
|
||||
{% if value[:nilable] || value[:default] != nil %} %pull.read_null_or { {% end %}
|
||||
|
||||
{% if value[:root] %}
|
||||
%pull.on_key!({{value[:root]}}) do
|
||||
{% end %}
|
||||
|
||||
{% if value[:converter] %}
|
||||
{{value[:converter]}}.from_json(%pull)
|
||||
{% elsif value[:type].is_a?(Path) || value[:type].is_a?(Generic) %}
|
||||
{{value[:type]}}.new(%pull)
|
||||
{% else %}
|
||||
::Union({{value[:type]}}).new(%pull)
|
||||
{% end %}
|
||||
|
||||
{% if value[:root] %}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% if value[:nilable] || value[:default] != nil %} } {% end %}
|
||||
rescue exc : ::JSON::ParseException
|
||||
raise ::JSON::MappingError.new(exc.message, self.class.to_s, {{value[:key] || value[:key_id].stringify}}, *%key_location, exc)
|
||||
end
|
||||
{% end %}
|
||||
else
|
||||
{% if strict %}
|
||||
raise ::JSON::MappingError.new("Unknown JSON attribute: #{key}", self.class.to_s, nil, *%key_location, nil)
|
||||
{% else %}
|
||||
%pull.skip
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
%pull.read_next
|
||||
|
||||
{% for key, value in _properties_ %}
|
||||
{% unless value[:nilable] || value[:default] != nil %}
|
||||
if %var{key.id}.nil? && !%found{key.id} && !::Union({{value[:type]}}).nilable?
|
||||
raise ::JSON::MappingError.new("Missing JSON attribute: {{(value[:key] || value[:key_id]).id}}", self.class.to_s, nil, *%location, nil)
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% if value[:nilable] %}
|
||||
{% if value[:default] != nil %}
|
||||
@{{value[:key_id]}} = %found{key.id} ? %var{key.id} : (default.responds_to?(:{{value[:key_id]}}) ? default.{{value[:key_id]}} : {{value[:default]}})
|
||||
{% else %}
|
||||
@{{value[:key_id]}} = %var{key.id}
|
||||
{% end %}
|
||||
{% elsif value[:default] != nil %}
|
||||
@{{value[:key_id]}} = %var{key.id}.nil? ? (default.responds_to?(:{{value[:key_id]}}) ? default.{{value[:key_id]}} : {{value[:default]}}) : %var{key.id}
|
||||
{% else %}
|
||||
@{{value[:key_id]}} = (%var{key.id}).as({{value[:type]}})
|
||||
{% end %}
|
||||
|
||||
{% if value[:presence] %}
|
||||
@{{value[:key_id]}}_present = %found{key.id}
|
||||
{% end %}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def to_json(json : ::JSON::Builder)
|
||||
json.object do
|
||||
{% for key, value in _properties_ %}
|
||||
_{{value[:key_id]}} = @{{value[:key_id]}}
|
||||
|
||||
{% unless value[:emit_null] %}
|
||||
unless _{{value[:key_id]}}.nil?
|
||||
{% end %}
|
||||
|
||||
json.field({{value[:key] || value[:key_id].stringify}}) do
|
||||
{% if value[:root] %}
|
||||
{% if value[:emit_null] %}
|
||||
if _{{value[:key_id]}}.nil?
|
||||
nil.to_json(json)
|
||||
else
|
||||
{% end %}
|
||||
|
||||
json.object do
|
||||
json.field({{value[:root]}}) do
|
||||
{% end %}
|
||||
|
||||
{% if value[:converter] %}
|
||||
if _{{value[:key_id]}}
|
||||
{{ value[:converter] }}.to_json(_{{value[:key_id]}}, json)
|
||||
else
|
||||
nil.to_json(json)
|
||||
end
|
||||
{% else %}
|
||||
_{{value[:key_id]}}.to_json(json)
|
||||
{% end %}
|
||||
|
||||
{% if value[:root] %}
|
||||
{% if value[:emit_null] %}
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
{% unless value[:emit_null] %}
|
||||
end
|
||||
{% end %}
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
@ -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"]
|
||||
document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body
|
||||
url = document.match(/src="(?<url>\/s\/player\/[^\/]+\/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 ""
|
||||
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
|
||||
def decrypt_signature(fmt : Hash(String, JSON::Any))
|
||||
return "" if !fmt["s"]? || !fmt["sp"]?
|
||||
|
||||
signature = a.join("")
|
||||
return "&#{fmt["sp"]?}=#{signature}"
|
||||
sp = fmt["sp"].as_s
|
||||
sig = fmt["s"].as_s.split("")
|
||||
DECRYPT_FUNCTION.each do |proc, value|
|
||||
sig = proc.call(sig, value)
|
||||
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
|
||||
|
@ -0,0 +1,13 @@
|
||||
module Invidious::Jobs
|
||||
JOBS = [] of BaseJob
|
||||
|
||||
def self.register(job : BaseJob)
|
||||
JOBS << job
|
||||
end
|
||||
|
||||
def self.start_all
|
||||
JOBS.each do |job|
|
||||
spawn { job.begin }
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,3 @@
|
||||
abstract class Invidious::Jobs::BaseJob
|
||||
abstract def begin
|
||||
end
|
@ -0,0 +1,131 @@
|
||||
class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
|
||||
private getter logger : Invidious::LogHandler
|
||||
private getter config : Config
|
||||
|
||||
def initialize(@logger, @config)
|
||||
end
|
||||
|
||||
def begin
|
||||
loop do
|
||||
begin
|
||||
{"/watch?v=jNQXAC9IVRw&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UC4QobU6STFB0P71PMvOGN5A")}.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[@id="recaptcha"])).try &.["data-sitekey"]
|
||||
s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-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)
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: {
|
||||
"clientKey" => config.captcha_key,
|
||||
"task" => {
|
||||
"type" => "NoCaptchaTaskProxyless",
|
||||
"websiteURL" => "https://www.youtube.com#{path}",
|
||||
"websiteKey" => site_key,
|
||||
"recaptchaDataSValue" => s_value,
|
||||
},
|
||||
}.to_json).body)
|
||||
|
||||
raise response["error"].as_s if response["error"]?
|
||||
task_id = response["taskId"].as_i
|
||||
|
||||
loop do
|
||||
sleep 10.seconds
|
||||
|
||||
response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: {
|
||||
"clientKey" => config.captcha_key,
|
||||
"taskId" => task_id,
|
||||
}.to_json).body)
|
||||
|
||||
if response["status"]?.try &.== "ready"
|
||||
break
|
||||
elsif response["errorId"]?.try &.as_i != 0
|
||||
raise response["errorDescription"].as_s
|
||||
end
|
||||
end
|
||||
|
||||
inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s
|
||||
headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || ""
|
||||
response = YT_POOL.client &.post("/das_captcha", headers, form: inputs)
|
||||
|
||||
response.cookies
|
||||
.select { |cookie| cookie.name != "PREF" }
|
||||
.each { |cookie| config.cookies << cookie }
|
||||
|
||||
# Persist cookies between runs
|
||||
File.write("config/config.yml", config.to_yaml)
|
||||
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!}
|
||||
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[@id="recaptcha"])).try &.["data-sitekey"]
|
||||
s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"]
|
||||
|
||||
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
|
||||
response = JSON.parse(captcha_client.post("/createTask", body: {
|
||||
"clientKey" => config.captcha_key,
|
||||
"task" => {
|
||||
"type" => "NoCaptchaTaskProxyless",
|
||||
"websiteURL" => location.to_s,
|
||||
"websiteKey" => site_key,
|
||||
"recaptchaDataSValue" => s_value,
|
||||
},
|
||||
}.to_json).body)
|
||||
|
||||
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["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || ""
|
||||
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)
|
||||
|
||||
cookies.each { |cookie| config.cookies << cookie }
|
||||
|
||||
# Persist cookies between runs
|
||||
File.write("config/config.yml", config.to_yaml)
|
||||
end
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("Exception: #{ex.message}")
|
||||
ensure
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,24 @@
|
||||
class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob
|
||||
private getter connection_channel : Channel({Bool, Channel(PQ::Notification)})
|
||||
private getter pg_url : URI
|
||||
|
||||
def initialize(@connection_channel, @pg_url)
|
||||
end
|
||||
|
||||
def begin
|
||||
connections = [] of Channel(PQ::Notification)
|
||||
|
||||
PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) }
|
||||
|
||||
loop do
|
||||
action, connection = connection_channel.receive
|
||||
|
||||
case action
|
||||
when true
|
||||
connections << connection
|
||||
when false
|
||||
connections.delete(connection)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,27 @@
|
||||
class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob
|
||||
QUERY = <<-SQL
|
||||
SELECT DISTINCT ON (ucid) *
|
||||
FROM channel_videos
|
||||
WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40)
|
||||
ORDER BY ucid, published DESC
|
||||
SQL
|
||||
POPULAR_VIDEOS = Atomic.new([] of ChannelVideo)
|
||||
private getter db : DB::Database
|
||||
|
||||
def initialize(@db)
|
||||
end
|
||||
|
||||
def begin
|
||||
loop do
|
||||
videos = db.query_all(QUERY, as: ChannelVideo)
|
||||
.sort_by(&.published)
|
||||
.reverse
|
||||
|
||||
POPULAR_VIDEOS.set(videos)
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,59 @@
|
||||
class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
|
||||
private getter db : DB::Database
|
||||
private getter logger : Invidious::LogHandler
|
||||
private getter config : Config
|
||||
|
||||
def initialize(@db, @logger, @config)
|
||||
end
|
||||
|
||||
def begin
|
||||
max_threads = config.channel_threads
|
||||
lim_threads = max_threads
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
backoff = 1.seconds
|
||||
|
||||
loop do
|
||||
db.query("SELECT id FROM channels ORDER BY updated") do |rs|
|
||||
rs.each do
|
||||
id = rs.read(String)
|
||||
|
||||
if active_threads >= lim_threads
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
spawn do
|
||||
begin
|
||||
channel = fetch_channel(id, db, config.full_refresh)
|
||||
|
||||
lim_threads = max_threads
|
||||
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
|
||||
rescue ex
|
||||
logger.puts("#{id} : #{ex.message}")
|
||||
if ex.message == "Deleted or invalid channel"
|
||||
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
|
||||
else
|
||||
lim_threads = 1
|
||||
logger.puts("#{id} : backing off for #{backoff}s")
|
||||
sleep backoff
|
||||
if backoff < 1.days
|
||||
backoff += backoff
|
||||
else
|
||||
backoff = 1.days
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,77 @@
|
||||
class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
|
||||
private getter db : DB::Database
|
||||
private getter logger : Invidious::LogHandler
|
||||
private getter config : Config
|
||||
|
||||
def initialize(@db, @logger, @config)
|
||||
end
|
||||
|
||||
def begin
|
||||
max_threads = config.feed_threads
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
loop do
|
||||
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
|
||||
rs.each do
|
||||
email = rs.read(String)
|
||||
view_name = "subscriptions_#{sha256(email)}"
|
||||
|
||||
if active_threads >= max_threads
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
spawn do
|
||||
begin
|
||||
# Drop outdated views
|
||||
column_array = get_column_array(db, view_name)
|
||||
ChannelVideo.type_array.each_with_index do |name, i|
|
||||
if name != column_array[i]?
|
||||
logger.puts("DROP MATERIALIZED VIEW #{view_name}")
|
||||
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||
raise "view does not exist"
|
||||
end
|
||||
end
|
||||
|
||||
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
|
||||
logger.puts("Materialized view #{view_name} is out-of-date, recreating...")
|
||||
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||
end
|
||||
|
||||
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
rescue ex
|
||||
# Rename old views
|
||||
begin
|
||||
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
|
||||
|
||||
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
|
||||
logger.puts("RENAME MATERIALIZED VIEW #{legacy_view_name}")
|
||||
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
|
||||
rescue ex
|
||||
begin
|
||||
# While iterating through, we may have an email stored from a deleted account
|
||||
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
||||
logger.puts("CREATE #{view_name}")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
|
||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("REFRESH #{email} : #{ex.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 5.seconds
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,59 @@
|
||||
class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
|
||||
STATISTICS = {
|
||||
"version" => "2.0",
|
||||
"software" => {
|
||||
"name" => "invidious",
|
||||
"version" => "",
|
||||
"branch" => "",
|
||||
},
|
||||
"openRegistrations" => true,
|
||||
"usage" => {
|
||||
"users" => {
|
||||
"total" => 0_i64,
|
||||
"activeHalfyear" => 0_i64,
|
||||
"activeMonth" => 0_i64,
|
||||
},
|
||||
},
|
||||
"metadata" => {
|
||||
"updatedAt" => Time.utc.to_unix,
|
||||
"lastChannelRefreshedAt" => 0_i64,
|
||||
},
|
||||
}
|
||||
|
||||
private getter db : DB::Database
|
||||
private getter config : Config
|
||||
|
||||
def initialize(@db, @config, @software_config : Hash(String, String))
|
||||
end
|
||||
|
||||
def begin
|
||||
load_initial_stats
|
||||
|
||||
loop do
|
||||
refresh_stats
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
# should only be called once at the very beginning
|
||||
private def load_initial_stats
|
||||
STATISTICS["software"] = {
|
||||
"name" => @software_config["name"],
|
||||
"version" => @software_config["version"],
|
||||
"branch" => @software_config["branch"],
|
||||
}
|
||||
STATISTICS["openRegistration"] = config.registration_enabled
|
||||
end
|
||||
|
||||
private def refresh_stats
|
||||
users = STATISTICS.dig("usage", "users").as(Hash(String, Int64))
|
||||
users["total"] = db.query_one("SELECT count(*) FROM users", as: Int64)
|
||||
users["activeHalfyear"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64)
|
||||
users["activeMonth"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64)
|
||||
STATISTICS["metadata"] = {
|
||||
"updatedAt" => Time.utc.to_unix,
|
||||
"lastChannelRefreshedAt" => db.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64,
|
||||
}
|
||||
end
|
||||
end
|
@ -0,0 +1,52 @@
|
||||
class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob
|
||||
private getter db : DB::Database
|
||||
private getter logger : Invidious::LogHandler
|
||||
private getter hmac_key : String
|
||||
private getter config : Config
|
||||
|
||||
def initialize(@db, @logger, @config, @hmac_key)
|
||||
end
|
||||
|
||||
def begin
|
||||
max_threads = 1
|
||||
if config.use_pubsub_feeds.is_a?(Int32)
|
||||
max_threads = config.use_pubsub_feeds.as(Int32)
|
||||
end
|
||||
|
||||
active_threads = 0
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
loop do
|
||||
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs|
|
||||
rs.each do
|
||||
ucid = rs.read(String)
|
||||
|
||||
if active_threads >= max_threads.as(Int32)
|
||||
if active_channel.receive
|
||||
active_threads -= 1
|
||||
end
|
||||
end
|
||||
|
||||
active_threads += 1
|
||||
|
||||
spawn do
|
||||
begin
|
||||
response = subscribe_pubsub(ucid, hmac_key, config)
|
||||
|
||||
if response.status_code >= 400
|
||||
logger.puts("#{ucid} : #{response.body}")
|
||||
end
|
||||
rescue ex
|
||||
logger.puts("#{ucid} : #{ex.message}")
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,19 @@
|
||||
class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob
|
||||
DECRYPT_FUNCTION = [] of {SigProc, Int32}
|
||||
|
||||
def begin
|
||||
loop do
|
||||
begin
|
||||
decrypt_function = fetch_decrypt_function
|
||||
DECRYPT_FUNCTION.clear
|
||||
decrypt_function.each { |df| DECRYPT_FUNCTION << df }
|
||||
rescue ex
|
||||
# TODO: Log error
|
||||
next
|
||||
ensure
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
abstract class Invidious::Routes::BaseRoute
|
||||
private getter config : Config
|
||||
private getter logger : Invidious::LogHandler
|
||||
|
||||
def initialize(@config, @logger)
|
||||
end
|
||||
|
||||
abstract def handle(env)
|
||||
end
|
@ -0,0 +1,27 @@
|
||||
class Invidious::Routes::Embed::Index < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||
|
||||
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
|
||||
videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
env.response.status_code = 500
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
url = "/embed/#{videos[0].id}?#{env.params.query}"
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query}"
|
||||
end
|
||||
else
|
||||
url = "/"
|
||||
end
|
||||
|
||||
env.redirect url
|
||||
end
|
||||
end
|
@ -0,0 +1,174 @@
|
||||
class Invidious::Routes::Embed::Show < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||
id = env.params.url["id"]
|
||||
|
||||
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"]?
|
||||
.try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/)
|
||||
video_series = md[0].split(",")
|
||||
env.params.query.delete("playlist")
|
||||
end
|
||||
|
||||
preferences = env.get("preferences").as(Preferences)
|
||||
|
||||
if id.includes?("%20") || id.includes?("+") || env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
|
||||
id = env.params.url["id"].gsub("%20", "").delete("+")
|
||||
|
||||
url = "/embed/#{id}"
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query.to_s.gsub("%20", "").delete("+")}"
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
end
|
||||
|
||||
# YouTube embed supports `videoseries` with either `list=PLID`
|
||||
# or `playlist=VIDEO_ID,VIDEO_ID`
|
||||
case id
|
||||
when "videoseries"
|
||||
url = ""
|
||||
|
||||
if plid
|
||||
begin
|
||||
playlist = get_playlist(PG_DB, plid, locale: locale)
|
||||
offset = env.params.query["index"]?.try &.to_i? || 0
|
||||
videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
env.response.status_code = 500
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
url = "/embed/#{videos[0].id}"
|
||||
elsif video_series
|
||||
url = "/embed/#{video_series.shift}"
|
||||
env.params.query["playlist"] = video_series.join(",")
|
||||
else
|
||||
return env.redirect "/"
|
||||
end
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query}"
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
when "live_stream"
|
||||
response = YT_POOL.client &.get("/embed/live_stream?channel=#{env.params.query["channel"]? || ""}")
|
||||
video_id = response.body.match(/"video_id":"(?<video_id>[a-zA-Z0-9_-]{11})"/).try &.["video_id"]
|
||||
|
||||
env.params.query.delete_all("channel")
|
||||
|
||||
if !video_id || video_id == "live_stream"
|
||||
error_message = "Video is unavailable."
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
url = "/embed/#{video_id}"
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query}"
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
when id.size > 11
|
||||
url = "/embed/#{id[0, 11]}"
|
||||
|
||||
if env.params.query.size > 0
|
||||
url += "?#{env.params.query}"
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
else nil # Continue
|
||||
end
|
||||
|
||||
params = process_video_params(env.params.query, preferences)
|
||||
|
||||
user = env.get?("user").try &.as(User)
|
||||
if user
|
||||
subscriptions = user.subscriptions
|
||||
watched = user.watched
|
||||
notifications = user.notifications
|
||||
end
|
||||
subscriptions ||= [] of String
|
||||
|
||||
begin
|
||||
video = get_video(id, PG_DB, region: params.region)
|
||||
rescue ex : VideoRedirect
|
||||
return env.redirect env.request.resource.gsub(id, ex.video_id)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
env.response.status_code = 500
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
if preferences.annotations_subscribed &&
|
||||
subscriptions.includes?(video.ucid) &&
|
||||
(env.params.query["iv_load_policy"]? || "1") == "1"
|
||||
params.annotations = true
|
||||
end
|
||||
|
||||
# if watched && !watched.includes? id
|
||||
# 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
|
||||
PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email)
|
||||
env.get("user").as(User).notifications.delete(id)
|
||||
notifications.delete(id)
|
||||
end
|
||||
|
||||
fmt_stream = video.fmt_stream
|
||||
adaptive_fmts = video.adaptive_fmts
|
||||
|
||||
if params.local
|
||||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) }
|
||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) }
|
||||
end
|
||||
|
||||
video_streams = video.video_streams
|
||||
audio_streams = video.audio_streams
|
||||
|
||||
if audio_streams.empty? && !video.live_now
|
||||
if params.quality == "dash"
|
||||
env.params.query.delete_all("quality")
|
||||
return env.redirect "/embed/#{id}?#{env.params.query}"
|
||||
elsif params.listen
|
||||
env.params.query.delete_all("listen")
|
||||
env.params.query["listen"] = "0"
|
||||
return env.redirect "/embed/#{id}?#{env.params.query}"
|
||||
end
|
||||
end
|
||||
|
||||
captions = video.captions
|
||||
|
||||
preferred_captions = captions.select { |caption|
|
||||
params.preferred_captions.includes?(caption.name.simpleText) ||
|
||||
params.preferred_captions.includes?(caption.languageCode.split("-")[0])
|
||||
}
|
||||
preferred_captions.sort_by! { |caption|
|
||||
(params.preferred_captions.index(caption.name.simpleText) ||
|
||||
params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil!
|
||||
}
|
||||
captions = captions - preferred_captions
|
||||
|
||||
aspect_ratio = nil
|
||||
|
||||
thumbnail = "/vi/#{video.id}/maxres.jpg"
|
||||
|
||||
if params.raw
|
||||
url = fmt_stream[0]["url"].as_s
|
||||
|
||||
fmt_stream.each do |fmt|
|
||||
url = fmt["url"].as_s if fmt["quality"].as_s == params.quality
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
end
|
||||
|
||||
rendered "embed"
|
||||
end
|
||||
end
|
@ -0,0 +1,34 @@
|
||||
class Invidious::Routes::Home < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
preferences = env.get("preferences").as(Preferences)
|
||||
locale = LOCALES[preferences.locale]?
|
||||
user = env.get? "user"
|
||||
|
||||
case preferences.default_home
|
||||
when ""
|
||||
templated "empty"
|
||||
when "Popular"
|
||||
templated "popular"
|
||||
when "Trending"
|
||||
env.redirect "/feed/trending"
|
||||
when "Subscriptions"
|
||||
if user
|
||||
env.redirect "/feed/subscriptions"
|
||||
else
|
||||
templated "popular"
|
||||
end
|
||||
when "Playlists"
|
||||
if user
|
||||
env.redirect "/view_all_playlists"
|
||||
else
|
||||
templated "popular"
|
||||
end
|
||||
else
|
||||
templated "empty"
|
||||
end
|
||||
end
|
||||
|
||||
private def popular_videos
|
||||
Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
class Invidious::Routes::Licenses < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||
rendered "licenses"
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
class Invidious::Routes::Privacy < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||
templated "privacy"
|
||||
end
|
||||
end
|
@ -0,0 +1,186 @@
|
||||
class Invidious::Routes::Watch < Invidious::Routes::BaseRoute
|
||||
def handle(env)
|
||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||
region = env.params.query["region"]?
|
||||
|
||||
if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
|
||||
url = "/watch?" + env.params.query.to_s.gsub("%20", "").delete("+")
|
||||
return env.redirect url
|
||||
end
|
||||
|
||||
if env.params.query["v"]?
|
||||
id = env.params.query["v"]
|
||||
|
||||
if env.params.query["v"].empty?
|
||||
error_message = "Invalid parameters."
|
||||
env.response.status_code = 400
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
if id.size > 11
|
||||
url = "/watch?v=#{id[0, 11]}"
|
||||
env.params.query.delete_all("v")
|
||||
if env.params.query.size > 0
|
||||
url += "&#{env.params.query}"
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
end
|
||||
else
|
||||
return env.redirect "/"
|
||||
end
|
||||
|
||||
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"]?
|
||||
|
||||
nojs ||= "0"
|
||||
nojs = nojs == "1"
|
||||
|
||||
preferences = env.get("preferences").as(Preferences)
|
||||
|
||||
user = env.get?("user").try &.as(User)
|
||||
if user
|
||||
subscriptions = user.subscriptions
|
||||
watched = user.watched
|
||||
notifications = user.notifications
|
||||
end
|
||||
subscriptions ||= [] of String
|
||||
|
||||
params = process_video_params(env.params.query, preferences)
|
||||
env.params.query.delete_all("listen")
|
||||
|
||||
begin
|
||||
video = get_video(id, PG_DB, region: params.region)
|
||||
rescue ex : VideoRedirect
|
||||
return env.redirect env.request.resource.gsub(id, ex.video_id)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
env.response.status_code = 500
|
||||
logger.puts("#{id} : #{ex.message}")
|
||||
return templated "error"
|
||||
end
|
||||
|
||||
if preferences.annotations_subscribed &&
|
||||
subscriptions.includes?(video.ucid) &&
|
||||
(env.params.query["iv_load_policy"]? || "1") == "1"
|
||||
params.annotations = true
|
||||
end
|
||||
env.params.query.delete_all("iv_load_policy")
|
||||
|
||||
if watched && !watched.includes? id
|
||||
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
|
||||
PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email)
|
||||
env.get("user").as(User).notifications.delete(id)
|
||||
notifications.delete(id)
|
||||
end
|
||||
|
||||
if nojs
|
||||
if preferences
|
||||
source = preferences.comments[0]
|
||||
if source.empty?
|
||||
source = preferences.comments[1]
|
||||
end
|
||||
|
||||
if source == "youtube"
|
||||
begin
|
||||
comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
|
||||
rescue ex
|
||||
if preferences.comments[1] == "reddit"
|
||||
comments, reddit_thread = fetch_reddit_comments(id)
|
||||
comment_html = template_reddit_comments(comments, locale)
|
||||
|
||||
comment_html = fill_links(comment_html, "https", "www.reddit.com")
|
||||
comment_html = replace_links(comment_html)
|
||||
end
|
||||
end
|
||||
elsif source == "reddit"
|
||||
begin
|
||||
comments, reddit_thread = fetch_reddit_comments(id)
|
||||
comment_html = template_reddit_comments(comments, locale)
|
||||
|
||||
comment_html = fill_links(comment_html, "https", "www.reddit.com")
|
||||
comment_html = replace_links(comment_html)
|
||||
rescue ex
|
||||
if preferences.comments[1] == "youtube"
|
||||
comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"]
|
||||
end
|
||||
|
||||
comment_html ||= ""
|
||||
end
|
||||
|
||||
fmt_stream = video.fmt_stream
|
||||
adaptive_fmts = video.adaptive_fmts
|
||||
|
||||
if params.local
|
||||
fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) }
|
||||
adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).full_path) }
|
||||
end
|
||||
|
||||
video_streams = video.video_streams
|
||||
audio_streams = video.audio_streams
|
||||
|
||||
# Older videos may not have audio sources available.
|
||||
# We redirect here so they're not unplayable
|
||||
if audio_streams.empty? && !video.live_now
|
||||
if params.quality == "dash"
|
||||
env.params.query.delete_all("quality")
|
||||
env.params.query["quality"] = "medium"
|
||||
return env.redirect "/watch?#{env.params.query}"
|
||||
elsif params.listen
|
||||
env.params.query.delete_all("listen")
|
||||
env.params.query["listen"] = "0"
|
||||
return env.redirect "/watch?#{env.params.query}"
|
||||
end
|
||||
end
|
||||
|
||||
captions = video.captions
|
||||
|
||||
preferred_captions = captions.select { |caption|
|
||||
params.preferred_captions.includes?(caption.name.simpleText) ||
|
||||
params.preferred_captions.includes?(caption.languageCode.split("-")[0])
|
||||
}
|
||||
preferred_captions.sort_by! { |caption|
|
||||
(params.preferred_captions.index(caption.name.simpleText) ||
|
||||
params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil!
|
||||
}
|
||||
captions = captions - preferred_captions
|
||||
|
||||
aspect_ratio = "16:9"
|
||||
|
||||
thumbnail = "/vi/#{video.id}/maxres.jpg"
|
||||
|
||||
if params.raw
|
||||
if params.listen
|
||||
url = audio_streams[0]["url"].as_s
|
||||
|
||||
audio_streams.each do |fmt|
|
||||
if fmt["bitrate"].as_i == params.quality.rchop("k").to_i
|
||||
url = fmt["url"].as_s
|
||||
end
|
||||
end
|
||||
else
|
||||
url = fmt_stream[0]["url"].as_s
|
||||
|
||||
fmt_stream.each do |fmt|
|
||||
if fmt["quality"].as_s == params.quality
|
||||
url = fmt["url"].as_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return env.redirect url
|
||||
end
|
||||
|
||||
templated "watch"
|
||||
end
|
||||
end
|
@ -0,0 +1,8 @@
|
||||
module Invidious::Routing
|
||||
macro get(path, controller)
|
||||
get {{ path }} do |env|
|
||||
controller_instance = {{ controller }}.new(config, logger)
|
||||
controller_instance.handle(env)
|
||||
end
|
||||
end
|
||||
end
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue