mirror of
				https://gitea.invidious.io/iv-org/invidious
				synced 2025-06-05 23:29:12 +02:00 
			
		
		
		
	Merge branch 'master' into 347-screenshots
This commit is contained in:
		@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								config/migrate-scripts/migrate-db-3646395.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										5
									
								
								config/migrate-scripts/migrate-db-3646395.sh
									
									
									
									
									
										Executable file
									
								
							@@ -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"
 | 
			
		||||
@@ -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");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
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");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								config/sql/session_ids.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								config/sql/session_ids.sql
									
									
									
									
									
										Normal file
									
								
							@@ -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");
 | 
			
		||||
 | 
			
		||||
@@ -4,7 +4,6 @@
 | 
			
		||||
 | 
			
		||||
CREATE TABLE public.users
 | 
			
		||||
(
 | 
			
		||||
  id text[] NOT NULL,
 | 
			
		||||
  updated timestamp with time zone,
 | 
			
		||||
  notifications text[],
 | 
			
		||||
  subscriptions text[],
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										207
									
								
								locales/fr.json
									
									
									
									
									
								
							
							
						
						
									
										207
									
								
								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 ❤"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										564
									
								
								locales/ru.json
									
									
									
									
									
								
							
							
						
						
									
										564
									
								
								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`\""
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 
 | 
			
		||||
  <a href="https://github.com/omarroth/invidious/issues/github.com/omarroth/invidious">
 | 
			
		||||
  Looks like you've found a bug in Invidious. Feel free to open a new issue
 | 
			
		||||
  <a href="https://github.com/omarroth/invidious/issues">
 | 
			
		||||
    here
 | 
			
		||||
  </a>
 | 
			
		||||
  or send an email to 
 | 
			
		||||
  <a href="mailto:omarroth@protonmail.com">
 | 
			
		||||
    omarroth@protonmail.com
 | 
			
		||||
  </a>.
 | 
			
		||||
    omarroth@protonmail.com</a>.
 | 
			
		||||
  END_HTML
 | 
			
		||||
  templated "error"
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -166,29 +166,11 @@ 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))
 | 
			
		||||
    if !anchor
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
      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
 | 
			
		||||
@@ -196,6 +178,22 @@ def extract_items(nodeset, ucid = nil)
 | 
			
		||||
    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 = anchor.content.strip
 | 
			
		||||
      author_id = anchor["href"].split("/")[-1]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ function subscribe(timeouts = 0) {
 | 
			
		||||
 | 
			
		||||
    var fallback = subscribe_button.innerHTML;
 | 
			
		||||
    subscribe_button.onclick = unsubscribe;
 | 
			
		||||
    subscribe_button.innerHTML = '<b><%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %></b>'
 | 
			
		||||
    subscribe_button.innerHTML = '<b><%= translate(locale, "Unsubscribe").gsub("'", "\\'") %> | <%= sub_count_text %></b>'
 | 
			
		||||
    
 | 
			
		||||
    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 = '<b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>'
 | 
			
		||||
    subscribe_button.innerHTML = '<b><%= translate(locale, "Subscribe").gsub("'", "\\'") %> | <%= sub_count_text %></b>'
 | 
			
		||||
 | 
			
		||||
    xhr.onreadystatechange = function() {
 | 
			
		||||
        if (xhr.readyState == 4) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user