mirror of https://github.com/iv-org/invidious.git
Merge branch 'master' into 347-screenshots
This commit is contained in:
commit
6a8a49d8ef
|
@ -43,6 +43,8 @@ Onion links:
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
See [Invidious-Updater](https://github.com/tmiland/Invidious-Updater) for a self-contained script that can automatically install and update Invidious.
|
||||||
|
|
||||||
### Docker:
|
### Docker:
|
||||||
|
|
||||||
#### Build and start cluster:
|
#### 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/videos.sql
|
||||||
$ psql invidious < /home/invidious/invidious/config/sql/channel_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/users.sql
|
||||||
|
$ psql invidious < /home/invidious/invidious/config/sql/session_ids.sql
|
||||||
$ psql invidious < /home/invidious/invidious/config/sql/nonces.sql
|
$ psql invidious < /home/invidious/invidious/config/sql/nonces.sql
|
||||||
$ exit
|
$ exit
|
||||||
```
|
```
|
||||||
|
@ -146,6 +149,7 @@ $ psql invidious < config/sql/channels.sql
|
||||||
$ psql invidious < config/sql/videos.sql
|
$ psql invidious < config/sql/videos.sql
|
||||||
$ psql invidious < config/sql/channel_videos.sql
|
$ psql invidious < config/sql/channel_videos.sql
|
||||||
$ psql invidious < config/sql/users.sql
|
$ psql invidious < config/sql/users.sql
|
||||||
|
$ psql invidious < config/sql/session_ids.sql
|
||||||
$ psql invidious < config/sql/nonces.sql
|
$ psql invidious < config/sql/nonces.sql
|
||||||
|
|
||||||
# Setup Invidious
|
# Setup Invidious
|
||||||
|
@ -155,7 +159,7 @@ $ crystal build src/invidious.cr --release
|
||||||
|
|
||||||
## Update Invidious
|
## 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:
|
## Usage:
|
||||||
|
|
||||||
|
@ -192,13 +196,14 @@ $ ./sentry
|
||||||
|
|
||||||
## Extensions
|
## 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
|
## Made with Invidious
|
||||||
|
|
||||||
- [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy.
|
- [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
|
- [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.
|
- [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
|
## Contributing
|
||||||
|
|
||||||
|
|
|
@ -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
|
CREATE INDEX channel_videos_ucid_idx
|
||||||
ON public.channel_videos
|
ON public.channel_videos
|
||||||
USING hash
|
USING btree
|
||||||
(ucid COLLATE pg_catalog."default");
|
(ucid COLLATE pg_catalog."default");
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,18 @@
|
||||||
CREATE TABLE public.nonces
|
CREATE TABLE public.nonces
|
||||||
(
|
(
|
||||||
nonce text,
|
nonce text,
|
||||||
expire timestamp with time zone
|
expire timestamp with time zone,
|
||||||
)
|
CONSTRAINT nonces_id_key UNIQUE (nonce)
|
||||||
WITH (
|
|
||||||
OIDS=FALSE
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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");
|
||||||
|
|
||||||
|
|
|
@ -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
|
CREATE TABLE public.users
|
||||||
(
|
(
|
||||||
id text[] NOT NULL,
|
|
||||||
updated timestamp with time zone,
|
updated timestamp with time zone,
|
||||||
notifications text[],
|
notifications text[],
|
||||||
subscriptions 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/videos.sql'
|
||||||
su postgres -c 'psql invidious < config/sql/channel_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/users.sql'
|
||||||
|
su postgres -c 'psql invidious < config/sql/session_ids.sql'
|
||||||
su postgres -c 'psql invidious < config/sql/nonces.sql'
|
su postgres -c 'psql invidious < config/sql/nonces.sql'
|
||||||
touch /var/lib/postgresql/data/setupFinished
|
touch /var/lib/postgresql/data/setupFinished
|
||||||
echo "### invidious database setup finished"
|
echo "### invidious database setup finished"
|
||||||
|
|
199
locales/fr.json
199
locales/fr.json
|
@ -1,152 +1,151 @@
|
||||||
{
|
{
|
||||||
"`x` subscribers": "`x` souscripteurs",
|
"`x` subscribers": "`x` abonnés",
|
||||||
"`x` videos": "`x` vidéos",
|
"`x` videos": "`x` vidéos",
|
||||||
"LIVE": "LIVE",
|
"LIVE": "EN DIRECT",
|
||||||
"Shared `x` ago": "Partagé il y a `x`",
|
"Shared `x` ago": "Partagé, il y a `x`",
|
||||||
"Unsubscribe": "Se désabonner",
|
"Unsubscribe": "Se désabonner",
|
||||||
"Subscribe": "S'abonner",
|
"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",
|
"View channel on YouTube": "Voir la chaîne sur YouTube",
|
||||||
"newest": "récent",
|
"newest": "Date d'ajout (la plus récente)",
|
||||||
"oldest": "aînée",
|
"oldest": "Date d'ajout (la plus ancienne)",
|
||||||
"popular": "appréciés",
|
"popular": "Les plus populaires",
|
||||||
"Preview page": "Page de prévisualisation",
|
|
||||||
"Next page": "Page suivante",
|
"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",
|
"Yes": "Oui",
|
||||||
"No": "Aucun",
|
"No": "Non",
|
||||||
"Import and Export Data": "Importation et exportation de données",
|
"Import and Export Data": "Importation et Exportation de Données",
|
||||||
"Import": "Importation",
|
"Import": "Importer",
|
||||||
"Import Invidious data": "Importation de données invalides",
|
"Import Invidious data": "Importer des données Invidious",
|
||||||
"Import YouTube subscriptions": "Importer des abonnements YouTube",
|
"Import YouTube subscriptions": "Importer des abonnements YouTube",
|
||||||
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
|
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
|
||||||
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
|
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
|
||||||
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
|
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
|
||||||
"Export": "Exporter",
|
"Export": "Exporter",
|
||||||
"Export subscriptions as OPML": "Exporter les abonnements comme OPML",
|
"Export subscriptions as OPML": "Exporter les abonnements en OPML",
|
||||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements comme OPML (pour NewPipe & FreeTube)",
|
"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",
|
"Export data as JSON": "Exporter les données au format JSON",
|
||||||
"Delete account?": "Supprimer un compte ?",
|
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
|
||||||
"History": "Histoire",
|
"History": "Historique",
|
||||||
"Previous page": "Page précédente",
|
"Previous page": "Page précédente",
|
||||||
"An alternative front-end to YouTube": "Un frontal alternatif à YouTube",
|
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
|
||||||
"JavaScript license information": "Informations sur la licence JavaScript",
|
"JavaScript license information": "Informations sur les licences JavaScript",
|
||||||
"source": "origine",
|
"source": "source",
|
||||||
"Login": "Connexion",
|
"Login": "Connexion",
|
||||||
"Login/Register": "Connexion/S'inscrire",
|
"Login/Register": "Connexion/S'inscrire",
|
||||||
"Login to Google": "Se connecter à Google",
|
"Login to Google": "Se connecter à Google",
|
||||||
"User ID:": "ID utilisateur:",
|
"User ID:": "Identifiant utilisateur :",
|
||||||
"Password:": "Mot de passe :",
|
"Password:": "Mot de passe :",
|
||||||
"Time (h:mm:ss):": "Temps (h:mm:ss):",
|
"Time (h:mm:ss):": "Heure (h:mm:ss):",
|
||||||
"Text CAPTCHA": "Texte CAPTCHA",
|
"Text CAPTCHA": "CAPTCHA Texte",
|
||||||
"Image CAPTCHA": "Image CAPTCHA",
|
"Image CAPTCHA": "CAPTCHA Image",
|
||||||
"Sign In": "S'identifier",
|
"Sign In": "S'identifier",
|
||||||
"Register": "S'inscrire",
|
"Register": "S'inscrire",
|
||||||
"Email:": "Courriel:",
|
"Email:": "Email:",
|
||||||
"Google verification code:": "Code de vérification Google :",
|
"Google verification code:": "Code de vérification Google :",
|
||||||
"Preferences": "Préférences",
|
"Preferences": "Préférences",
|
||||||
"Player preferences": "Joueur préférences",
|
"Player preferences": "Préférences du Lecteur",
|
||||||
"Always loop: ": "Toujours en boucle: ",
|
"Always loop: ": "Lire en boucle: ",
|
||||||
"Autoplay: ": "Autoplay: ",
|
"Autoplay: ": "Lire Automatiquement: ",
|
||||||
"Autoplay next video: ": "Lecture automatique de la vidéo suivante: ",
|
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
||||||
"Listen by default: ": "Écouter par défaut: ",
|
"Listen by default: ": "Audio Uniquement par défaut : ",
|
||||||
"Default speed: ": "Vitesse par défaut: ",
|
"Default speed: ": "Vitesse par défaut: ",
|
||||||
"Preferred video quality: ": "Qualité vidéo préférée: ",
|
"Preferred video quality: ": "Qualité vidéo souhaitée : ",
|
||||||
"Player volume: ": "Volume de lecteur: ",
|
"Player volume: ": "Volume du lecteur: ",
|
||||||
"Default comments: ": "Commentaires par défaut: ",
|
"Default comments: ": "Source des Commentaires : ",
|
||||||
"Default captions: ": "Légendes par défaut: ",
|
"Default captions: ": "Sous-titres principal : ",
|
||||||
"Fallback captions: ": "Légendes de repli: ",
|
"Fallback captions: ": "Sous-titre secondaire : ",
|
||||||
"Show related videos? ": "Voir les vidéos liées à ce sujet? ",
|
"Show related videos? ": "Voir les vidéos liées à ce sujet? ",
|
||||||
"Visual preferences": "Préférences visuelles",
|
"Visual preferences": "Préférences du site",
|
||||||
"Dark mode: ": "Mode sombre: ",
|
"Dark mode: ": "Mode Sombre: ",
|
||||||
"Thin mode: ": "Mode Thin: ",
|
"Thin mode: ": "Mode Simplifié: ",
|
||||||
"Subscription preferences": "Préférences d'abonnement",
|
"Subscription preferences": "Préférences de la page d'abonnements",
|
||||||
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers le flux: ",
|
"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 le flux: ",
|
"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 : ",
|
"Sort videos by: ": "Trier les vidéos par : ",
|
||||||
"published": "publié",
|
"published": "publié",
|
||||||
"published - reverse": "publié - reverse",
|
"published - reverse": "publié - inversé",
|
||||||
"alphabetically": "alphabétiquement",
|
"alphabetically": "alphabétiquement",
|
||||||
"alphabetically - reverse": "alphabétiquement - contraire",
|
"alphabetically - reverse": "alphabétiquement - inversé",
|
||||||
"channel name": "nom du canal",
|
"channel name": "nom de la chaîne",
|
||||||
"channel name - reverse": "nom du canal - contraire",
|
"channel name - reverse": "nom de la chaîne - inversé",
|
||||||
"Only show latest video from channel: ": "Afficher uniquement les dernières vidéos de la chaîne: ",
|
"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 les dernières vidéos non regardées 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 images non surveillées: ",
|
"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) : ",
|
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
|
||||||
"Data preferences": "Préférences de données",
|
"Data preferences": "Préférences liées aux données",
|
||||||
"Clear watch history": "Historique clair de la montre",
|
"Clear watch history": "Supprimer l'historique des vidéos regardées",
|
||||||
"Import/Export data": "Données d'importation/exportation",
|
"Import/Export data": "Importation/exportation de ",
|
||||||
"Manage subscriptions": "Gérer les abonnements",
|
"Manage subscriptions": "Gérer les abonnements",
|
||||||
"Watch history": "Historique des montres",
|
"Watch history": "Historique de visionnage",
|
||||||
"Delete account": "Supprimer un compte",
|
"Delete account": "Supprimer votre compte",
|
||||||
"Save preferences": "Enregistrer les préférences",
|
"Save preferences": "Enregistrer les préférences",
|
||||||
"Subscription manager": "Gestionnaire d'abonnement",
|
"Subscription manager": "Gestionnaire d'abonnement",
|
||||||
"`x` subscriptions": "`x` abonnements",
|
"`x` subscriptions": "`x` abonnements",
|
||||||
"Import/Export": "Importer/Exporter",
|
"Import/Export": "Importer/Exporter",
|
||||||
"unsubscribe": "se désabonner",
|
"unsubscribe": "se désabonner",
|
||||||
"Subscriptions": "Abonnements",
|
"Subscriptions": "Abonnements",
|
||||||
"`x` unseen notifications": "`x` notifications invisibles",
|
"`x` unseen notifications": "`x` notifications non vues",
|
||||||
"search": "perquisition",
|
"search": "Rechercher",
|
||||||
"Sign out": "Déconnexion",
|
"Sign out": "Déconnexion",
|
||||||
"Released under the AGPLv3 by Omar Roth.": "Publié sous l'AGPLv3 par Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
|
||||||
"Source available here.": "Source disponible ici.",
|
"Source available here.": "Code Source",
|
||||||
"View JavaScript license information.": "Voir les informations de licence JavaScript.",
|
"View JavaScript license information.": "Voir les informations des licences JavaScript.",
|
||||||
"Trending": "Tendances",
|
"Trending": "Tendances",
|
||||||
"Watch video on Youtube": "Voir la vidéo sur Youtube",
|
"Watch video on Youtube": "Voir la vidéo sur Youtube",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
"License: ": "Licence: ",
|
"License: ": "Licence: ",
|
||||||
"Family friendly? ": "Convivialité familiale? ",
|
"Family friendly? ": "Tout Public? ",
|
||||||
"Wilson score: ": "Wilson marque: ",
|
"Wilson score: ": "Score de Wilson: ",
|
||||||
"Engagement: ": "Fiançailles: ",
|
"Engagement: ": "Poucentage de spectateur aillant aimé Liker ou Disliker la vidéo : ",
|
||||||
"Whitelisted regions: ": "Régions en liste blanche: ",
|
"Whitelisted regions: ": "Régions en liste blanche: ",
|
||||||
"Blacklisted regions: ": "Régions sur liste noire: ",
|
"Blacklisted regions: ": "Régions sur liste noire: ",
|
||||||
"Shared `x`": "Partagée `x`",
|
"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.",
|
"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 sur YouTube",
|
"View YouTube comments": "Voir les commentaires YouTube",
|
||||||
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
|
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
|
||||||
"View `x` comments": "Voir `x` commentaires",
|
"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",
|
"Hide replies": "Masquer les réponses",
|
||||||
"Show replies": "Afficher les réponses",
|
"Show replies": "Afficher les réponses",
|
||||||
"Incorrect password": "Mot de passe incorrect",
|
"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.",
|
"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.",
|
"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 answer": "Réponse non valide",
|
||||||
"Invalid CAPTCHA": "CAPTCHA invalide",
|
"Invalid CAPTCHA": "CAPTCHA invalide",
|
||||||
"CAPTCHA is a required field": "CAPTCHA est un champ obligatoire",
|
"CAPTCHA is a required field": "Veuillez rentrez un CAPTCHA",
|
||||||
"User ID is a required field": "Utilisateur ID est un champ obligatoire",
|
"User ID is a required field": "Veuillez rentrez un Identifiant Utilisateur",
|
||||||
"Password is a required field": "Mot de passe est un champ obligatoire",
|
"Password is a required field": "Veuillez rentrez un Mot de passe",
|
||||||
"Invalid username or password": "Nom d'utilisateur ou mot de passe invalide",
|
"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 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.",
|
"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",
|
"Please sign in": "Veuillez vous connecter",
|
||||||
"Invidious Private Feed for `x`": "Flux privé Invidious pour `x`",
|
"Invidious Private Feed for `x`": "Flux RSS privé pour `x`",
|
||||||
"channel:`x`": "chenal:`x`",
|
"channel:`x`": "chaîne:`x`",
|
||||||
"Deleted or invalid channel": "Canal supprimé ou non valide",
|
"Deleted or invalid channel": "Chaîne supprimée ou invalide",
|
||||||
"This channel does not exist.": "Ce canal n'existe pas.",
|
"This channel does not exist.": "Cette chaine n'existe pas.",
|
||||||
"Could not get channel info.": "Impossible d'obtenir des informations sur les chaînes.",
|
"Could not get channel info.": "Impossible de charger les informations de cette chaîne.",
|
||||||
"Could not fetch comments": "Impossible d'aller chercher les commentaires",
|
"Could not fetch comments": "Impossible de charger les commentaires",
|
||||||
"View `x` replies": "Voir `x` réponses",
|
"View `x` replies": "Voir `x` réponses",
|
||||||
"`x` ago": "il y a `x`",
|
"`x` ago": "il y a `x`",
|
||||||
"Load more": "Charger plus",
|
"Load more": "Charger plus",
|
||||||
"`x` points": "`x` points",
|
"`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",
|
"Playlist is empty": "La liste de lecture est vide",
|
||||||
"Invalid playlist.": "Liste de lecture invalide.",
|
"Invalid playlist.": "Liste de lecture invalide.",
|
||||||
"Playlist does not exist.": "La liste de lecture n'existe pas.",
|
"Playlist does not exist.": "La liste de lecture n'existe pas.",
|
||||||
"Could not pull trending pages.": "Impossible de tirer les pages de tendances.",
|
"Could not pull trending pages.": "Impossible de charger les pages de tendances.",
|
||||||
"Hidden field \"challenge\" is a required field": "Champ caché \"contestation\" est un champ obligatoire",
|
"Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field",
|
||||||
"Hidden field \"token\" is a required field": "Champ caché \"jeton\" est un champ obligatoire",
|
"Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field",
|
||||||
"Invalid challenge": "Contestation non valide",
|
"Invalid challenge": "Invalid challenge",
|
||||||
"Invalid token": "Jeton non valide",
|
"Invalid token": "Invalid token",
|
||||||
"Invalid user": "Iutilisateur non valide",
|
"Invalid user": "Invalid user",
|
||||||
"Token is expired, please try again": "Le jeton est expiré, veuillez réessayer",
|
"Token is expired, please try again": "Token is expired, please try again",
|
||||||
"English": "Anglais",
|
"English": "Anglais",
|
||||||
"English (auto-generated)": "Anglais (auto-généré)",
|
"English (auto-generated)": "Anglais (générés automatiquement)",
|
||||||
"Afrikaans": "Afrikaans",
|
"Afrikaans": "Afrikaans",
|
||||||
"Albanian": "Albanais",
|
"Albanian": "Albanais",
|
||||||
"Amharic": "Amharique",
|
"Amharic": "Amharique",
|
||||||
|
@ -258,21 +257,21 @@
|
||||||
"`x` hours": "`x` heures",
|
"`x` hours": "`x` heures",
|
||||||
"`x` minutes": "`x` minutes",
|
"`x` minutes": "`x` minutes",
|
||||||
"`x` seconds": "`x` secondes",
|
"`x` seconds": "`x` secondes",
|
||||||
"Fallback comments: ": "Commentaires de repli: ",
|
"Fallback comments: ": "Commentaires secondaires : ",
|
||||||
"Popular": "Populaire",
|
"Popular": "Populaire",
|
||||||
"Top": "Haut",
|
"Top": "Top",
|
||||||
"About": "Sur",
|
"About": "A Propos",
|
||||||
"Rating: ": "Évaluation: ",
|
"Rating: ": "Évaluation: ",
|
||||||
"Language: ": "Langue: ",
|
"Language: ": "Langue: ",
|
||||||
"Default": "",
|
"Default": "Défaut",
|
||||||
"Music": "",
|
"Music": "Musique",
|
||||||
"Gaming": "",
|
"Gaming": "Jeux Vidéo",
|
||||||
"News": "",
|
"News": "Actualités",
|
||||||
"Movies": "",
|
"Movies": "Films",
|
||||||
"Download": "",
|
"Download": "Télécharger",
|
||||||
"Download as: ": "",
|
"Download as: ": "Télécharger en :",
|
||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||||
"(edited)": "",
|
"(edited)": "(modifié)",
|
||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
|
||||||
"`x` marked it with a ❤": ""
|
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤"
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,8 +277,8 @@
|
||||||
"Movies": "Фильмы",
|
"Movies": "Фильмы",
|
||||||
"Download": "Скачать",
|
"Download": "Скачать",
|
||||||
"Download as: ": "Скачать как: ",
|
"Download as: ": "Скачать как: ",
|
||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||||
"(edited)": "",
|
"(edited)": "(изменено)",
|
||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "Прямая ссылка на YouTube",
|
||||||
"`x` marked it with a ❤": ""
|
"`x` marked it with a ❤": "❤ от автора канала \"`x`\""
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,9 +163,10 @@ before_all do |env|
|
||||||
|
|
||||||
# Invidious users only have SID
|
# Invidious users only have SID
|
||||||
if !env.request.cookies.has_key? "SSID"
|
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)
|
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
|
||||||
|
|
||||||
env.set "challenge", challenge
|
env.set "challenge", challenge
|
||||||
|
@ -177,7 +178,7 @@ before_all do |env|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
begin
|
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)
|
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
|
||||||
env.set "challenge", challenge
|
env.set "challenge", challenge
|
||||||
|
@ -312,7 +313,7 @@ get "/watch" do |env|
|
||||||
end
|
end
|
||||||
|
|
||||||
if watched && !watched.includes? id
|
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
|
end
|
||||||
|
|
||||||
if nojs
|
if nojs
|
||||||
|
@ -818,7 +819,7 @@ post "/login" do |env|
|
||||||
# Prefer Authenticator app and SMS over unsupported protocols
|
# 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
|
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]
|
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]
|
tl = challenge_results[1][2]
|
||||||
|
|
||||||
|
@ -880,7 +881,7 @@ post "/login" do |env|
|
||||||
|
|
||||||
sid = login.cookies["SID"].value
|
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
|
# We are now logged in
|
||||||
|
|
||||||
|
@ -986,7 +987,7 @@ post "/login" do |env|
|
||||||
|
|
||||||
if Crypto::Bcrypt::Password.new(user.password.not_nil!) == password
|
if Crypto::Bcrypt::Password.new(user.password.not_nil!) == password
|
||||||
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
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
|
if Kemal.config.ssl || CONFIG.https_only
|
||||||
secure = true
|
secure = true
|
||||||
|
@ -1024,13 +1025,14 @@ post "/login" do |env|
|
||||||
end
|
end
|
||||||
|
|
||||||
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
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 = user.to_a
|
||||||
|
|
||||||
user_array[5] = user_array[5].to_json
|
user_array[4] = user_array[4].to_json
|
||||||
args = arg_array(user_array)
|
args = arg_array(user_array)
|
||||||
|
|
||||||
PG_DB.exec("INSERT INTO users VALUES (#{args})", 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]}"
|
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
||||||
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
||||||
|
@ -1078,7 +1080,7 @@ get "/signout" do |env|
|
||||||
|
|
||||||
user = env.get("user").as(User)
|
user = env.get("user").as(User)
|
||||||
sid = env.get("sid").as(String)
|
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|
|
env.request.cookies.each do |cookie|
|
||||||
cookie.expires = Time.new(1990, 1, 1)
|
cookie.expires = Time.new(1990, 1, 1)
|
||||||
|
@ -1252,7 +1254,7 @@ get "/mark_watched" do |env|
|
||||||
if user
|
if user
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
if !user.watched.includes? id
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1347,9 +1349,10 @@ get "/subscription_manager" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("locale").as(String)]?
|
||||||
|
|
||||||
user = env.get? "user"
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
referer = get_referer(env, "/")
|
referer = get_referer(env, "/")
|
||||||
|
|
||||||
if !user
|
if !user && !sid
|
||||||
next env.redirect referer
|
next env.redirect referer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1360,7 +1363,7 @@ get "/subscription_manager" do |env|
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
headers["Cookie"] = env.request.headers["Cookie"]
|
headers["Cookie"] = env.request.headers["Cookie"]
|
||||||
|
|
||||||
user = get_user(user.id[0], headers, PG_DB)
|
user, sid = get_user(sid, headers, PG_DB)
|
||||||
end
|
end
|
||||||
|
|
||||||
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
|
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 = env.params.query["format"]?
|
||||||
format ||= "rss"
|
format ||= "rss"
|
||||||
|
|
||||||
subscriptions = [] of InvidiousChannel
|
subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY('{#{user.subscriptions.join(",")}}')", as: InvidiousChannel)
|
||||||
user.subscriptions.each do |ucid|
|
|
||||||
begin
|
|
||||||
subscriptions << get_channel(ucid, PG_DB, false, false)
|
|
||||||
rescue ex
|
|
||||||
next
|
|
||||||
end
|
|
||||||
end
|
|
||||||
subscriptions.sort_by! { |channel| channel.author.downcase }
|
subscriptions.sort_by! { |channel| channel.author.downcase }
|
||||||
|
|
||||||
if action_takeout
|
if action_takeout
|
||||||
|
@ -1756,10 +1752,12 @@ get "/feed/subscriptions" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("locale").as(String)]?
|
||||||
|
|
||||||
user = env.get? "user"
|
user = env.get? "user"
|
||||||
|
sid = env.get? "sid"
|
||||||
referer = get_referer(env)
|
referer = get_referer(env)
|
||||||
|
|
||||||
if user
|
if user
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
|
sid = sid.as(String)
|
||||||
preferences = user.preferences
|
preferences = user.preferences
|
||||||
|
|
||||||
if preferences.unseen_only
|
if preferences.unseen_only
|
||||||
|
@ -1771,7 +1769,7 @@ get "/feed/subscriptions" do |env|
|
||||||
headers["Cookie"] = env.request.headers["Cookie"]
|
headers["Cookie"] = env.request.headers["Cookie"]
|
||||||
|
|
||||||
if !user.password
|
if !user.password
|
||||||
user = get_user(user.id[0], headers, PG_DB)
|
user, sid = get_user(sid, headers, PG_DB)
|
||||||
end
|
end
|
||||||
|
|
||||||
max_results = preferences.max_results
|
max_results = preferences.max_results
|
||||||
|
@ -3033,7 +3031,8 @@ end
|
||||||
ucid = env.params.url["ucid"]
|
ucid = env.params.url["ucid"]
|
||||||
page = env.params.query["page"]?.try &.to_i?
|
page = env.params.query["page"]?.try &.to_i?
|
||||||
page ||= 1
|
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"
|
sort_by ||= "newest"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
|
@ -3438,7 +3437,7 @@ get "/api/v1/mixes/:rdid" do |env|
|
||||||
rdid = env.params.url["rdid"]
|
rdid = env.params.url["rdid"]
|
||||||
|
|
||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
continuation ||= rdid.lchop("RD")
|
continuation ||= rdid.lchop("RD")[0, 11]
|
||||||
|
|
||||||
format = env.params.query["format"]?
|
format = env.params.query["format"]?
|
||||||
format ||= "json"
|
format ||= "json"
|
||||||
|
@ -3662,6 +3661,8 @@ get "/latest_version" do |env|
|
||||||
id = env.params.query["id"]?
|
id = env.params.query["id"]?
|
||||||
itag = env.params.query["itag"]?
|
itag = env.params.query["itag"]?
|
||||||
|
|
||||||
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
local = env.params.query["local"]?
|
local = env.params.query["local"]?
|
||||||
local ||= "false"
|
local ||= "false"
|
||||||
local = local == "true"
|
local = local == "true"
|
||||||
|
@ -3670,7 +3671,7 @@ get "/latest_version" do |env|
|
||||||
halt env, status_code: 400
|
halt env, status_code: 400
|
||||||
end
|
end
|
||||||
|
|
||||||
video = get_video(id, PG_DB, proxies)
|
video = get_video(id, PG_DB, proxies, region: region)
|
||||||
|
|
||||||
fmt_stream = video.fmt_stream(decrypt_function)
|
fmt_stream = video.fmt_stream(decrypt_function)
|
||||||
adaptive_fmts = video.adaptive_fmts(decrypt_function)
|
adaptive_fmts = video.adaptive_fmts(decrypt_function)
|
||||||
|
@ -3944,13 +3945,12 @@ end
|
||||||
error 500 do |env|
|
error 500 do |env|
|
||||||
error_message = <<-END_HTML
|
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
|
||||||
<a href="https://github.com/omarroth/invidious/issues/github.com/omarroth/invidious">
|
<a href="https://github.com/omarroth/invidious/issues">
|
||||||
here
|
here
|
||||||
</a>
|
</a>
|
||||||
or send an email to
|
or send an email to
|
||||||
<a href="mailto:omarroth@protonmail.com">
|
<a href="mailto:omarroth@protonmail.com">
|
||||||
omarroth@protonmail.com
|
omarroth@protonmail.com</a>.
|
||||||
</a>.
|
|
||||||
END_HTML
|
END_HTML
|
||||||
templated "error"
|
templated "error"
|
||||||
end
|
end
|
||||||
|
|
|
@ -260,6 +260,132 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
|
||||||
return url
|
return url
|
||||||
end
|
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)
|
def get_about_info(ucid, locale)
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
|
|
||||||
|
@ -290,7 +416,7 @@ def get_about_info(ucid, locale)
|
||||||
sub_count ||= 0
|
sub_count ||= 0
|
||||||
|
|
||||||
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
|
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
|
# Auto-generated channels
|
||||||
# https://support.google.com/youtube/answer/2579942
|
# https://support.google.com/youtube/answer/2579942
|
||||||
|
|
|
@ -166,7 +166,7 @@ def extract_videos(nodeset, ucid = nil)
|
||||||
videos.map { |video| video.as(SearchVideo) }
|
videos.map { |video| video.as(SearchVideo) }
|
||||||
end
|
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
|
# TODO: Make this a 'common', so it makes more sense to be used here
|
||||||
items = [] of SearchItem
|
items = [] of SearchItem
|
||||||
|
|
||||||
|
@ -175,26 +175,24 @@ def extract_items(nodeset, ucid = nil)
|
||||||
if !anchor
|
if !anchor
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
title = anchor.content.strip
|
||||||
|
id = anchor["href"]
|
||||||
|
|
||||||
if anchor["href"].starts_with? "https://www.googleadservices.com"
|
if anchor["href"].starts_with? "https://www.googleadservices.com"
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
|
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
|
||||||
if !anchor
|
if anchor
|
||||||
author = ""
|
|
||||||
author_id = ""
|
|
||||||
else
|
|
||||||
author = anchor.content.strip
|
author = anchor.content.strip
|
||||||
author_id = anchor["href"].split("/")[-1]
|
author_id = anchor["href"].split("/")[-1]
|
||||||
end
|
end
|
||||||
|
|
||||||
anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
|
author ||= author_name
|
||||||
if !anchor
|
author_id ||= ucid
|
||||||
next
|
|
||||||
end
|
author ||= ""
|
||||||
title = anchor.content.strip
|
author_id ||= ""
|
||||||
id = anchor["href"]
|
|
||||||
|
|
||||||
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
|
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
|
||||||
description_html, description = html_to_content(description_html)
|
description_html, description = html_to_content(description_html)
|
||||||
|
@ -354,3 +352,94 @@ def extract_items(nodeset, ucid = nil)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
end
|
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"]
|
item = item["playlistPanelVideoRenderer"]
|
||||||
|
|
||||||
id = item["videoId"].as_s
|
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
|
author = item["longBylineText"]["runs"][0]["text"].as_s
|
||||||
ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
|
ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
|
||||||
length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
|
length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
|
||||||
|
|
|
@ -161,117 +161,6 @@ def produce_playlist_url(id, index)
|
||||||
return url
|
return url
|
||||||
end
|
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)
|
def fetch_playlist(plid, locale)
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ class User
|
||||||
end
|
end
|
||||||
|
|
||||||
add_mapping({
|
add_mapping({
|
||||||
id: Array(String),
|
|
||||||
updated: Time,
|
updated: Time,
|
||||||
notifications: Array(String),
|
notifications: Array(String),
|
||||||
subscriptions: Array(String),
|
subscriptions: Array(String),
|
||||||
|
@ -126,18 +125,21 @@ class Preferences
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_user(sid, headers, db, refresh = true)
|
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)
|
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
|
||||||
user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
|
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
|
||||||
|
|
||||||
if refresh && Time.now - user.updated > 1.minute
|
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 = user.to_a
|
||||||
|
|
||||||
user_array[5] = user_array[5].to_json
|
user_array[4] = user_array[4].to_json
|
||||||
args = arg_array(user_array)
|
args = arg_array(user_array)
|
||||||
|
|
||||||
db.exec("INSERT INTO users VALUES (#{args}) \
|
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
|
begin
|
||||||
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
||||||
|
@ -149,14 +151,17 @@ def get_user(sid, headers, db, refresh = true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
user = fetch_user(sid, headers, db)
|
user, sid = fetch_user(sid, headers, db)
|
||||||
user_array = user.to_a
|
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)
|
args = arg_array(user.to_a)
|
||||||
|
|
||||||
db.exec("INSERT INTO users VALUES (#{args}) \
|
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
|
begin
|
||||||
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
view_name = "subscriptions_#{sha256(user.email)[0..7]}"
|
||||||
|
@ -168,7 +173,7 @@ def get_user(sid, headers, db, refresh = true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return user
|
return user, sid
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_user(sid, headers, db)
|
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))
|
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)
|
user = User.new(Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
|
||||||
return user
|
return user, sid
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_user(sid, email, password)
|
def create_user(sid, email, password)
|
||||||
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
||||||
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
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
|
end
|
||||||
|
|
||||||
def create_response(user_id, operation, key, db, expire = 6.hours)
|
def create_response(user_id, operation, key, db, expire = 6.hours)
|
||||||
|
|
|
@ -20,7 +20,7 @@ function subscribe(timeouts = 0) {
|
||||||
|
|
||||||
var fallback = subscribe_button.innerHTML;
|
var fallback = subscribe_button.innerHTML;
|
||||||
subscribe_button.onclick = unsubscribe;
|
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() {
|
xhr.onreadystatechange = function() {
|
||||||
if (xhr.readyState == 4) {
|
if (xhr.readyState == 4) {
|
||||||
|
@ -55,7 +55,7 @@ function unsubscribe(timeouts = 0) {
|
||||||
|
|
||||||
var fallback = subscribe_button.innerHTML;
|
var fallback = subscribe_button.innerHTML;
|
||||||
subscribe_button.onclick = subscribe;
|
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() {
|
xhr.onreadystatechange = function() {
|
||||||
if (xhr.readyState == 4) {
|
if (xhr.readyState == 4) {
|
||||||
|
|
Loading…
Reference in New Issue