diff --git a/README.md b/README.md
index aeafd261..689252c9 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,8 @@ Onion links:
## Installation
+See [Invidious-Updater](https://github.com/tmiland/Invidious-Updater) for a self-contained script that can automatically install and update Invidious.
+
### Docker:
#### Build and start cluster:
@@ -105,6 +107,7 @@ $ psql invidious < /home/invidious/invidious/config/sql/channels.sql
$ psql invidious < /home/invidious/invidious/config/sql/videos.sql
$ psql invidious < /home/invidious/invidious/config/sql/channel_videos.sql
$ psql invidious < /home/invidious/invidious/config/sql/users.sql
+$ psql invidious < /home/invidious/invidious/config/sql/session_ids.sql
$ psql invidious < /home/invidious/invidious/config/sql/nonces.sql
$ exit
```
@@ -146,6 +149,7 @@ $ psql invidious < config/sql/channels.sql
$ psql invidious < config/sql/videos.sql
$ psql invidious < config/sql/channel_videos.sql
$ psql invidious < config/sql/users.sql
+$ psql invidious < config/sql/session_ids.sql
$ psql invidious < config/sql/nonces.sql
# Setup Invidious
@@ -155,7 +159,7 @@ $ crystal build src/invidious.cr --release
## Update Invidious
-You can find information about how to update in the wiki: [Updating](https://github.com/omarroth/invidious/wiki/Updating).
+You can see how to update Invidious [here](https://github.com/omarroth/invidious/wiki/Updating).
## Usage:
@@ -192,13 +196,14 @@ $ ./sentry
## Extensions
-Extensions for Invidious and for integrating Invidious into other projects [are in the wiki](https://github.com/omarroth/invidious/wiki/Extensions)
+[Extensions](https://github.com/omarroth/invidious/wiki/Extensions) can be found in the wiki, as well as documentation for integrating it into other projects.
## Made with Invidious
- [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy.
- [CloudTube](https://github.com/cloudrac3r/cadencegq): Website featuring pastebin, image host, and YouTube player
- [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists.
+- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube.
## Contributing
diff --git a/config/migrate-scripts/migrate-db-3646395.sh b/config/migrate-scripts/migrate-db-3646395.sh
new file mode 100755
index 00000000..38a4f665
--- /dev/null
+++ b/config/migrate-scripts/migrate-db-3646395.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+psql invidious < config/sql/session_ids.sql
+psql invidious -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING"
+psql invidious -c "ALTER TABLE users DROP COLUMN id"
diff --git a/config/sql/channel_videos.sql b/config/sql/channel_videos.sql
index 98567780..279fad29 100644
--- a/config/sql/channel_videos.sql
+++ b/config/sql/channel_videos.sql
@@ -31,6 +31,6 @@ CREATE INDEX channel_videos_published_idx
CREATE INDEX channel_videos_ucid_idx
ON public.channel_videos
- USING hash
+ USING btree
(ucid COLLATE pg_catalog."default");
diff --git a/config/sql/nonces.sql b/config/sql/nonces.sql
index d2ec5be6..7b8ce9f2 100644
--- a/config/sql/nonces.sql
+++ b/config/sql/nonces.sql
@@ -5,10 +5,18 @@
CREATE TABLE public.nonces
(
nonce text,
- expire timestamp with time zone
-)
-WITH (
- OIDS=FALSE
+ expire timestamp with time zone,
+ CONSTRAINT nonces_id_key UNIQUE (nonce)
);
-GRANT ALL ON TABLE public.nonces TO kemal;
\ No newline at end of file
+GRANT ALL ON TABLE public.nonces TO kemal;
+
+-- Index: public.nonces_nonce_idx
+
+-- DROP INDEX public.nonces_nonce_idx;
+
+CREATE INDEX nonces_nonce_idx
+ ON public.nonces
+ USING btree
+ (nonce COLLATE pg_catalog."default");
+
diff --git a/config/sql/session_ids.sql b/config/sql/session_ids.sql
new file mode 100644
index 00000000..afbabb67
--- /dev/null
+++ b/config/sql/session_ids.sql
@@ -0,0 +1,23 @@
+-- Table: public.session_ids
+
+-- DROP TABLE public.session_ids;
+
+CREATE TABLE public.session_ids
+(
+ id text NOT NULL,
+ email text,
+ issued timestamp with time zone,
+ CONSTRAINT session_ids_pkey PRIMARY KEY (id)
+);
+
+GRANT ALL ON TABLE public.session_ids TO kemal;
+
+-- Index: public.session_ids_id_idx
+
+-- DROP INDEX public.session_ids_id_idx;
+
+CREATE INDEX session_ids_id_idx
+ ON public.session_ids
+ USING btree
+ (id COLLATE pg_catalog."default");
+
diff --git a/config/sql/users.sql b/config/sql/users.sql
index f806271c..536508a4 100644
--- a/config/sql/users.sql
+++ b/config/sql/users.sql
@@ -4,7 +4,6 @@
CREATE TABLE public.users
(
- id text[] NOT NULL,
updated timestamp with time zone,
notifications text[],
subscriptions text[],
diff --git a/docker/entrypoint.postgres.sh b/docker/entrypoint.postgres.sh
index 9a258dd6..8f987201 100755
--- a/docker/entrypoint.postgres.sh
+++ b/docker/entrypoint.postgres.sh
@@ -16,6 +16,7 @@ if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
su postgres -c 'psql invidious < config/sql/videos.sql'
su postgres -c 'psql invidious < config/sql/channel_videos.sql'
su postgres -c 'psql invidious < config/sql/users.sql'
+ su postgres -c 'psql invidious < config/sql/session_ids.sql'
su postgres -c 'psql invidious < config/sql/nonces.sql'
touch /var/lib/postgresql/data/setupFinished
echo "### invidious database setup finished"
diff --git a/locales/fr.json b/locales/fr.json
index e4bb5111..fed48bd9 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1,152 +1,151 @@
{
- "`x` subscribers": "`x` souscripteurs",
+ "`x` subscribers": "`x` abonnés",
"`x` videos": "`x` vidéos",
- "LIVE": "LIVE",
- "Shared `x` ago": "Partagé il y a `x`",
+ "LIVE": "EN DIRECT",
+ "Shared `x` ago": "Partagé, il y a `x`",
"Unsubscribe": "Se désabonner",
"Subscribe": "S'abonner",
- "Login to subscribe to `x`": "Se connecter pour s'abonner à `x`",
+ "Login to subscribe to `x`": "Vous devez vous connecter pour s'abonner à `x`",
"View channel on YouTube": "Voir la chaîne sur YouTube",
- "newest": "récent",
- "oldest": "aînée",
- "popular": "appréciés",
- "Preview page": "Page de prévisualisation",
+ "newest": "Date d'ajout (la plus récente)",
+ "oldest": "Date d'ajout (la plus ancienne)",
+ "popular": "Les plus populaires",
"Next page": "Page suivante",
- "Clear watch history?": "L'histoire de la montre est claire?",
+ "Clear watch history?": "Êtes vous sûr de vouloir supprimer l'historique des vidéos regardées",
"Yes": "Oui",
- "No": "Aucun",
- "Import and Export Data": "Importation et exportation de données",
- "Import": "Importation",
- "Import Invidious data": "Importation de données invalides",
+ "No": "Non",
+ "Import and Export Data": "Importation et Exportation de Données",
+ "Import": "Importer",
+ "Import Invidious data": "Importer des données Invidious",
"Import YouTube subscriptions": "Importer des abonnements YouTube",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
"Export": "Exporter",
- "Export subscriptions as OPML": "Exporter les abonnements comme OPML",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements comme OPML (pour NewPipe & FreeTube)",
+ "Export subscriptions as OPML": "Exporter les abonnements en OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON",
- "Delete account?": "Supprimer un compte ?",
- "History": "Histoire",
+ "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
+ "History": "Historique",
"Previous page": "Page précédente",
- "An alternative front-end to YouTube": "Un frontal alternatif à YouTube",
- "JavaScript license information": "Informations sur la licence JavaScript",
- "source": "origine",
+ "An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
+ "JavaScript license information": "Informations sur les licences JavaScript",
+ "source": "source",
"Login": "Connexion",
"Login/Register": "Connexion/S'inscrire",
"Login to Google": "Se connecter à Google",
- "User ID:": "ID utilisateur:",
- "Password:": "Mot de passe:",
- "Time (h:mm:ss):": "Temps (h:mm:ss):",
- "Text CAPTCHA": "Texte CAPTCHA",
- "Image CAPTCHA": "Image CAPTCHA",
+ "User ID:": "Identifiant utilisateur :",
+ "Password:": "Mot de passe :",
+ "Time (h:mm:ss):": "Heure (h:mm:ss):",
+ "Text CAPTCHA": "CAPTCHA Texte",
+ "Image CAPTCHA": "CAPTCHA Image",
"Sign In": "S'identifier",
"Register": "S'inscrire",
- "Email:": "Courriel:",
- "Google verification code:": "Code de vérification Google:",
+ "Email:": "Email:",
+ "Google verification code:": "Code de vérification Google :",
"Preferences": "Préférences",
- "Player preferences": "Joueur préférences",
- "Always loop: ": "Toujours en boucle: ",
- "Autoplay: ": "Autoplay: ",
- "Autoplay next video: ": "Lecture automatique de la vidéo suivante: ",
- "Listen by default: ": "Écouter par défaut: ",
+ "Player preferences": "Préférences du Lecteur",
+ "Always loop: ": "Lire en boucle: ",
+ "Autoplay: ": "Lire Automatiquement: ",
+ "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
+ "Listen by default: ": "Audio Uniquement par défaut : ",
"Default speed: ": "Vitesse par défaut: ",
- "Preferred video quality: ": "Qualité vidéo préférée: ",
- "Player volume: ": "Volume de lecteur: ",
- "Default comments: ": "Commentaires par défaut: ",
- "Default captions: ": "Légendes par défaut: ",
- "Fallback captions: ": "Légendes de repli: ",
+ "Preferred video quality: ": "Qualité vidéo souhaitée : ",
+ "Player volume: ": "Volume du lecteur: ",
+ "Default comments: ": "Source des Commentaires : ",
+ "Default captions: ": "Sous-titres principal : ",
+ "Fallback captions: ": "Sous-titre secondaire : ",
"Show related videos? ": "Voir les vidéos liées à ce sujet? ",
- "Visual preferences": "Préférences visuelles",
- "Dark mode: ": "Mode sombre: ",
- "Thin mode: ": "Mode Thin: ",
- "Subscription preferences": "Préférences d'abonnement",
- "Redirect homepage to feed: ": "Rediriger la page d'accueil vers le flux: ",
- "Number of videos shown in feed: ": "Nombre de vidéos montrées dans le flux: ",
- "Sort videos by: ": "Trier les vidéos par: ",
+ "Visual preferences": "Préférences du site",
+ "Dark mode: ": "Mode Sombre: ",
+ "Thin mode: ": "Mode Simplifié: ",
+ "Subscription preferences": "Préférences de la page d'abonnements",
+ "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
+ "Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
+ "Sort videos by: ": "Trier les vidéos par : ",
"published": "publié",
- "published - reverse": "publié - reverse",
+ "published - reverse": "publié - inversé",
"alphabetically": "alphabétiquement",
- "alphabetically - reverse": "alphabétiquement - contraire",
- "channel name": "nom du canal",
- "channel name - reverse": "nom du canal - contraire",
- "Only show latest video from channel: ": "Afficher uniquement les dernières vidéos de la chaîne: ",
- "Only show latest unwatched video from channel: ": "Afficher uniquement les dernières vidéos non regardées de la chaîne: ",
- "Only show unwatched: ": "Afficher uniquement les images non surveillées: ",
- "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a): ",
- "Data preferences": "Préférences de données",
- "Clear watch history": "Historique clair de la montre",
- "Import/Export data": "Données d'importation/exportation",
+ "alphabetically - reverse": "alphabétiquement - inversé",
+ "channel name": "nom de la chaîne",
+ "channel name - reverse": "nom de la chaîne - inversé",
+ "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne : ",
+ "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne si elle n'a pas était regardée: ",
+ "Only show unwatched: ": "Afficher uniquement les vidéos regardées: ",
+ "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
+ "Data preferences": "Préférences liées aux données",
+ "Clear watch history": "Supprimer l'historique des vidéos regardées",
+ "Import/Export data": "Importation/exportation de ",
"Manage subscriptions": "Gérer les abonnements",
- "Watch history": "Historique des montres",
- "Delete account": "Supprimer un compte",
+ "Watch history": "Historique de visionnage",
+ "Delete account": "Supprimer votre compte",
"Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement",
"`x` subscriptions": "`x` abonnements",
"Import/Export": "Importer/Exporter",
"unsubscribe": "se désabonner",
"Subscriptions": "Abonnements",
- "`x` unseen notifications": "`x` notifications invisibles",
- "search": "perquisition",
+ "`x` unseen notifications": "`x` notifications non vues",
+ "search": "Rechercher",
"Sign out": "Déconnexion",
- "Released under the AGPLv3 by Omar Roth.": "Publié sous l'AGPLv3 par Omar Roth.",
- "Source available here.": "Source disponible ici.",
- "View JavaScript license information.": "Voir les informations de licence JavaScript.",
+ "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
+ "Source available here.": "Code Source",
+ "View JavaScript license information.": "Voir les informations des licences JavaScript.",
"Trending": "Tendances",
"Watch video on Youtube": "Voir la vidéo sur Youtube",
"Genre: ": "Genre: ",
"License: ": "Licence: ",
- "Family friendly? ": "Convivialité familiale? ",
- "Wilson score: ": "Wilson marque: ",
- "Engagement: ": "Fiançailles: ",
+ "Family friendly? ": "Tout Public? ",
+ "Wilson score: ": "Score de Wilson: ",
+ "Engagement: ": "Poucentage de spectateur aillant aimé Liker ou Disliker la vidéo : ",
"Whitelisted regions: ": "Régions en liste blanche: ",
"Blacklisted regions: ": "Régions sur liste noire: ",
"Shared `x`": "Partagée `x`",
- "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Hi! On dirait que vous avez désactivé JavaScript. Cliquez ici pour voir les commentaires, gardez à l'esprit que le chargement peut prendre un peu plus de temps.",
- "View YouTube comments": "Voir les commentaires sur YouTube",
+ "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Il semblerait que JavaScript sois désactivé. Cliquez ici pour voir les commentaires, gardez à l'esprit que le chargement peut prendre plus de temps.",
+ "View YouTube comments": "Voir les commentaires YouTube",
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
"View `x` comments": "Voir `x` commentaires",
- "View Reddit comments": "Voir Reddit commentaires",
+ "View Reddit comments": "Voir les commentaires Reddit",
"Hide replies": "Masquer les réponses",
"Show replies": "Afficher les réponses",
"Incorrect password": "Mot de passe incorrect",
- "Quota exceeded, try again in a few hours": "Quota dépassé, réessayez dans quelques heures",
+ "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassé, réessayez dans quelques heures",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.",
- "Invalid TFA code": "Code TFA invalide",
+ "Invalid TFA code": "Code d'authentification à deux facteurs invalide",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.",
"Invalid answer": "Réponse non valide",
"Invalid CAPTCHA": "CAPTCHA invalide",
- "CAPTCHA is a required field": "CAPTCHA est un champ obligatoire",
- "User ID is a required field": "Utilisateur ID est un champ obligatoire",
- "Password is a required field": "Mot de passe est un champ obligatoire",
+ "CAPTCHA is a required field": "Veuillez rentrez un CAPTCHA",
+ "User ID is a required field": "Veuillez rentrez un Identifiant Utilisateur",
+ "Password is a required field": "Veuillez rentrez un Mot de passe",
"Invalid username or password": "Nom d'utilisateur ou mot de passe invalide",
- "Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant 'S'identifier avec Google'",
+ "Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant \"S'identifier avec Google\"",
"Password cannot be empty": "Le mot de passe ne peut pas être vide",
"Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères.",
- "Please sign in": "Veuillez ouvrir une session",
- "Invidious Private Feed for `x`": "Flux privé Invidious pour `x`",
- "channel:`x`": "chenal:`x`",
- "Deleted or invalid channel": "Canal supprimé ou non valide",
- "This channel does not exist.": "Ce canal n'existe pas.",
- "Could not get channel info.": "Impossible d'obtenir des informations sur les chaînes.",
- "Could not fetch comments": "Impossible d'aller chercher les commentaires",
+ "Please sign in": "Veuillez vous connecter",
+ "Invidious Private Feed for `x`": "Flux RSS privé pour `x`",
+ "channel:`x`": "chaîne:`x`",
+ "Deleted or invalid channel": "Chaîne supprimée ou invalide",
+ "This channel does not exist.": "Cette chaine n'existe pas.",
+ "Could not get channel info.": "Impossible de charger les informations de cette chaîne.",
+ "Could not fetch comments": "Impossible de charger les commentaires",
"View `x` replies": "Voir `x` réponses",
"`x` ago": "il y a `x`",
"Load more": "Charger plus",
"`x` points": "`x` points",
- "Could not create mix.": "Impossible de créer du mixage.",
+ "Could not create mix.": "Impossible de charger cette liste de lecture.",
"Playlist is empty": "La liste de lecture est vide",
"Invalid playlist.": "Liste de lecture invalide.",
"Playlist does not exist.": "La liste de lecture n'existe pas.",
- "Could not pull trending pages.": "Impossible de tirer les pages de tendances.",
- "Hidden field \"challenge\" is a required field": "Champ caché \"contestation\" est un champ obligatoire",
- "Hidden field \"token\" is a required field": "Champ caché \"jeton\" est un champ obligatoire",
- "Invalid challenge": "Contestation non valide",
- "Invalid token": "Jeton non valide",
- "Invalid user": "Iutilisateur non valide",
- "Token is expired, please try again": "Le jeton est expiré, veuillez réessayer",
+ "Could not pull trending pages.": "Impossible de charger les pages de tendances.",
+ "Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field",
+ "Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field",
+ "Invalid challenge": "Invalid challenge",
+ "Invalid token": "Invalid token",
+ "Invalid user": "Invalid user",
+ "Token is expired, please try again": "Token is expired, please try again",
"English": "Anglais",
- "English (auto-generated)": "Anglais (auto-généré)",
+ "English (auto-generated)": "Anglais (générés automatiquement)",
"Afrikaans": "Afrikaans",
"Albanian": "Albanais",
"Amharic": "Amharique",
@@ -258,21 +257,21 @@
"`x` hours": "`x` heures",
"`x` minutes": "`x` minutes",
"`x` seconds": "`x` secondes",
- "Fallback comments: ": "Commentaires de repli: ",
+ "Fallback comments: ": "Commentaires secondaires : ",
"Popular": "Populaire",
- "Top": "Haut",
- "About": "Sur",
+ "Top": "Top",
+ "About": "A Propos",
"Rating: ": "Évaluation: ",
"Language: ": "Langue: ",
- "Default": "",
- "Music": "",
- "Gaming": "",
- "News": "",
- "Movies": "",
- "Download": "",
- "Download as: ": "",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "Youtube permalink of the comment": "",
- "`x` marked it with a ❤": ""
+ "Default": "Défaut",
+ "Music": "Musique",
+ "Gaming": "Jeux Vidéo",
+ "News": "Actualités",
+ "Movies": "Films",
+ "Download": "Télécharger",
+ "Download as: ": "Télécharger en :",
+ "%A %B %-d, %Y": "%A %-d %B %Y",
+ "(edited)": "(modifié)",
+ "Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
+ "`x` marked it with a ❤": "`x` l'a marqué d'un ❤"
}
diff --git a/locales/ru.json b/locales/ru.json
index ec62cadb..9c64fb48 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1,284 +1,284 @@
{
- "`x` subscribers": "`x` подписчиков",
- "`x` videos": "`x` видео",
- "LIVE": "ПРЯМОЙ ЭФИР",
- "Shared `x` ago": "Опубликовано `x` назад",
- "Unsubscribe": "Отписаться",
- "Subscribe": "Подписаться",
- "Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
- "View channel on YouTube": "Канал на YouTube",
- "newest": "новые",
- "oldest": "старые",
- "popular": "популярные",
- "Preview page": "Предварительный просмотр",
- "Next page": "Следующая страница",
- "Clear watch history?": "Очистить историю просмотров?",
- "Yes": "Да",
- "No": "Нет",
- "Import and Export Data": "Импорт и экспорт данных",
- "Import": "Импорт",
- "Import Invidious data": "Импортировать данные Invidious",
- "Import YouTube subscriptions": "Импортировать YouTube подписки",
- "Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
- "Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
- "Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
- "Export": "Экспорт",
- "Export subscriptions as OPML": "Экспортировать подписки в OPML",
- "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
- "Export data as JSON": "Экспортировать данные в JSON",
- "Delete account?": "Удалить аккаунт?",
- "History": "История",
- "Previous page": "Предыдущая страница",
- "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
- "JavaScript license information": "Лицензии JavaScript",
- "source": "источник",
- "Login": "Войти",
- "Login/Register": "Войти/Регистрация",
- "Login to Google": "Войти через Google",
- "User ID:": "ID пользователя:",
- "Password:": "Пароль:",
- "Time (h:mm:ss):": "Время (ч:мм:сс):",
- "Text CAPTCHA": "Текст капчи",
- "Image CAPTCHA": "Изображение капчи",
- "Sign In": "Войти",
- "Register": "Регистрация",
- "Email:": "Эл. почта:",
- "Google verification code:": "Код подтверждения Google:",
- "Preferences": "Настройки",
- "Player preferences": "Настройки проигрывателя",
- "Always loop: ": "Всегда повторять: ",
- "Autoplay: ": "Автовоспроизведение: ",
- "Autoplay next video: ": "Автовоспроизведение следующего видео: ",
- "Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
- "Default speed: ": "Скорость по-умолчанию: ",
- "Preferred video quality: ": "Предпочтительное качество видео: ",
- "Player volume: ": "Громкость воспроизведения: ",
- "Default comments: ": "Источник комментариев: ",
- "youtube": "YouTube",
- "reddit": "Reddit",
- "Default captions: ": "Субтитры по-умолчанию: ",
- "Fallback captions: ": "Резервные субтитры: ",
- "Show related videos? ": "Показывать похожие видео? ",
- "Visual preferences": "Визуальные настройки",
- "Dark mode: ": "Темная тема: ",
- "Thin mode: ": "Облегченный режим: ",
- "Subscription preferences": "Настройки подписок",
- "Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
- "Number of videos shown in feed: ": "Число видео в ленте: ",
- "Sort videos by: ": "Сортировать видео по: ",
- "published": "дате публикации",
- "published - reverse": "дате - обратный порядок",
- "alphabetically": "алфавиту",
- "alphabetically - reverse": "алфавиту - обратный порядок",
- "channel name": "имени канала",
- "channel name - reverse": "имени канала - обратный порядок",
- "Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
- "Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
- "Only show unwatched: ": "Отображать только непросмотренные видео: ",
- "Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
- "Data preferences": "Настройки данных",
- "Clear watch history": "Очистить историю просмотра",
- "Import/Export data": "Импорт/Экспорт данных",
- "Manage subscriptions": "Управление подписками",
- "Watch history": "История просмотров",
- "Delete account": "Удалить аккаунт",
- "Save preferences": "Сохранить настройки",
- "Subscription manager": "Менеджер подписок",
- "`x` subscriptions": "`x` подписок",
- "Import/Export": "Импорт/Экспорт",
- "unsubscribe": "отписаться",
- "Subscriptions": "Подписки",
- "`x` unseen notifications": "`x` новых оповещений",
- "search": "поиск",
- "Sign out": "Выйти",
- "Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
- "Source available here.": "Исходный код доступен здесь.",
- "Liberapay: ": "Liberapay: ",
- "Patreon: ": "Patreon: ",
- "BTC: ": "BTC: ",
- "BCH: ": "BCH: ",
- "View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
- "Trending": "В тренде",
- "Watch video on Youtube": "Смотреть на YouTube",
- "Genre: ": "Жанр: ",
- "License: ": "Лицензия: ",
- "Family friendly? ": "Семейный просмотр: ",
- "Wilson score: ": "Рейтинг Вильсона: ",
- "Engagement: ": "Вовлеченность: ",
- "Whitelisted regions: ": "Доступно для: ",
- "Blacklisted regions: ": "Недоступно для: ",
- "Shared `x`": "Опубликовано `x`",
- "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).",
- "View YouTube comments": "Смотреть комментарии с YouTube",
- "View more comments on Reddit": "Больше комментариев на Reddit",
- "View `x` comments": "Показать `x` комментариев",
- "View Reddit comments": "Смотреть комментарии с Reddit",
- "Hide replies": "Скрыть ответы",
- "Show replies": "Показать ответы",
- "Incorrect password": "Неправильный пароль",
- "Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
- "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
- "Invalid TFA code": "Неправильный TFA код",
- "Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
- "Invalid answer": "Неверный ответ",
- "Invalid CAPTCHA": "Неверная капча",
- "CAPTCHA is a required field": "Необходимо ввести капчу",
- "User ID is a required field": "Необходимо ввести идентификатор пользователя",
- "Password is a required field": "Необходимо ввести пароль",
- "Invalid username or password": "Недопустимый пароль или имя пользователя",
- "Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
- "Password cannot be empty": "Пароль не может быть пустым",
- "Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
- "Please sign in": "Пожалуйста, войдите",
- "Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
- "channel:`x`": "канал: `x`",
- "Deleted or invalid channel": "Канал удален или не найден",
- "This channel does not exist.": "Такой канал не существует.",
- "Could not get channel info.": "Невозможно получить информацию о канале.",
- "Could not fetch comments": "Невозможно получить комментарии",
- "View `x` replies": "Показать `x` ответов",
- "`x` ago": "`x` назад",
- "Load more": "Загрузить больше",
- "`x` points": "`x` очков",
- "Could not create mix.": "Невозможно создать \"микс\".",
- "Playlist is empty": "Плейлист пуст",
- "Invalid playlist.": "Некорректный плейлист.",
- "Playlist does not exist.": "Плейлист не существует.",
- "Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
- "Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
- "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
- "Invalid challenge": "Неправильный ответ в \"challenge\"",
- "Invalid token": "Неправильный токен",
- "Invalid user": "Недопустимое имя пользователя",
- "Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
- "English": "Английский",
- "English (auto-generated)": "Английский (созданы автоматически)",
- "Afrikaans": "Африкаанс",
- "Albanian": "Албанский",
- "Amharic": "Амхарский",
- "Arabic": "Арабский",
- "Armenian": "Армянский",
- "Azerbaijani": "Азербайджанский",
- "Bangla": "",
- "Basque": "",
- "Belarusian": "",
- "Bosnian": "",
- "Bulgarian": "",
- "Burmese": "",
- "Catalan": "",
- "Cebuano": "",
- "Chinese (Simplified)": "",
- "Chinese (Traditional)": "",
- "Corsican": "",
- "Croatian": "",
- "Czech": "",
- "Danish": "",
- "Dutch": "",
- "Esperanto": "",
- "Estonian": "",
- "Filipino": "",
- "Finnish": "",
- "French": "",
- "Galician": "",
- "Georgian": "",
- "German": "",
- "Greek": "",
- "Gujarati": "",
- "Haitian Creole": "",
- "Hausa": "",
- "Hawaiian": "",
- "Hebrew": "",
- "Hindi": "",
- "Hmong": "",
- "Hungarian": "",
- "Icelandic": "",
- "Igbo": "",
- "Indonesian": "",
- "Irish": "",
- "Italian": "",
- "Japanese": "",
- "Javanese": "",
- "Kannada": "",
- "Kazakh": "",
- "Khmer": "",
- "Korean": "",
- "Kurdish": "",
- "Kyrgyz": "",
- "Lao": "",
- "Latin": "",
- "Latvian": "",
- "Lithuanian": "",
- "Luxembourgish": "",
- "Macedonian": "",
- "Malagasy": "",
- "Malay": "",
- "Malayalam": "",
- "Maltese": "",
- "Maori": "",
- "Marathi": "",
- "Mongolian": "",
- "Nepali": "",
- "Norwegian": "",
- "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` лет",
- "`x` months": "`x` месяцев",
- "`x` weeks": "`x` недель",
- "`x` days": "`x` дней",
- "`x` hours": "`x` часов",
- "`x` minutes": "`x` минут",
- "`x` seconds": "`x` секунд",
- "Fallback comments: ": "Резервные комментарии: ",
- "Popular": "Популярное",
- "Top": "Топ",
- "About": "О сайте",
- "Rating: ": "Рейтинг: ",
- "Language: ": "Язык: ",
- "Default": "По-умолчанию",
- "Music": "Музыка",
- "Gaming": "Игры",
- "News": "Новости",
- "Movies": "Фильмы",
- "Download": "Скачать",
- "Download as: ": "Скачать как: ",
- "%A %B %-d, %Y": "",
- "(edited)": "",
- "Youtube permalink of the comment": "",
- "`x` marked it with a ❤": ""
+ "`x` subscribers": "`x` подписчиков",
+ "`x` videos": "`x` видео",
+ "LIVE": "ПРЯМОЙ ЭФИР",
+ "Shared `x` ago": "Опубликовано `x` назад",
+ "Unsubscribe": "Отписаться",
+ "Subscribe": "Подписаться",
+ "Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
+ "View channel on YouTube": "Канал на YouTube",
+ "newest": "новые",
+ "oldest": "старые",
+ "popular": "популярные",
+ "Preview page": "Предварительный просмотр",
+ "Next page": "Следующая страница",
+ "Clear watch history?": "Очистить историю просмотров?",
+ "Yes": "Да",
+ "No": "Нет",
+ "Import and Export Data": "Импорт и экспорт данных",
+ "Import": "Импорт",
+ "Import Invidious data": "Импортировать данные Invidious",
+ "Import YouTube subscriptions": "Импортировать YouTube подписки",
+ "Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
+ "Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
+ "Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
+ "Export": "Экспорт",
+ "Export subscriptions as OPML": "Экспортировать подписки в OPML",
+ "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
+ "Export data as JSON": "Экспортировать данные в JSON",
+ "Delete account?": "Удалить аккаунт?",
+ "History": "История",
+ "Previous page": "Предыдущая страница",
+ "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
+ "JavaScript license information": "Лицензии JavaScript",
+ "source": "источник",
+ "Login": "Войти",
+ "Login/Register": "Войти/Регистрация",
+ "Login to Google": "Войти через Google",
+ "User ID:": "ID пользователя:",
+ "Password:": "Пароль:",
+ "Time (h:mm:ss):": "Время (ч:мм:сс):",
+ "Text CAPTCHA": "Текст капчи",
+ "Image CAPTCHA": "Изображение капчи",
+ "Sign In": "Войти",
+ "Register": "Регистрация",
+ "Email:": "Эл. почта:",
+ "Google verification code:": "Код подтверждения Google:",
+ "Preferences": "Настройки",
+ "Player preferences": "Настройки проигрывателя",
+ "Always loop: ": "Всегда повторять: ",
+ "Autoplay: ": "Автовоспроизведение: ",
+ "Autoplay next video: ": "Автовоспроизведение следующего видео: ",
+ "Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
+ "Default speed: ": "Скорость по-умолчанию: ",
+ "Preferred video quality: ": "Предпочтительное качество видео: ",
+ "Player volume: ": "Громкость воспроизведения: ",
+ "Default comments: ": "Источник комментариев: ",
+ "youtube": "YouTube",
+ "reddit": "Reddit",
+ "Default captions: ": "Субтитры по-умолчанию: ",
+ "Fallback captions: ": "Резервные субтитры: ",
+ "Show related videos? ": "Показывать похожие видео? ",
+ "Visual preferences": "Визуальные настройки",
+ "Dark mode: ": "Темная тема: ",
+ "Thin mode: ": "Облегченный режим: ",
+ "Subscription preferences": "Настройки подписок",
+ "Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
+ "Number of videos shown in feed: ": "Число видео в ленте: ",
+ "Sort videos by: ": "Сортировать видео по: ",
+ "published": "дате публикации",
+ "published - reverse": "дате - обратный порядок",
+ "alphabetically": "алфавиту",
+ "alphabetically - reverse": "алфавиту - обратный порядок",
+ "channel name": "имени канала",
+ "channel name - reverse": "имени канала - обратный порядок",
+ "Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
+ "Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
+ "Only show unwatched: ": "Отображать только непросмотренные видео: ",
+ "Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
+ "Data preferences": "Настройки данных",
+ "Clear watch history": "Очистить историю просмотра",
+ "Import/Export data": "Импорт/Экспорт данных",
+ "Manage subscriptions": "Управление подписками",
+ "Watch history": "История просмотров",
+ "Delete account": "Удалить аккаунт",
+ "Save preferences": "Сохранить настройки",
+ "Subscription manager": "Менеджер подписок",
+ "`x` subscriptions": "`x` подписок",
+ "Import/Export": "Импорт/Экспорт",
+ "unsubscribe": "отписаться",
+ "Subscriptions": "Подписки",
+ "`x` unseen notifications": "`x` новых оповещений",
+ "search": "поиск",
+ "Sign out": "Выйти",
+ "Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
+ "Source available here.": "Исходный код доступен здесь.",
+ "Liberapay: ": "Liberapay: ",
+ "Patreon: ": "Patreon: ",
+ "BTC: ": "BTC: ",
+ "BCH: ": "BCH: ",
+ "View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
+ "Trending": "В тренде",
+ "Watch video on Youtube": "Смотреть на YouTube",
+ "Genre: ": "Жанр: ",
+ "License: ": "Лицензия: ",
+ "Family friendly? ": "Семейный просмотр: ",
+ "Wilson score: ": "Рейтинг Вильсона: ",
+ "Engagement: ": "Вовлеченность: ",
+ "Whitelisted regions: ": "Доступно для: ",
+ "Blacklisted regions: ": "Недоступно для: ",
+ "Shared `x`": "Опубликовано `x`",
+ "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).",
+ "View YouTube comments": "Смотреть комментарии с YouTube",
+ "View more comments on Reddit": "Больше комментариев на Reddit",
+ "View `x` comments": "Показать `x` комментариев",
+ "View Reddit comments": "Смотреть комментарии с Reddit",
+ "Hide replies": "Скрыть ответы",
+ "Show replies": "Показать ответы",
+ "Incorrect password": "Неправильный пароль",
+ "Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
+ "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
+ "Invalid TFA code": "Неправильный TFA код",
+ "Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
+ "Invalid answer": "Неверный ответ",
+ "Invalid CAPTCHA": "Неверная капча",
+ "CAPTCHA is a required field": "Необходимо ввести капчу",
+ "User ID is a required field": "Необходимо ввести идентификатор пользователя",
+ "Password is a required field": "Необходимо ввести пароль",
+ "Invalid username or password": "Недопустимый пароль или имя пользователя",
+ "Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
+ "Password cannot be empty": "Пароль не может быть пустым",
+ "Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
+ "Please sign in": "Пожалуйста, войдите",
+ "Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
+ "channel:`x`": "канал: `x`",
+ "Deleted or invalid channel": "Канал удален или не найден",
+ "This channel does not exist.": "Такой канал не существует.",
+ "Could not get channel info.": "Невозможно получить информацию о канале.",
+ "Could not fetch comments": "Невозможно получить комментарии",
+ "View `x` replies": "Показать `x` ответов",
+ "`x` ago": "`x` назад",
+ "Load more": "Загрузить больше",
+ "`x` points": "`x` очков",
+ "Could not create mix.": "Невозможно создать \"микс\".",
+ "Playlist is empty": "Плейлист пуст",
+ "Invalid playlist.": "Некорректный плейлист.",
+ "Playlist does not exist.": "Плейлист не существует.",
+ "Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
+ "Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
+ "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
+ "Invalid challenge": "Неправильный ответ в \"challenge\"",
+ "Invalid token": "Неправильный токен",
+ "Invalid user": "Недопустимое имя пользователя",
+ "Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
+ "English": "Английский",
+ "English (auto-generated)": "Английский (созданы автоматически)",
+ "Afrikaans": "Африкаанс",
+ "Albanian": "Албанский",
+ "Amharic": "Амхарский",
+ "Arabic": "Арабский",
+ "Armenian": "Армянский",
+ "Azerbaijani": "Азербайджанский",
+ "Bangla": "",
+ "Basque": "",
+ "Belarusian": "",
+ "Bosnian": "",
+ "Bulgarian": "",
+ "Burmese": "",
+ "Catalan": "",
+ "Cebuano": "",
+ "Chinese (Simplified)": "",
+ "Chinese (Traditional)": "",
+ "Corsican": "",
+ "Croatian": "",
+ "Czech": "",
+ "Danish": "",
+ "Dutch": "",
+ "Esperanto": "",
+ "Estonian": "",
+ "Filipino": "",
+ "Finnish": "",
+ "French": "",
+ "Galician": "",
+ "Georgian": "",
+ "German": "",
+ "Greek": "",
+ "Gujarati": "",
+ "Haitian Creole": "",
+ "Hausa": "",
+ "Hawaiian": "",
+ "Hebrew": "",
+ "Hindi": "",
+ "Hmong": "",
+ "Hungarian": "",
+ "Icelandic": "",
+ "Igbo": "",
+ "Indonesian": "",
+ "Irish": "",
+ "Italian": "",
+ "Japanese": "",
+ "Javanese": "",
+ "Kannada": "",
+ "Kazakh": "",
+ "Khmer": "",
+ "Korean": "",
+ "Kurdish": "",
+ "Kyrgyz": "",
+ "Lao": "",
+ "Latin": "",
+ "Latvian": "",
+ "Lithuanian": "",
+ "Luxembourgish": "",
+ "Macedonian": "",
+ "Malagasy": "",
+ "Malay": "",
+ "Malayalam": "",
+ "Maltese": "",
+ "Maori": "",
+ "Marathi": "",
+ "Mongolian": "",
+ "Nepali": "",
+ "Norwegian": "",
+ "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` лет",
+ "`x` months": "`x` месяцев",
+ "`x` weeks": "`x` недель",
+ "`x` days": "`x` дней",
+ "`x` hours": "`x` часов",
+ "`x` minutes": "`x` минут",
+ "`x` seconds": "`x` секунд",
+ "Fallback comments: ": "Резервные комментарии: ",
+ "Popular": "Популярное",
+ "Top": "Топ",
+ "About": "О сайте",
+ "Rating: ": "Рейтинг: ",
+ "Language: ": "Язык: ",
+ "Default": "По-умолчанию",
+ "Music": "Музыка",
+ "Gaming": "Игры",
+ "News": "Новости",
+ "Movies": "Фильмы",
+ "Download": "Скачать",
+ "Download as: ": "Скачать как: ",
+ "%A %B %-d, %Y": "%-d %B %Y, %A",
+ "(edited)": "(изменено)",
+ "Youtube permalink of the comment": "Прямая ссылка на YouTube",
+ "`x` marked it with a ❤": "❤ от автора канала \"`x`\""
}
diff --git a/src/invidious.cr b/src/invidious.cr
index 72ac8caf..222b82ae 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -163,9 +163,10 @@ before_all do |env|
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
- user = PG_DB.query_one?("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
+ email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
- if user
+ if email
+ user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
env.set "challenge", challenge
@@ -177,7 +178,7 @@ before_all do |env|
end
else
begin
- user = get_user(sid, headers, PG_DB, false)
+ user, sid = get_user(sid, headers, PG_DB, false)
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
env.set "challenge", challenge
@@ -312,7 +313,7 @@ get "/watch" do |env|
end
if watched && !watched.includes? id
- PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE $2 = id", [id], user.as(User).id)
+ PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email)
end
if nojs
@@ -818,7 +819,7 @@ post "/login" do |env|
# Prefer Authenticator app and SMS over unsupported protocols
if challenge_results[0][-1][0][0][8] != 6 && challenge_results[0][-1][0][0][8] != 9
tfa = challenge_results[0][-1][0].as_a.select { |auth_type| auth_type[8] == 6 || auth_type[8] == 9 }[0]
- select_challenge = "[#{challenge_results[0][-1][0].as_a.index(tfa).not_nil!}]"
+ select_challenge = "[2,null,null,null,[#{tfa[8]}]]"
tl = challenge_results[1][2]
@@ -880,7 +881,7 @@ post "/login" do |env|
sid = login.cookies["SID"].value
- user = get_user(sid, headers, PG_DB)
+ user, sid = get_user(sid, headers, PG_DB)
# We are now logged in
@@ -986,7 +987,7 @@ post "/login" do |env|
if Crypto::Bcrypt::Password.new(user.password.not_nil!) == password
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- PG_DB.exec("UPDATE users SET id = id || $1 WHERE LOWER(email) = LOWER($2)", [sid], email)
+ PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.now)
if Kemal.config.ssl || CONFIG.https_only
secure = true
@@ -1024,13 +1025,14 @@ post "/login" do |env|
end
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- user = create_user(sid, email, password)
+ user, sid = create_user(sid, email, password)
user_array = user.to_a
- user_array[5] = user_array[5].to_json
+ user_array[4] = user_array[4].to_json
args = arg_array(user_array)
PG_DB.exec("INSERT INTO users VALUES (#{args})", user_array)
+ PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.now)
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
@@ -1078,7 +1080,7 @@ get "/signout" do |env|
user = env.get("user").as(User)
sid = env.get("sid").as(String)
- PG_DB.exec("UPDATE users SET id = array_remove(id, $1) WHERE email = $2", sid, user.email)
+ PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid)
env.request.cookies.each do |cookie|
cookie.expires = Time.new(1990, 1, 1)
@@ -1252,7 +1254,7 @@ get "/mark_watched" do |env|
if user
user = user.as(User)
if !user.watched.includes? id
- PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE $2 = id", [id], user.id)
+ PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.email)
end
end
@@ -1347,9 +1349,10 @@ get "/subscription_manager" do |env|
locale = LOCALES[env.get("locale").as(String)]?
user = env.get? "user"
+ sid = env.get? "sid"
referer = get_referer(env, "/")
- if !user
+ if !user && !sid
next env.redirect referer
end
@@ -1360,7 +1363,7 @@ get "/subscription_manager" do |env|
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
- user = get_user(user.id[0], headers, PG_DB)
+ user, sid = get_user(sid, headers, PG_DB)
end
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
@@ -1370,14 +1373,7 @@ get "/subscription_manager" do |env|
format = env.params.query["format"]?
format ||= "rss"
- subscriptions = [] of InvidiousChannel
- user.subscriptions.each do |ucid|
- begin
- subscriptions << get_channel(ucid, PG_DB, false, false)
- rescue ex
- next
- end
- end
+ subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY('{#{user.subscriptions.join(",")}}')", as: InvidiousChannel)
subscriptions.sort_by! { |channel| channel.author.downcase }
if action_takeout
@@ -1756,10 +1752,12 @@ get "/feed/subscriptions" do |env|
locale = LOCALES[env.get("locale").as(String)]?
user = env.get? "user"
+ sid = env.get? "sid"
referer = get_referer(env)
if user
user = user.as(User)
+ sid = sid.as(String)
preferences = user.preferences
if preferences.unseen_only
@@ -1771,7 +1769,7 @@ get "/feed/subscriptions" do |env|
headers["Cookie"] = env.request.headers["Cookie"]
if !user.password
- user = get_user(user.id[0], headers, PG_DB)
+ user, sid = get_user(sid, headers, PG_DB)
end
max_results = preferences.max_results
@@ -3033,7 +3031,8 @@ end
ucid = env.params.url["ucid"]
page = env.params.query["page"]?.try &.to_i?
page ||= 1
- sort_by = env.params.query["sort_by"]?.try &.downcase
+ sort_by = env.params.query["sort"]?.try &.downcase
+ sort_by ||= env.params.query["sort_by"]?.try &.downcase
sort_by ||= "newest"
begin
@@ -3438,7 +3437,7 @@ get "/api/v1/mixes/:rdid" do |env|
rdid = env.params.url["rdid"]
continuation = env.params.query["continuation"]?
- continuation ||= rdid.lchop("RD")
+ continuation ||= rdid.lchop("RD")[0, 11]
format = env.params.query["format"]?
format ||= "json"
@@ -3662,6 +3661,8 @@ get "/latest_version" do |env|
id = env.params.query["id"]?
itag = env.params.query["itag"]?
+ region = env.params.query["region"]?
+
local = env.params.query["local"]?
local ||= "false"
local = local == "true"
@@ -3670,7 +3671,7 @@ get "/latest_version" do |env|
halt env, status_code: 400
end
- video = get_video(id, PG_DB, proxies)
+ video = get_video(id, PG_DB, proxies, region: region)
fmt_stream = video.fmt_stream(decrypt_function)
adaptive_fmts = video.adaptive_fmts(decrypt_function)
@@ -3943,14 +3944,13 @@ end
error 500 do |env|
error_message = <<-END_HTML
- Looks like you've found a bug in Invidious. Feel free to open a new issue
-
+ Looks like you've found a bug in Invidious. Feel free to open a new issue
+
here
or send an email to
- omarroth@protonmail.com
- .
+ omarroth@protonmail.com.
END_HTML
templated "error"
end
diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr
index ccaf2487..b6692919 100644
--- a/src/invidious/channels.cr
+++ b/src/invidious/channels.cr
@@ -260,6 +260,132 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
return url
end
+def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false)
+ if !auto_generated
+ cursor = Base64.urlsafe_encode(cursor, false)
+ end
+
+ meta = IO::Memory.new
+
+ if auto_generated
+ meta.write(Bytes[0x08, 0x0a])
+ end
+
+ meta.write(Bytes[0x12, 0x09])
+ meta.print("playlists")
+
+ if auto_generated
+ meta.write(Bytes[0x20, 0x32])
+ else
+ # TODO: Look at 0x01, 0x00
+ case sort
+ when "oldest", "oldest_created"
+ meta.write(Bytes[0x18, 0x02])
+ when "newest", "newest_created"
+ meta.write(Bytes[0x18, 0x03])
+ when "last", "last_added"
+ meta.write(Bytes[0x18, 0x04])
+ end
+
+ meta.write(Bytes[0x20, 0x01])
+ end
+
+ meta.write(Bytes[0x30, 0x02])
+ meta.write(Bytes[0x38, 0x01])
+ meta.write(Bytes[0x60, 0x01])
+ meta.write(Bytes[0x6a, 0x00])
+
+ meta.write(Bytes[0x7a, cursor.size])
+ meta.print(cursor)
+
+ meta.write(Bytes[0xb8, 0x01, 0x00])
+
+ meta.rewind
+ meta = Base64.urlsafe_encode(meta.to_slice)
+ meta = URI.escape(meta)
+
+ continuation = IO::Memory.new
+ continuation.write(Bytes[0x12, ucid.size])
+ continuation.print(ucid)
+
+ continuation.write(Bytes[0x1a])
+ continuation.write(write_var_int(meta.size))
+ continuation.print(meta)
+
+ continuation.rewind
+ continuation = continuation.gets_to_end
+
+ wrapper = IO::Memory.new
+ wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
+ wrapper.write(write_var_int(continuation.size))
+ wrapper.print(continuation)
+ wrapper.rewind
+
+ wrapper = Base64.urlsafe_encode(wrapper.to_slice)
+ wrapper = URI.escape(wrapper)
+
+ url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
+
+ return url
+end
+
+def extract_channel_playlists_cursor(url, auto_generated)
+ wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
+
+ wrapper = URI.unescape(wrapper)
+ wrapper = Base64.decode(wrapper)
+
+ # 0xe2 0xa9 0x85 0xb2 0x02
+ wrapper += 5
+
+ continuation_size = read_var_int(wrapper[0, 4])
+ wrapper += write_var_int(continuation_size).size
+ continuation = wrapper[0, continuation_size]
+
+ # 0x12
+ continuation += 1
+ ucid_size = continuation[0]
+ continuation += 1
+ ucid = continuation[0, ucid_size]
+ continuation += ucid_size
+
+ # 0x1a
+ continuation += 1
+ meta_size = read_var_int(continuation[0, 4])
+ continuation += write_var_int(meta_size).size
+ meta = continuation[0, meta_size]
+ continuation += meta_size
+
+ meta = String.new(meta)
+ meta = URI.unescape(meta)
+ meta = Base64.decode(meta)
+
+ # 0x12 0x09 playlists
+ meta += 11
+
+ until meta[0] == 0x7a
+ tag = read_var_int(meta[0, 4])
+ meta += write_var_int(tag).size
+ value = meta[0]
+ meta += 1
+ end
+
+ # 0x7a
+ meta += 1
+ cursor_size = meta[0]
+ meta += 1
+ cursor = meta[0, cursor_size]
+
+ cursor = String.new(cursor)
+
+ if !auto_generated
+ cursor = URI.unescape(cursor)
+ cursor = Base64.decode_string(cursor)
+ end
+
+ return cursor
+end
+
def get_about_info(ucid, locale)
client = make_client(YT_URL)
@@ -290,7 +416,7 @@ def get_about_info(ucid, locale)
sub_count ||= 0
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
- ucid = about.xpath_node(%q(//link[@rel="canonical"])).not_nil!["href"].split("/")[-1]
+ ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index 942757c3..45ebc4dd 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -166,35 +166,33 @@ def extract_videos(nodeset, ucid = nil)
videos.map { |video| video.as(SearchVideo) }
end
-def extract_items(nodeset, ucid = nil)
+def extract_items(nodeset, ucid = nil, author_name = nil)
# TODO: Make this a 'common', so it makes more sense to be used here
items = [] of SearchItem
nodeset.each do |node|
- anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
+ anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if !anchor
next
end
+ title = anchor.content.strip
+ id = anchor["href"]
if anchor["href"].starts_with? "https://www.googleadservices.com"
next
end
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
- if !anchor
- author = ""
- author_id = ""
- else
+ if anchor
author = anchor.content.strip
author_id = anchor["href"].split("/")[-1]
end
- anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
- if !anchor
- next
- end
- title = anchor.content.strip
- id = anchor["href"]
+ author ||= author_name
+ author_id ||= ucid
+
+ author ||= ""
+ author_id ||= ""
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
description_html, description = html_to_content(description_html)
@@ -354,3 +352,94 @@ def extract_items(nodeset, ucid = nil)
return items
end
+
+def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
+ items = [] of SearchPlaylist
+
+ nodeset.each do |shelf|
+ shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")]))
+
+ if !shelf_anchor
+ next
+ end
+
+ title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")]))
+ if title
+ title = title.content.strip
+ end
+ title ||= ""
+
+ id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"]
+ if !id
+ next
+ end
+
+ is_playlist = false
+ videos = [] of SearchPlaylistVideo
+
+ shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node|
+ type = child_node.xpath_node(%q(./div))
+ if !type
+ next
+ end
+
+ case type["class"]
+ when .includes? "yt-lockup-video"
+ is_playlist = true
+
+ anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
+ if anchor
+ video_title = anchor.content.strip
+ video_id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
+ end
+ video_title ||= ""
+ video_id ||= ""
+
+ anchor = child_node.xpath_node(%q(.//span[@class="video-time"]))
+ if anchor
+ length_seconds = decode_length_seconds(anchor.content)
+ end
+ length_seconds ||= 0
+
+ videos << SearchPlaylistVideo.new(
+ video_title,
+ video_id,
+ length_seconds
+ )
+ when .includes? "yt-lockup-playlist"
+ anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
+ if anchor
+ playlist_title = anchor.content.strip
+ params = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)
+ plid = params["list"]
+ end
+ playlist_title ||= ""
+ plid ||= ""
+
+ items << SearchPlaylist.new(
+ playlist_title,
+ plid,
+ author_name,
+ ucid,
+ 50,
+ Array(SearchPlaylistVideo).new
+ )
+ end
+ end
+
+ if is_playlist
+ plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
+
+ items << SearchPlaylist.new(
+ title,
+ plid,
+ author_name,
+ ucid,
+ videos.size,
+ videos
+ )
+ end
+ end
+
+ return items
+end
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index a56f468a..a3ada869 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -52,7 +52,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
item = item["playlistPanelVideoRenderer"]
id = item["videoId"].as_s
- title = item["title"]["simpleText"].as_s
+ title = item["title"]?.try &.["simpleText"].as_s
+ if !title
+ next
+ end
author = item["longBylineText"]["runs"][0]["text"].as_s
ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index 220a0ef7..28f2e4ce 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -161,117 +161,6 @@ def produce_playlist_url(id, index)
return url
end
-def produce_channel_playlists_url(ucid, cursor, sort = "newest")
- cursor = Base64.urlsafe_encode(cursor, false)
-
- meta = IO::Memory.new
- meta.write(Bytes[0x12, 0x09])
- meta.print("playlists")
-
- # TODO: Look at 0x01, 0x00
- case sort
- when "oldest", "oldest_created"
- meta.write(Bytes[0x18, 0x02])
- when "newest", "newest_created"
- meta.write(Bytes[0x18, 0x03])
- when "last", "last_added"
- meta.write(Bytes[0x18, 0x04])
- end
-
- meta.write(Bytes[0x20, 0x01])
- meta.write(Bytes[0x30, 0x02])
- meta.write(Bytes[0x38, 0x01])
- meta.write(Bytes[0x60, 0x01])
- meta.write(Bytes[0x6a, 0x00])
-
- meta.write(Bytes[0x7a, cursor.size])
- meta.print(cursor)
-
- meta.write(Bytes[0xb8, 0x01, 0x00])
-
- meta.rewind
- meta = Base64.urlsafe_encode(meta.to_slice)
- meta = URI.escape(meta)
-
- continuation = IO::Memory.new
- continuation.write(Bytes[0x12, ucid.size])
- continuation.print(ucid)
-
- continuation.write(Bytes[0x1a])
- continuation.write(write_var_int(meta.size))
- continuation.print(meta)
-
- continuation.rewind
- continuation = continuation.gets_to_end
-
- wrapper = IO::Memory.new
- wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
- wrapper.write(write_var_int(continuation.size))
- wrapper.print(continuation)
- wrapper.rewind
-
- wrapper = Base64.urlsafe_encode(wrapper.to_slice)
- wrapper = URI.escape(wrapper)
-
- url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
-
- return url
-end
-
-def extract_channel_playlists_cursor(url)
- wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
-
- wrapper = URI.unescape(wrapper)
- wrapper = Base64.decode(wrapper)
-
- # 0xe2 0xa9 0x85 0xb2 0x02
- wrapper += 5
-
- continuation_size = read_var_int(wrapper[0, 4])
- wrapper += write_var_int(continuation_size).size
- continuation = wrapper[0, continuation_size]
-
- # 0x12
- continuation += 1
- ucid_size = continuation[0]
- continuation += 1
- ucid = continuation[0, ucid_size]
- continuation += ucid_size
-
- # 0x1a
- continuation += 1
- meta_size = read_var_int(continuation[0, 4])
- continuation += write_var_int(meta_size).size
- meta = continuation[0, meta_size]
- continuation += meta_size
-
- meta = String.new(meta)
- meta = URI.unescape(meta)
- meta = Base64.decode(meta)
-
- # 0x12 0x09 playlists
- meta += 11
-
- until meta[0] == 0x7a
- tag = read_var_int(meta[0, 4])
- meta += write_var_int(tag).size
- value = meta[0]
- meta += 1
- end
-
- # 0x7a
- meta += 1
- cursor_size = meta[0]
- meta += 1
- cursor = meta[0, cursor_size]
-
- cursor = String.new(cursor)
- cursor = URI.unescape(cursor)
- cursor = Base64.decode_string(cursor)
-
- return cursor
-end
-
def fetch_playlist(plid, locale)
client = make_client(YT_URL)
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index d45c5af4..072638ba 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -12,7 +12,6 @@ class User
end
add_mapping({
- id: Array(String),
updated: Time,
notifications: Array(String),
subscriptions: Array(String),
@@ -126,18 +125,21 @@ class Preferences
end
def get_user(sid, headers, db, refresh = true)
- if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE $1 = ANY(id))", sid, as: Bool)
- user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
+ if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
+ user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
if refresh && Time.now - user.updated > 1.minute
- user = fetch_user(sid, headers, db)
+ user, sid = fetch_user(sid, headers, db)
user_array = user.to_a
- user_array[5] = user_array[5].to_json
+ user_array[4] = user_array[4].to_json
args = arg_array(user_array)
db.exec("INSERT INTO users VALUES (#{args}) \
- ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
+ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
+
+ db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
+ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
@@ -149,14 +151,17 @@ def get_user(sid, headers, db, refresh = true)
end
end
else
- user = fetch_user(sid, headers, db)
+ user, sid = fetch_user(sid, headers, db)
user_array = user.to_a
- user_array[5] = user_array[5].to_json
+ user_array[4] = user_array[4].to_json
args = arg_array(user.to_a)
db.exec("INSERT INTO users VALUES (#{args}) \
- ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
+ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
+
+ db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
+ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
@@ -168,7 +173,7 @@ def get_user(sid, headers, db, refresh = true)
end
end
- return user
+ return user, sid
end
def fetch_user(sid, headers, db)
@@ -196,17 +201,17 @@ def fetch_user(sid, headers, db)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- user = User.new([sid], Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
- return user
+ user = User.new(Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
+ return user, sid
end
def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- user = User.new([sid], Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
+ user = User.new(Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
- return user
+ return user, sid
end
def create_response(user_id, operation, key, db, expire = 6.hours)
diff --git a/src/invidious/views/components/subscribe_widget_script.ecr b/src/invidious/views/components/subscribe_widget_script.ecr
index c5e36b79..e2994c86 100644
--- a/src/invidious/views/components/subscribe_widget_script.ecr
+++ b/src/invidious/views/components/subscribe_widget_script.ecr
@@ -20,7 +20,7 @@ function subscribe(timeouts = 0) {
var fallback = subscribe_button.innerHTML;
subscribe_button.onclick = unsubscribe;
- subscribe_button.innerHTML = '<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>'
+ subscribe_button.innerHTML = '<%= translate(locale, "Unsubscribe").gsub("'", "\\'") %> | <%= sub_count_text %>'
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
@@ -55,7 +55,7 @@ function unsubscribe(timeouts = 0) {
var fallback = subscribe_button.innerHTML;
subscribe_button.onclick = subscribe;
- subscribe_button.innerHTML = '<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>'
+ subscribe_button.innerHTML = '<%= translate(locale, "Subscribe").gsub("'", "\\'") %> | <%= sub_count_text %>'
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {