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("")
|
||||
def decrypt_signature(fmt : Hash(String, JSON::Any))
|
||||
return "" if !fmt["s"]? || !fmt["sp"]?
|
||||
|
||||
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
|
||||
sp = fmt["sp"].as_s
|
||||
sig = fmt["s"].as_s.split("")
|
||||
DECRYPT_FUNCTION.each do |proc, value|
|
||||
sig = proc.call(sig, value)
|
||||
end
|
||||
|
||||
signature = a.join("")
|
||||
return "&#{fmt["sp"]?}=#{signature}"
|
||||
end
|
||||
|
||||
def splice(a, b)
|
||||
c = a[0]
|
||||
a[0] = a[b % a.size]
|
||||
a[b % a.size] = c
|
||||
return a
|
||||
return "&#{sp}=#{sig.join("")}"
|
||||
end
|
||||
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue