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) {