diff --git a/README.md b/README.md index 8be021c9..20f509aa 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,17 @@ Onion links: [Alternative Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) +## Screenshots + +| Player | Preferences | Subscriptions | +| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| [](screenshots/01_player.png?raw=true) | [](screenshots/02_preferences.png?raw=true) | [](screenshots/03_subscriptions.png?raw=true) | +| [](screenshots/04_description.png?raw=true) | [](screenshots/05_preferences.png?raw=true) | [](screenshots/06_subscriptions.png?raw=true) | + ## 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: @@ -98,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 ``` @@ -107,7 +117,7 @@ $ exit ```bash $ sudo -i -u invidious $ cd invidious -$ shards +$ shards update && shards install $ crystal build src/invidious.cr --release # test compiled binary $ ./invidious # stop with ctrl c @@ -115,6 +125,7 @@ $ exit ``` #### systemd service + ```bash $ sudo cp /home/invidious/invidious/invidious.service /etc/systemd/system/invidious.service $ sudo systemctl enable invidious.service @@ -138,15 +149,17 @@ $ 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 -$ shards +$ shards update && shards install $ 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: @@ -178,16 +191,19 @@ $ ./sentry ``` ## Documentation + [Documentation](https://github.com/omarroth/invidious/wiki) can be found in the wiki. ## Extensions -Extensions for Invidious and for integrating Invidious into other projects [are in the wiki](https://github.com/omarroth/invidious/wiki/Extensions) + +[Extensions](https://github.com/omarroth/invidious/wiki/Extensions) can be found in the wiki, as well as documentation for integrating it into other projects. ## Made with Invidious - [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy. - [CloudTube](https://github.com/cloudrac3r/cadencegq): Website featuring pastebin, image host, and YouTube player - [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. +- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube. ## Contributing diff --git a/config/config.yml b/config/config.yml index f981a398..c6f8420a 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,4 +10,4 @@ db: dbname: invidious full_refresh: false https_only: false -domain: invidio.us +domain: diff --git a/config/migrate-scripts/migrate-db-30e6d29.sh b/config/migrate-scripts/migrate-db-30e6d29.sh deleted file mode 100755 index 259862df..00000000 --- a/config/migrate-scripts/migrate-db-30e6d29.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -psql invidious -c "ALTER TABLE channels ADD COLUMN deleted bool;" -psql invidious -c "UPDATE channels SET deleted = false;" diff --git a/docker/Dockerfile b/docker/Dockerfile index 0197db94..e2d434db 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,7 +9,7 @@ ADD . /invidious WORKDIR /invidious RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \ - shards && \ + shards update && shards install && \ crystal build src/invidious.cr CMD [ "/invidious/invidious" ] diff --git a/docker/entrypoint.postgres.sh b/docker/entrypoint.postgres.sh index 9a258dd6..8f987201 100755 --- a/docker/entrypoint.postgres.sh +++ b/docker/entrypoint.postgres.sh @@ -16,6 +16,7 @@ if [ ! -f /var/lib/postgresql/data/setupFinished ]; then su postgres -c 'psql invidious < config/sql/videos.sql' su postgres -c 'psql invidious < config/sql/channel_videos.sql' su postgres -c 'psql invidious < config/sql/users.sql' + su postgres -c 'psql invidious < config/sql/session_ids.sql' su postgres -c 'psql invidious < config/sql/nonces.sql' touch /var/lib/postgresql/data/setupFinished echo "### invidious database setup finished" diff --git a/locales/ar.json b/locales/ar.json index da7125f3..e8b59c46 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -82,6 +82,14 @@ "Manage subscriptions": "إدارة المشتركين", "Watch history": "سجل المشاهدة", "Delete account": "حذف الحساب", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "حفظ التفضيلات", "Subscription manager": "مدير الإشتراكات", "`x` subscriptions": "`x` مشتركين", @@ -280,5 +288,7 @@ "%A %B %-d, %Y": "", "(edited)": "", "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "" } diff --git a/locales/de.json b/locales/de.json index a23631a0..e40c0b31 100644 --- a/locales/de.json +++ b/locales/de.json @@ -82,6 +82,14 @@ "Manage subscriptions": "Abonnements verwalten", "Watch history": "Verlauf", "Delete account": "Account löschen", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "Einstellungen speichern", "Subscription manager": "Abonnementverwaltung", "`x` subscriptions": "`x` Abonnements", @@ -264,9 +272,9 @@ "`x` hours": "`x` Stunden", "`x` minutes": "`x` Minuten", "`x` seconds": "`x` Sekunden", - "Fallback comments: ": "", + "Fallback comments: ": "Alternative Kommentare: ", "Popular": "Populär", - "Top": "", + "Top": "Top", "About": "Über", "Rating: ": "Bewertung: ", "Language: ": "Sprache: ", @@ -280,5 +288,7 @@ "%A %B %-d, %Y": "", "(edited)": "", "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "" } diff --git a/locales/en-US.json b/locales/en-US.json index 20fc019e..1848ff20 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -80,6 +80,14 @@ "Manage subscriptions": "Manage subscriptions", "Watch history": "Watch history", "Delete account": "Delete account", + "Administrator preferences": "Administrator preferences", + "Default homepage: ": "Default homepage: ", + "Feed menu: ": "Feed menu: ", + "Top enabled? ": "Top enabled? ", + "CAPTCHA enabled? ": "CAPTCHA enabled? ", + "Login enabled? ": "Login enabled? ", + "Registration enabled? ": "Registration enabled? ", + "Report statistics? ": "Report statistics? ", "Save preferences": "Save preferences", "Subscription manager": "Subscription manager", "`x` subscriptions": "`x` subscriptions", @@ -274,5 +282,7 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "Youtube permalink of the comment": "Youtube permalink of the comment", - "`x` marked it with a ❤": "`x` marked it with a ❤" + "`x` marked it with a ❤": "`x` marked it with a ❤", + "Audio mode": "Audio mode", + "Video mode": "Video mode" } diff --git a/locales/eu.json b/locales/eu.json index b0378ea8..8f887f71 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -1,11 +1,11 @@ { - "`x` subscribers": "", - "`x` videos": "", - "LIVE": "", - "Shared `x` ago": "", - "Unsubscribe": "", + "`x` subscribers": "`x` harpidedun", + "`x` videos": "`x` bideo", + "LIVE": "ZUZENEAN", + "Shared `x` ago": "Duela `x` partekatua", + "Unsubscribe": "Harpidetza kendu", "Subscribe": "Harpidetu", - "Login to subscribe to `x`": "", + "Login to subscribe to `x`": "Saioa hasi `x`(e)ra harpidetzeko", "View channel on YouTube": "Ikusi kanala YouTuben", "newest": "berrienak", "oldest": "zaharrenak", @@ -24,22 +24,22 @@ "Import NewPipe data (.zip)": "NewPipeko datuak inportatu (.zip)", "Export": "Esportatu", "Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "", - "Export data as JSON": "", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Harpidetzak OPML bezala esportatu (NewPipe eta FreeTuberako)", + "Export data as JSON": "Datuak JSON bezala esportatu", "Delete account?": "Kontua ezabatu?", "History": "Historia", "Previous page": "Aurreko orria", - "An alternative front-end to YouTube": "", - "JavaScript license information": "", - "source": "", - "Login": "", - "Login/Register": "", - "Login to Google": "", - "User ID:": "", - "Password:": "", - "Time (h:mm:ss):": "", - "Text CAPTCHA": "", - "Image CAPTCHA": "", + "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat", + "JavaScript license information": "JavaScript lizentzia informazioa", + "source": "iturburua", + "Login": "Saioa hasi", + "Login/Register": "Saioa hasi/Izena eman", + "Login to Google": "Googlekin hasi saioa", + "User ID:": "Erabiltzaile IDa:", + "Password:": "Pasahitza:", + "Time (h:mm:ss):": "Denbora (o:mm:ss):", + "Text CAPTCHA": "Testu CAPTCHA", + "Image CAPTCHA": "Irudi CAPTCHA", "Sign In": "", "Register": "", "Email:": "", @@ -80,6 +80,14 @@ "Manage subscriptions": "", "Watch history": "", "Delete account": "", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "", "Subscription manager": "", "`x` subscriptions": "", @@ -274,5 +282,7 @@ "%A %B %-d, %Y": "", "(edited)": "", "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "" } diff --git a/locales/fr.json b/locales/fr.json index e4bb5111..f5c34a6e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,152 +1,159 @@ { - "`x` subscribers": "`x` souscripteurs", + "`x` subscribers": "`x` abonnés", "`x` videos": "`x` vidéos", - "LIVE": "LIVE", + "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 vous 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": "Importer et Exporter les 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?": "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:": "ID 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: ", - "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: ", - "Show related videos? ": "Voir les vidéos liées à ce sujet? ", + "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 souhaitée : ", + "Player volume: ": "Volume du lecteur : ", + "Default comments: ": "Source des Commentaires : ", + "Default captions: ": "Sous-titres principal : ", + "Fallback captions: ": "Sous-titres 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: ", - "published": "publié", - "published - reverse": "publié - reverse", + "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": "publication", + "published - reverse": "publication - 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 non regardée : ", + "Only show unwatched: ": "Afficher uniquement les vidéos non 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": "Importer/exporter les données", "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", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "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: ", - "Whitelisted regions: ": "Régions en liste blanche: ", - "Blacklisted regions: ": "Régions sur liste noire: ", + "Genre: ": "Genre : ", + "License: ": "Licence : ", + "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", + "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères", + "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 +265,23 @@ "`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", - "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 ❤": "" + "Top": "Top", + "About": "A Propos", + "Rating: ": "Évaluation : ", + "Language: ": "Langue : ", + "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 ❤", + "Audio mode": "Mode Audio", + "Video mode": "Mode Vidéo" } diff --git a/locales/it.json b/locales/it.json new file mode 100644 index 00000000..eeae6ed3 --- /dev/null +++ b/locales/it.json @@ -0,0 +1,287 @@ +{ + "`x` subscribers": "`x` iscritti", + "`x` videos": "`x` video", + "LIVE": "IN DIRETTA", + "Shared `x` ago": "Condiviso `x` fa", + "Unsubscribe": "Disiscriviti", + "Subscribe": "Iscriviti", + "Login to subscribe to `x`": "Accedi per iscriverti a `x`", + "View channel on YouTube": "Vedi canale su YouTube", + "newest": "Data di aggiunta (più recente)", + "oldest": "Data di aggiunta (più vecchia)", + "popular": "Tendenze", + "Next page": "Pagina successiva", + "Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?", + "Yes": "Si", + "No": "No", + "Import and Export Data": "Importazione ed esportazione dati", + "Import": "Importa", + "Import Invidious data": "Importa dati Invidious", + "Import YouTube subscriptions": "Importa le iscrizioni da YouTube", + "Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)", + "Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)", + "Export": "Esporta", + "Export subscriptions as OPML": "Esporta gli abbonamenti come OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esporta gli abbonamenti come OPML (per NewPipe e FreeTube)", + "Export data as JSON": "Esporta i dati in formato JSON", + "Delete account?": "Sei sicuro di voler cancellare l'account?", + "History": "Cronologia", + "Previous page": "Pagina precedente", + "An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube", + "JavaScript license information": "Info licenze JavaScript", + "source": "sorgente", + "Login": "Entra", + "Login/Register": "Entra/Registrati", + "Login to Google": "Entra con Google", + "User ID:": "ID utente:", + "Password:": "Password:", + "Time (h:mm:ss):": "Orario (h:mm:ss):", + "Text CAPTCHA": "Testo del CAPTCHA", + "Image CAPTCHA": "Immagine CAPTCHA", + "Sign In": "Entra", + "Register": "Registrati", + "Email:": "Email:", + "Google verification code:": "Codice di verifica Google:", + "Preferences": "Preferenze", + "Player preferences": "Preferenze del riproduttore", + "Always loop: ": "Ripeti sempre: ", + "Autoplay: ": "Riproduzione automatica: ", + "Autoplay next video: ": "Riproduci automaticamente il prossimo video: ", + "Listen by default: ": "Modalità solo audio come predefinita: ", + "Default speed: ": "Velocità di riproduzione predefinita: ", + "Preferred video quality: ": "Preferenza sulla qualità video: ", + "Player volume: ": "Volume di riproduzione: ", + "Default comments: ": "Origine dei commenti: ", + "Default captions: ": "Sottotitoli predefiniti: ", + "Fallback captions: ": "Sottotitoli alternativi: ", + "Show related videos? ": "Mostra video correlati? ", + "Visual preferences": "Preferenze grafiche", + "Dark mode: ": "Tema scuro: ", + "Thin mode: ": "Modalità per connessioni lente: ", + "Subscription preferences": "Preferenze iscrizioni", + "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", + "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ", + "Sort videos by: ": "Ordinare i video per: ", + "published": "data di pubblicazione", + "published - reverse": "data di pubblicazione - decrescente", + "alphabetically": "ordine alfabetico", + "alphabetically - reverse": "ordine alfabetico - decrescente", + "channel name": "nome del canale", + "channel name - reverse": "nome del canale - decrescente", + "Only show latest video from channel: ": "Mostra solo il video più recente del canale: ", + "Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ", + "Only show unwatched: ": "Mostra solo i video non guardati: ", + "Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ", + "Data preferences": "Preferenze dati", + "Clear watch history": "Cancella la cronologia dei video guardati", + "Import/Export data": "Importazione/esportazione dati", + "Manage subscriptions": "Gestisci le iscrizioni", + "Watch history": "Cronologia dei video", + "Delete account": "Elimina l'account", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", + "Save preferences": "Salva le preferenze", + "Subscription manager": "Gestisci le iscrizioni", + "`x` subscriptions": "`x` iscrizioni", + "Import/Export": "Importa/esporta", + "unsubscribe": "disiscriviti", + "Subscriptions": "Iscrizioni", + "`x` unseen notifications": "`x` notifiche non visualizzate", + "search": "Cerca", + "Sign out": "Esci", + "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", + "Source available here.": "Codice sorgente.", + "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", + "Trending": "Tendenze", + "Watch video on Youtube": "Guarda il video su YouTube", + "Genre: ": "Genere: ", + "License: ": "Licenza: ", + "Family friendly? ": "Per tutti? ", + "Wilson score: ": "Punteggio di Wilson: ", + "Engagement: ": "Tasso di coinvolgimento: ", + "Whitelisted regions: ": "Regioni nella lista bianca: ", + "Blacklisted regions: ": "Regioni nella lista nera: ", + "Shared `x`": "Condiviso `x`", + "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", + "View YouTube comments": "Visualizza i commenti da YouTube", + "View more comments on Reddit": "Visualizza più commenti su Reddit", + "View `x` comments": "Visualizza `x` commenti", + "View Reddit comments": "Visualizza i commenti da Reddit", + "Hide replies": "Nascondi le risposte", + "Show replies": "Mostra le risposte", + "Incorrect password": "Password sbagliata", + "Quota exceeded, try again in a few hours": "Limite superato, prova di nuovo fra qualche ora", + "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Impossibile autenticarsi, controlla che l'autenticazione in due passaggi (Authenticator o SMS) sia attiva.", + "Invalid TFA code": "Codice di autenticazione a due fattori non valido", + "Login failed. This may be because two-factor authentication is not enabled on your account.": "Login fallito. L'errore potrebbe essere causato dal fatto che la verifica in due passaggi non è attiva sul tuo account.", + "Invalid answer": "Risposta errata", + "Invalid CAPTCHA": "CAPTCHA errato", + "CAPTCHA is a required field": "Il CAPTCHA è un campo obbligatorio", + "User ID is a required field": "L'ID utente è obbligatorio", + "Password is a required field": "La password è un campo obbligatorio", + "Invalid username or password": "Nome utente o password errati", + "Please sign in using 'Sign in with Google'": "Per favore accedi con \"Entra con Google\"", + "Password cannot be empty": "La password non può essere vuota", + "Password cannot be longer than 55 characters": "La password non può contenere più di 55 caratteri", + "Please sign in": "Per favore, entra", + "Invidious Private Feed for `x`": "Feed privato Invidious per `x`", + "channel:`x`": "canale:`x`", + "Deleted or invalid channel": "Canale cancellato o invalido", + "This channel does not exist.": "Canale inesistente.", + "Could not get channel info.": "Impossibile ottenere le informazioni del canale.", + "Could not fetch comments": "Impossibile recuperare i commenti", + "View `x` replies": "Visualizza `x` risposte", + "`x` ago": "`x` fa", + "Load more": "Carica altro", + "`x` points": "`x` punti", + "Could not create mix.": "Impossibile creare il mix.", + "Playlist is empty": "Playlist vuota", + "Invalid playlist.": "Playlist invalida.", + "Playlist does not exist.": "Playlist inesistente.", + "Could not pull trending pages.": "Impossibile recuperare le tendenze.", + "Hidden field \"challenge\" is a required field": "Il campo nascosto \"challenge\" è obbligatorio", + "Hidden field \"token\" is a required field": "Il campo nascosto \"token\" è obbligatorio", + "Invalid challenge": "Campo \"challenge\" invalido", + "Invalid token": "Campo \"token\" invalido", + "Invalid user": "Utente invalido", + "Token is expired, please try again": "Token scaduto, riprova", + "English": "Inglese", + "English (auto-generated)": "Inglese (generati automaticamente)", + "Afrikaans": "Afrikaans", + "Albanian": "Albanese", + "Amharic": "Amarico", + "Arabic": "Arabo", + "Armenian": "Armeno", + "Azerbaijani": "Azero", + "Bangla": "Bengalese", + "Basque": "Basco", + "Belarusian": "Biellorusso", + "Bosnian": "Bosniaco", + "Bulgarian": "Bulgaro", + "Burmese": "Birmano", + "Catalan": "Catalano", + "Cebuano": "Sugbuanon", + "Chinese (Simplified)": "Cinese semplifiato", + "Chinese (Traditional)": "Cinese tradizionale", + "Corsican": "Corso", + "Croatian": "Croato", + "Czech": "Ceco", + "Danish": "Danese", + "Dutch": "Olandese", + "Esperanto": "Esperanto", + "Estonian": "Estone", + "Filipino": "Filippino", + "Finnish": "Finlandese", + "French": "Francese", + "Galician": "Galiziano", + "Georgian": "Georgiano", + "German": "Tedesco", + "Greek": "Greco", + "Gujarati": "Gujarati", + "Haitian Creole": "Creolo haitiano", + "Hausa": "Lingua hausa", + "Hawaiian": "Hawaiano", + "Hebrew": "Ebreo", + "Hindi": "Hindi", + "Hmong": "Hmong", + "Hungarian": "Ungarese", + "Icelandic": "Islandese", + "Igbo": "Igbo", + "Indonesian": "Indonesiano", + "Irish": "Irlandese", + "Italian": "Italiano", + "Japanese": "Giapponese", + "Javanese": "Giavanese", + "Kannada": "Kannada", + "Kazakh": "Kazaco", + "Khmer": "Khmer", + "Korean": "Coreano", + "Kurdish": "Curdo", + "Kyrgyz": "Kirghize", + "Lao": "Lao", + "Latin": "Latino", + "Latvian": "Lettone", + "Lithuanian": "Lituano", + "Luxembourgish": "Lussemburghese", + "Macedonian": "Macedone", + "Malagasy": "Malgascio", + "Malay": "Malese", + "Malayalam": "Lingua malayalam", + "Maltese": "Maltese", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongolo", + "Nepali": "Nepalese", + "Norwegian": "Norvegese", + "Nyanja": "Nyanja", + "Pashto": "Lingua pashtu", + "Persian": "Persiano", + "Polish": "Polacco", + "Portuguese": "Portoghese", + "Punjabi": "Punjabi", + "Romanian": "Rumeno", + "Russian": "Russo", + "Samoan": "Samoan", + "Scottish Gaelic": "Gaelico scozzese", + "Serbian": "Serbo", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Cingalese", + "Slovak": "Slovacco", + "Slovenian": "Sloveno", + "Somali": "Somalo", + "Southern Sotho": "Sotho del Sud", + "Spanish": "Spagnolo", + "Spanish (Latin America)": "Spagnolo (America latina)", + "Sundanese": "Sudanese", + "Swahili": "Swahili", + "Swedish": "Svedese", + "Tajik": "Tajik", + "Tamil": "Tamil", + "Telugu": "Telugu", + "Thai": "Thaï", + "Turkish": "Turco", + "Ukrainian": "Ucraino", + "Urdu": "Urdu", + "Uzbek": "Uzbeco", + "Vietnamese": "Vietnamese", + "Welsh": "Gallese", + "Western Frisian": "Frisone occidentale", + "Xhosa": "Xhosa", + "Yiddish": "Yiddish", + "Yoruba": "Yoruba", + "Zulu": "Zulu", + "`x` years": "`x` anni", + "`x` months": "`x` mesi", + "`x` weeks": "`x` settimane", + "`x` days": "`x` giorni", + "`x` hours": "`x` ore", + "`x` minutes": "`x` minuti", + "`x` seconds": "`x` secondi", + "Fallback comments: ": "Commenti alternativi: ", + "Popular": "Popolare", + "Top": "Top", + "About": "A proposito", + "Rating: ": "Punteggio: ", + "Language: ": "Lingua: ", + "Default": "Predefinito", + "Music": "Musica", + "Gaming": "Videogiochi", + "News": "Notizie", + "Movies": "Film", + "Download": "Scarica", + "Download as: ": "Scarica come: ", + "%A %B %-d, %Y": "%A %-d %B %Y", + "(edited)": "(modificato)", + "Youtube permalink of the comment": "Link permanente al commento di YouTube", + "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", + "Audio mode": "Modalità audio", + "Video mode": "Modalità video" +} diff --git a/locales/nb_NO.json b/locales/nb_NO.json index dd575416..d299da2f 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -80,6 +80,14 @@ "Manage subscriptions": "Behandle abonnementer", "Watch history": "Visningshistorikk", "Delete account": "Slett konto", + "Administrator preferences": "Administratorinnstillinger", + "Default homepage: ": "Forvalgt hjemmeside: ", + "Feed menu: ": "Flyt-meny: ", + "Top enabled? ": "", + "CAPTCHA enabled? ": "CAPTCHA påskrudd? ", + "Login enabled? ": "Innlogging påskrudd? ", + "Registration enabled? ": "Registrering påskrudd? ", + "Report statistics? ": "", "Save preferences": "Lagre innstillinger", "Subscription manager": "Abonnementsbehandler", "`x` subscriptions": "`x` abonnementer", @@ -264,15 +272,17 @@ "About": "Om", "Rating: ": "Vurdering: ", "Language: ": "Språk: ", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", + "Default": "Forvalg", + "Music": "Musikk", + "Gaming": "Spill", + "News": "Nyheter", + "Movies": "Filmer", + "Download": "Last ned", + "Download as: ": "Last ned som: ", "%A %B %-d, %Y": "", - "(edited)": "", - "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "(edited)": "(redigert)", + "Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet", + "`x` marked it with a ❤": "`x` levnet et ❤", + "Audio mode": "Lydmodus", + "Video mode": "Video-modus" } diff --git a/locales/nl.json b/locales/nl.json index 0969b4cc..2224d326 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -80,6 +80,14 @@ "Manage subscriptions": "Abonnees beheren", "Watch history": "Kijkgeschiedenis", "Delete account": "Account verwijderen", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "Opslaan voorkeuren", "Subscription manager": "Abonnees beheerder", "`x` subscriptions": "`x` abonnees", @@ -274,5 +282,7 @@ "%A %B %-d, %Y": "", "(edited)": "", "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "`x` marked it with a ❤": "", + "Audio mode": "", + "Video mode": "" } diff --git a/locales/pl.json b/locales/pl.json index 3b46559b..d9a21cf1 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -29,7 +29,7 @@ "Delete account?": "Usunąć konto?", "History": "Historia", "Previous page": "Poprzednia strona", - "An alternative front-end to YouTube": "", + "An alternative front-end to YouTube": "Alternatywny front-end dla YouTube", "JavaScript license information": "Informacja o licencji JavaScript", "source": "źródło", "Login": "Zaloguj", @@ -80,6 +80,14 @@ "Manage subscriptions": "Organizuj subskrybcje", "Watch history": "Historia", "Delete account": "Usuń konto", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "Zapisz preferencje", "Subscription manager": "Manager subskrybcji", "`x` subscriptions": "`x` subskrybcji", @@ -145,107 +153,107 @@ "Invalid token": "Niepoprawny token", "Invalid user": "Niepoprawny użytkownik", "Token is expired, please try again": "Token wygasł, spróbuj ponownie", - "English": "", - "English (auto-generated)": "", + "English": "angielski", + "English (auto-generated)": "angielski (automatycznie generowane)", "Afrikaans": "", - "Albanian": "", + "Albanian": "albański", "Amharic": "", - "Arabic": "", + "Arabic": "arabski", "Armenian": "", "Azerbaijani": "", "Bangla": "", "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", + "Belarusian": "białoruski", + "Bosnian": "bośniacki", + "Bulgarian": "bułgarski", + "Burmese": "birmański", + "Catalan": "kataloński", "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", + "Chinese (Simplified)": "chiński (uproszczony)", + "Chinese (Traditional)": "chiński (tradycyjny)", + "Corsican": "korsykański", + "Croatian": "chorwacki", + "Czech": "czeski", + "Danish": "duński", + "Dutch": "holenderski", + "Esperanto": "esperanto", + "Estonian": "estoński", + "Filipino": "filipiński", + "Finnish": "fiński", + "French": "francuski", + "Galician": "galicyjski", + "Georgian": "gruziński", + "German": "niemiecki", + "Greek": "grecki", "Gujarati": "", "Haitian Creole": "", "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", + "Hawaiian": "hawajski", + "Hebrew": "hebrajski", + "Hindi": "hindi", "Hmong": "", - "Hungarian": "", - "Icelandic": "", + "Hungarian": "węgierski", + "Icelandic": "islandzki", "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", + "Indonesian": "indonezyjski", + "Irish": "irlandzki", + "Italian": "włoski", + "Japanese": "japoński", + "Javanese": "jawajski", "Kannada": "", - "Kazakh": "", + "Kazakh": "kazachski", "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", + "Korean": "koreański", + "Kurdish": "kurdyjski", + "Kyrgyz": "kirgiski", "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", + "Latin": "łaciński", + "Latvian": "łotewski", + "Lithuanian": "litewski", + "Luxembourgish": "luksemburski", + "Macedonian": "macedoński", + "Malagasy": "malgaski", + "Malay": "malajski", "Malayalam": "", - "Maltese": "", + "Maltese": "maltański", "Maori": "", "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian": "", + "Mongolian": "mongolski", + "Nepali": "nepalski", + "Norwegian": "norweski", "Nyanja": "", "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", + "Persian": "perski", + "Polish": "polski", + "Portuguese": "portugalski", "Punjabi": "", - "Romanian": "", - "Russian": "", + "Romanian": "rumuński", + "Russian": "rosyjski", "Samoan": "", "Scottish Gaelic": "", - "Serbian": "", + "Serbian": "serbski", "Shona": "", "Sindhi": "", "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", + "Slovak": "słowacki", + "Slovenian": "słoweński", + "Somali": "somalijski", "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", + "Spanish": "hiszpański", + "Spanish (Latin America)": "hiszpański (ameryka łacińska)", "Sundanese": "", "Swahili": "", - "Swedish": "", + "Swedish": "szwedzki", "Tajik": "", "Tamil": "", "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", + "Thai": "tajski", + "Turkish": "turecki", + "Ukrainian": "ukraiński", "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", + "Uzbek": "uzbecki", + "Vietnamese": "wietnamski", + "Welsh": "walijski", "Western Frisian": "", "Xhosa": "", "Yiddish": "", @@ -258,21 +266,23 @@ "`x` hours": "`x` godzin", "`x` minutes": "`x` minut", "`x` seconds": "`x` sekund", - "Fallback comments: ": "", - "Popular": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", + "Fallback comments: ": "Zastępcze komentarze: ", + "Popular": "Popularne", + "Top": "Na czasie", + "About": "Informacje", + "Rating: ": "Ocena: ", + "Language: ": "Język: ", "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", + "Music": "Muzyka", + "Gaming": "Gry", + "News": "Wiadomości", + "Movies": "Filmy", + "Download": "Pobierz", + "Download as: ": "Pobierz jako: ", "%A %B %-d, %Y": "", - "(edited)": "", - "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "(edited)": "(edytowany)", + "Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube", + "`x` marked it with a ❤": "", + "Audio mode": "Tryb audio", + "Video mode": "Tryb wideo" } diff --git a/locales/ru.json b/locales/ru.json index ec62cadb..a840a869 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -82,6 +82,14 @@ "Manage subscriptions": "Управление подписками", "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", + "Administrator preferences": "", + "Default homepage: ": "", + "Feed menu: ": "", + "Top enabled? ": "", + "CAPTCHA enabled? ": "", + "Login enabled? ": "", + "Registration enabled? ": "", + "Report statistics? ": "", "Save preferences": "Сохранить настройки", "Subscription manager": "Менеджер подписок", "`x` subscriptions": "`x` подписок", @@ -159,103 +167,103 @@ "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": "", + "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` месяцев", @@ -277,8 +285,10 @@ "Movies": "Фильмы", "Download": "Скачать", "Download as: ": "Скачать как: ", - "%A %B %-d, %Y": "", - "(edited)": "", - "Youtube permalink of the comment": "", - "`x` marked it with a ❤": "" + "%A %B %-d, %Y": "%-d %B %Y, %A", + "(edited)": "(изменено)", + "Youtube permalink of the comment": "Прямая ссылка на YouTube", + "`x` marked it with a ❤": "❤ от автора канала \"`x`\"", + "Audio mode": "Аудио режим", + "Video mode": "Видео режим" } diff --git a/screenshots/01_player.png b/screenshots/01_player.png new file mode 100644 index 00000000..63e6dbba Binary files /dev/null and b/screenshots/01_player.png differ diff --git a/screenshots/02_preferences.png b/screenshots/02_preferences.png new file mode 100644 index 00000000..1cd29add Binary files /dev/null and b/screenshots/02_preferences.png differ diff --git a/screenshots/03_subscriptions.png b/screenshots/03_subscriptions.png new file mode 100644 index 00000000..f5cfa8c1 Binary files /dev/null and b/screenshots/03_subscriptions.png differ diff --git a/screenshots/04_description.png b/screenshots/04_description.png new file mode 100644 index 00000000..f8ec2564 Binary files /dev/null and b/screenshots/04_description.png differ diff --git a/screenshots/05_preferences.png b/screenshots/05_preferences.png new file mode 100644 index 00000000..dc6d4a42 Binary files /dev/null and b/screenshots/05_preferences.png differ diff --git a/screenshots/06_subscriptions.png b/screenshots/06_subscriptions.png new file mode 100644 index 00000000..0da82f55 Binary files /dev/null and b/screenshots/06_subscriptions.png differ diff --git a/shard.yml b/shard.yml index e0fea2ee..7edab354 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: invidious -version: 0.14.0 +version: 0.14.1 authors: - Omar Roth @@ -9,16 +9,13 @@ targets: main: src/invidious.cr dependencies: - detect_language: - github: detectlanguage/detectlanguage-crystal kemal: github: kemalcr/kemal - commit: afd17fc pg: github: will/crystal-pg sqlite3: github: crystal-lang/crystal-sqlite3 -crystal: 0.27.1 +crystal: 0.27.2 license: AGPLv3 diff --git a/src/invidious.cr b/src/invidious.cr index 43c47bef..558cc304 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -require "detect_language" require "digest/md5" require "file_utils" require "kemal" @@ -29,44 +28,40 @@ require "./invidious/helpers/*" require "./invidious/*" CONFIG = Config.from_yaml(File.read("config/config.yml")) -HMAC_KEY = CONFIG.hmac_key || Random::Secure.random_bytes(32) - -crawl_threads = CONFIG.crawl_threads -channel_threads = CONFIG.channel_threads -feed_threads = CONFIG.feed_threads -video_threads = CONFIG.video_threads +HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +config = CONFIG logger = Invidious::LogHandler.new Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" - parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{crawl_threads})") do |number| + parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{config.crawl_threads})") do |number| begin - crawl_threads = number.to_i + config.crawl_threads = number.to_i rescue ex puts "THREADS must be integer" exit end end - parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{channel_threads})") do |number| + parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number| begin - channel_threads = number.to_i + config.channel_threads = number.to_i rescue ex puts "THREADS must be integer" exit end end - parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{feed_threads})") do |number| + parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{config.feed_threads})") do |number| begin - feed_threads = number.to_i + config.feed_threads = number.to_i rescue ex puts "THREADS must be integer" exit end end - parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{video_threads})") do |number| + parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{config.video_threads})") do |number| begin - video_threads = number.to_i + config.video_threads = number.to_i rescue ex puts "THREADS must be integer" exit @@ -78,23 +73,34 @@ Kemal.config.extra_options do |parser| end end -Kemal::CLI.new +Kemal::CLI.new ARGV -YT_URL = URI.parse("https://www.youtube.com") -REDDIT_URL = URI.parse("https://www.reddit.com") -LOGIN_URL = URI.parse("https://accounts.google.com") +YT_URL = URI.parse("https://www.youtube.com") +REDDIT_URL = URI.parse("https://www.reddit.com") +LOGIN_URL = URI.parse("https://accounts.google.com") +PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") +TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@hotmail.com.json") +CURRENT_COMMIT = `git rev-list HEAD --max-count=1 --abbrev-commit`.strip +CURRENT_VERSION = `git describe --tags $(git rev-list --tags --max-count=1)`.strip +CURRENT_BRANCH = `git status | head -1`.strip LOCALES = { "ar" => load_locale("ar"), "de" => load_locale("de"), "en-US" => load_locale("en-US"), + "eu" => load_locale("eu"), "fr" => load_locale("fr"), + "it" => load_locale("it"), "nb_NO" => load_locale("nb_NO"), "nl" => load_locale("nl"), "pl" => load_locale("pl"), "ru" => load_locale("ru"), } +statistics = { + "error" => "Statistics are not availabile.", +} + decrypt_function = [] of {name: String, value: Int32} spawn do update_decrypt_function do |function| @@ -114,6 +120,25 @@ before_all do |env| end # API Endpoints +get "/api/v1/stats" do |env| + env.response.content_type = "application/json" + + if statistics["error"]? + halt env, status_code: 500, response: statistics.to_json + end + + if !config.statistics_enabled + error_message = {"error" => "Statistics are not enabled."}.to_json + halt env, status_code: 400, response: error_message + end + + if env.params.query["pretty"]? && env.params.query["pretty"] == "1" + statistics.to_pretty_json + else + statistics.to_json + end +end + get "/api/v1/captions/:id" do |env| locale = LOCALES[env.get("locale").as(String)]? @@ -293,7 +318,7 @@ get "/api/v1/insights/:id" do |env| env.response.content_type = "application/json" error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json - halt env, status_code: 503, response: error_message + halt env, status_code: 410, response: error_message client = make_client(YT_URL) headers = HTTP::Headers.new @@ -412,7 +437,7 @@ get "/api/v1/videos/:id" do |env| json.field "description", description json.field "descriptionHtml", video.description json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) json.field "keywords", video.keywords json.field "viewCount", video.views @@ -459,7 +484,7 @@ get "/api/v1/videos/:id" do |env| end if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? - host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) + host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) host_params = env.request.query_params host_params.delete_all("v") @@ -620,7 +645,7 @@ get "/api/v1/trending" do |env| json.field "authorUrl", "/channel/#{video.ucid}" json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) json.field "description", video.description json.field "descriptionHtml", video.description_html json.field "liveNow", video.live_now @@ -655,11 +680,16 @@ get "/api/v1/channels/:ucid" do |env| end page = 1 - begin - videos, count = get_60_videos(ucid, page, auto_generated, sort_by) - rescue ex - error_message = {"error" => ex.message}.to_json - halt env, status_code: 500, response: error_message + if auto_generated + videos = [] of SearchVideo + count = 0 + else + begin + videos, count = get_60_videos(ucid, page, auto_generated, sort_by) + rescue ex + error_message = {"error" => ex.message}.to_json + halt env, status_code: 500, response: error_message + end end client = make_client(YT_URL) @@ -762,6 +792,7 @@ get "/api/v1/channels/:ucid" do |env| json.field "joined", joined.to_unix json.field "paid", paid + json.field "autoGenerated", auto_generated json.field "isFamilyFriendly", is_family_friendly json.field "description", description json.field "descriptionHtml", description_html @@ -794,7 +825,7 @@ get "/api/v1/channels/:ucid" do |env| json.field "viewCount", video.views json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) json.field "lengthSeconds", video.length_seconds json.field "liveNow", video.live_now json.field "paid", video.paid @@ -848,7 +879,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 @@ -891,7 +923,7 @@ end json.field "viewCount", video.views json.field "published", video.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) json.field "lengthSeconds", video.length_seconds json.field "liveNow", video.live_now json.field "paid", video.paid @@ -909,6 +941,127 @@ end end end +["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route| + get route do |env| + locale = LOCALES[env.get("locale").as(String)]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + error_message = {"error" => ex.message}.to_json + halt env, status_code: 500, response: error_message + end + + response = JSON.build do |json| + json.array do + videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + + json.field "authorId", ucid + json.field "authorUrl", "/channel/#{ucid}" + + json.field "videoThumbnails" do + generate_thumbnails(json, video.id) + end + + json.field "description", video.description + json.field "descriptionHtml", video.description_html + + json.field "viewCount", video.views + json.field "published", video.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) + json.field "lengthSeconds", video.length_seconds + json.field "liveNow", video.live_now + json.field "paid", video.paid + json.field "premium", video.premium + end + end + end + end + + if env.params.query["pretty"]? && env.params.query["pretty"] == "1" + JSON.parse(response).to_pretty_json + else + response + end + end +end + +["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route| + get route do |env| + locale = LOCALES[env.get("locale").as(String)]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "last" + + begin + author, ucid, auto_generated = get_about_info(ucid, locale) + rescue ex + error_message = ex.message + halt env, status_code: 500, response: error_message + end + + items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) + + response = JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + json.object do + if item.is_a?(SearchPlaylist) + json.field "title", item.title + json.field "playlistId", item.id + + json.field "author", item.author + json.field "authorId", item.ucid + json.field "authorUrl", "/channel/#{item.ucid}" + + json.field "videoCount", item.video_count + json.field "videos" do + json.array do + item.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "lengthSeconds", video.length_seconds + + json.field "videoThumbnails" do + generate_thumbnails(json, video.id) + end + end + end + end + end + end + end + end + end + end + + json.field "continuation", continuation + end + end + + if env.params.query["pretty"]? && env.params.query["pretty"] == "1" + JSON.parse(response).to_pretty_json + else + response + end + end +end + get "/api/v1/channels/search/:ucid" do |env| locale = LOCALES[env.get("locale").as(String)]? @@ -946,7 +1099,7 @@ get "/api/v1/channels/search/:ucid" do |env| json.field "viewCount", item.views json.field "published", item.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published, locale)) json.field "lengthSeconds", item.length_seconds json.field "liveNow", item.live_now json.field "paid", item.paid @@ -1031,7 +1184,7 @@ get "/api/v1/search" do |env| date = env.params.query["date"]?.try &.downcase date ||= "" - duration = env.params.query["date"]?.try &.downcase + duration = env.params.query["duration"]?.try &.downcase duration ||= "" features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } @@ -1075,7 +1228,7 @@ get "/api/v1/search" do |env| json.field "viewCount", item.views json.field "published", item.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published, locale)) json.field "lengthSeconds", item.length_seconds json.field "liveNow", item.live_now json.field "paid", item.paid @@ -1253,7 +1406,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" @@ -1355,8 +1508,8 @@ get "/api/manifest/dash/id/:id" do |env| halt env, status_code: 403 end - if video.info["dashmpd"]? - manifest = client.get(video.info["dashmpd"]).body + if dashmpd = video.player_response["streamingData"]["dashManifestUrl"]?.try &.as_s + manifest = client.get(dashmpd).body manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| url = baseurl.lchop("") @@ -1445,7 +1598,7 @@ get "/api/manifest/hls_variant/*" do |env| env.response.content_type = "application/x-mpegURL" env.response.headers.add("Access-Control-Allow-Origin", "*") - host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) + host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) manifest = manifest.body manifest.gsub("https://www.youtube.com", host_url) @@ -1459,7 +1612,7 @@ get "/api/manifest/hls_playlist/*" do |env| halt env, status_code: manifest.status_code end - host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain) + host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) manifest = manifest.body.gsub("https://www.youtube.com", host_url) manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url) @@ -1474,12 +1627,20 @@ end # YouTube /videoplayback links expire after 6 hours, # so we have a mechanism here to redirect to the latest version get "/latest_version" do |env| - id = env.params.query["id"]? - itag = env.params.query["itag"]? + if env.params.query["download_widget"]? + download_widget = JSON.parse(env.params.query["download_widget"]) + id = download_widget["id"].as_s + itag = download_widget["itag"].as_s + title = download_widget["title"].as_s + local = "true" + end + + id ||= env.params.query["id"]? + itag ||= env.params.query["itag"]? region = env.params.query["region"]? - local = env.params.query["local"]? + local ||= env.params.query["local"]? local ||= "false" local = local == "true" @@ -1504,6 +1665,10 @@ get "/latest_version" do |env| url = URI.parse(url).full_path.not_nil! end + if title + url += "&title=#{title}" + end + env.redirect url end @@ -1565,7 +1730,7 @@ end get "/videoplayback" do |env| query_params = env.params.query - fvip = query_params["fvip"] + fvip = query_params["fvip"]? || "3" mn = query_params["mn"].split(",")[-1] host = "https://r#{fvip}---#{mn}.googlevideo.com" url = "/videoplayback?#{query_params.to_s}" @@ -1601,13 +1766,18 @@ get "/videoplayback" do |env| end if response.status_code >= 400 - halt env, status_code: 403 + halt env, status_code: response.status_code end client = make_client(URI.parse(host), proxies, region) client.get(url, headers) do |response| env.response.status_code = response.status_code + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.escape(title)}\"; filename*=UTF-8''#{URI.escape(title)}" + end + response.headers.each do |key, value| env.response.headers[key] = value end diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index ccaf2487..bb548045 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -1,9 +1,10 @@ class InvidiousChannel add_mapping({ - id: String, - author: String, - updated: Time, - deleted: Bool, + id: String, + author: String, + updated: Time, + deleted: Bool, + subscribed: Time?, }) end @@ -15,10 +16,7 @@ class ChannelVideo updated: Time, ucid: String, author: String, - length_seconds: { - type: Int32, - default: 0, - }, + length_seconds: {type: Int32, default: 0}, }) end @@ -50,13 +48,11 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma end def get_channel(id, db, refresh = true, pull_all_videos = true) - client = make_client(YT_URL) - if db.query_one?("SELECT EXISTS (SELECT true FROM channels WHERE id = $1)", id, as: Bool) channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) if refresh && Time.now - channel.updated > 10.minutes - channel = fetch_channel(id, client, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -64,7 +60,7 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", channel_array) end else - channel = fetch_channel(id, client, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) @@ -74,7 +70,9 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) return channel end -def fetch_channel(ucid, client, db, pull_all_videos = true, locale = nil) +def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) + client = make_client(YT_URL) + rss = client.get("/feeds/videos.xml?channel_id=#{ucid}").body rss = XML.parse_html(rss) @@ -188,11 +186,88 @@ def fetch_channel(ucid, client, db, pull_all_videos = true, locale = nil) db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid) end - channel = InvidiousChannel.new(ucid, author, Time.now, false) + channel = InvidiousChannel.new(ucid, author, Time.now, false, nil) return channel end +def subscribe_pubsub(ucid, key, config) + client = make_client(PUBSUB_URL) + time = Time.now.to_unix.to_s + + host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) + + body = { + "hub.callback" => "#{host_url}/feed/webhook/#{time}:#{OpenSSL::HMAC.hexdigest(:sha1, key, time)}", + "hub.topic" => "https://www.youtube.com/feeds/videos.xml?channel_id=#{ucid}", + "hub.verify" => "async", + "hub.mode" => "subscribe", + "hub.lease_seconds" => "432000", + "hub.secret" => key.to_s, + } + + return client.post("/subscribe", form: body) +end + +def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) + client = make_client(YT_URL) + + if continuation + url = produce_channel_playlists_url(ucid, continuation, sort_by, auto_generated) + + response = client.get(url) + json = JSON.parse(response.body) + + if json["load_more_widget_html"].as_s.empty? + return [] of SearchItem, nil + end + + continuation = XML.parse_html(json["load_more_widget_html"].as_s) + continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href])) + if continuation + continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated) + end + + html = XML.parse_html(json["content_html"].as_s) + nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) + else + url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list" + + if auto_generated + url += "&view=50" + else + url += "&view=1" + end + + case sort_by + when "last", "last_added" + # + when "oldest", "oldest_created" + url += "&sort=da" + when "newest", "newest_created" + url += "&sort=dd" + end + + response = client.get(url) + html = XML.parse_html(response.body) + + continuation = html.xpath_node(%q(//button[@data-uix-load-more-href])) + if continuation + continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated) + end + + nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")])) + end + + if auto_generated + items = extract_shelf_items(nodeset, ucid, author) + else + items = extract_items(nodeset, ucid, author) + end + + return items, continuation +end + def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest") if auto_generated seed = Time.unix(1525757349) @@ -260,6 +335,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 +491,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 @@ -334,3 +535,21 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest") return videos, count end + +def get_latest_videos(ucid) + client = make_client(YT_URL) + videos = [] of SearchVideo + + url = produce_channel_videos_url(ucid, 0) + response = client.get(url) + json = JSON.parse(response.body) + + if json["content_html"]? && !json["content_html"].as_s.empty? + document = XML.parse_html(json["content_html"].as_s) + nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])) + + videos = extract_videos(nodeset, ucid) + end + + return videos +end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5164117e..c714dd56 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -184,7 +184,7 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, region) json.field "content", content json.field "contentHtml", content_html json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published)) + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "likeCount", node_comment["likeCount"] json.field "commentId", node_comment["commentId"] json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] @@ -252,7 +252,7 @@ end def fetch_reddit_comments(id) client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidio.us:v0.14.0 (by /u/omarroth)"} + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"} query = "(url:3D#{id}%20OR%20url:#{id})%20(site:youtube.com%20OR%20site:youtu.be)" search_results = client.get("/search.json?q=#{query}", headers) @@ -310,7 +310,7 @@ def template_youtube_comments(comments, locale) #{child["author"]}

#{child["contentHtml"]}

- #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64)))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} + #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} | [YT] | @@ -324,7 +324,7 @@ def template_youtube_comments(comments, locale)
-
🖤
+
@@ -375,7 +375,7 @@ def template_reddit_comments(root, locale) [ - ] #{author} #{translate(locale, "`x` points", number_with_separator(score))} - #{translate(locale, "`x` ago", recode_date(child.created_utc))} + #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}

#{body_html} diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 942757c3..3574e5cc 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -1,9 +1,9 @@ class Config YAML.mapping({ + video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional) crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page) channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions) feed_threads: Int32, # Number of threads to use for updating feeds - video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional) db: NamedTuple( # Database configuration user: String, password: String, @@ -11,11 +11,19 @@ user: String, port: Int32, dbname: String, ), - dl_api_key: String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional - https_only: Bool?, # Used to tell Invidious it is behind a proxy, so links to resources should be https:// - hmac_key: String?, # HMAC signing key for CSRF tokens - full_refresh: Bool, # Used for crawling channels: threads should check all videos uploaded by a channel - domain: String, # Domain to be used for links to resources on the site where an absolute URL is required + full_refresh: Bool, # Used for crawling channels: threads should check all videos uploaded by a channel + https_only: Bool?, # Used to tell Invidious it is behind a proxy, so links to resources should be https:// + hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions + domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required + use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + default_home: {type: String, default: "Top"}, + feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending"]}, + top_enabled: {type: Bool, default: true}, + captcha_enabled: {type: Bool, default: true}, + login_enabled: {type: Bool, default: true}, + registration_enabled: {type: Bool, default: true}, + statistics_enabled: {type: Bool, default: false}, + admins: {type: Array(String), default: [] of String}, }) end @@ -66,7 +74,7 @@ class DenyFrame < Kemal::Handler end end -def rank_videos(db, n, filter, url) +def rank_videos(db, n) top = [] of {Float64, String} db.query("SELECT id, wilson_score, published FROM videos WHERE views > 5000 ORDER BY published DESC LIMIT 1000") do |rs| @@ -87,41 +95,7 @@ def rank_videos(db, n, filter, url) top.reverse! top = top.map { |a, b| b } - if filter - language_list = [] of String - top.each do |id| - if language_list.size == n - break - else - client = make_client(url) - begin - video = get_video(id, db) - rescue ex - next - end - - if video.language - language = video.language - else - description = XML.parse(video.description) - content = [video.title, description.content].join(" ") - content = content[0, 10000] - - results = DetectLanguage.detect(content) - language = results[0].language - - db.exec("UPDATE videos SET language = $1 WHERE id = $2", language, id) - end - - if language == "en" - language_list << id - end - end - end - return language_list - else - return top[0..n - 1] - end + return top[0..n - 1] end def login_req(login_form, f_req) @@ -166,29 +140,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 +152,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 +326,94 @@ def extract_items(nodeset, ucid = nil) return items end + +def extract_shelf_items(nodeset, ucid = nil, author_name = nil) + items = [] of SearchPlaylist + + nodeset.each do |shelf| + shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")])) + + if !shelf_anchor + next + end + + title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")])) + if title + title = title.content.strip + end + title ||= "" + + id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"] + if !id + next + end + + is_playlist = false + videos = [] of SearchPlaylistVideo + + shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node| + type = child_node.xpath_node(%q(./div)) + if !type + next + end + + case type["class"] + when .includes? "yt-lockup-video" + is_playlist = true + + anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a)) + if anchor + video_title = anchor.content.strip + video_id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"] + end + video_title ||= "" + video_id ||= "" + + anchor = child_node.xpath_node(%q(.//span[@class="video-time"])) + if anchor + length_seconds = decode_length_seconds(anchor.content) + end + length_seconds ||= 0 + + videos << SearchPlaylistVideo.new( + video_title, + video_id, + length_seconds + ) + when .includes? "yt-lockup-playlist" + anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a)) + if anchor + playlist_title = anchor.content.strip + params = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!) + plid = params["list"] + end + playlist_title ||= "" + plid ||= "" + + items << SearchPlaylist.new( + playlist_title, + plid, + author_name, + ucid, + 50, + Array(SearchPlaylistVideo).new + ) + end + end + + if is_playlist + plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"] + + items << SearchPlaylist.new( + title, + plid, + author_name, + ucid, + videos.size, + videos + ) + end + end + + return items +end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ef2e35dd..5ccc1009 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -136,31 +136,26 @@ def decode_date(string : String) return Time.now - delta end -def recode_date(time : Time) +def recode_date(time : Time, locale) span = Time.now - time if span.total_days > 365.0 - span = {span.total_days / 365, "year"} + span = translate(locale, "`x` years", (span.total_days.to_i / 365).to_s) elsif span.total_days > 30.0 - span = {span.total_days / 30, "month"} + span = translate(locale, "`x` months", (span.total_days.to_i / 30).to_s) elsif span.total_days > 7.0 - span = {span.total_days / 7, "week"} + span = translate(locale, "`x` weeks", (span.total_days.to_i / 7).to_s) elsif span.total_hours > 24.0 - span = {span.total_days, "day"} + span = translate(locale, "`x` days", (span.total_days.to_i).to_s) elsif span.total_minutes > 60.0 - span = {span.total_hours, "hour"} + span = translate(locale, "`x` hours", (span.total_hours.to_i).to_s) elsif span.total_seconds > 60.0 - span = {span.total_minutes, "minute"} + span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s) else - span = {span.total_seconds, "second"} + span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s) end - span = {span[0].to_i, span[1]} - if span[0] > 1 - span = {span[0], span[1] + "s"} - end - - return span.join(" ") + return span end def number_with_separator(number) @@ -205,7 +200,12 @@ def make_host_url(ssl, host) scheme = "http://" end - return "#{scheme}#{host}" + if host + host = host.lchop(".") + return "#{scheme}#{host}" + else + return "" + end end def get_referer(env, fallback = "/") diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index 04816efd..49745aba 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -55,7 +55,7 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false) active_channel = Channel(Bool).new loop do - db.query("SELECT id FROM channels WHERE deleted = false ORDER BY updated") do |rs| + db.query("SELECT id FROM channels ORDER BY updated") do |rs| rs.each do id = rs.read(String) @@ -68,13 +68,12 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false) active_threads += 1 spawn do begin - client = make_client(YT_URL) - channel = fetch_channel(id, client, db, full_refresh) + channel = fetch_channel(id, db, full_refresh) - db.exec("UPDATE channels SET updated = $1, author = $2 WHERE id = $3", Time.now, channel.author, id) + db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.now, channel.author, id) rescue ex if ex.message == "Deleted or invalid channel" - db.exec("UPDATE channels SET deleted = true WHERE id = $1", id) + db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.now, id) end logger.write("#{id} : #{ex.message}\n") end @@ -132,7 +131,16 @@ def refresh_feeds(db, logger, max_threads = 1) begin db.exec("REFRESH MATERIALIZED VIEW #{view_name}") rescue ex - logger.write("REFRESH #{email} : #{ex.message}\n") + # Create view if it doesn't exist + if ex.message.try &.ends_with? "does not exist" + db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ + SELECT * FROM channel_videos WHERE \ + ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \ + ORDER BY published DESC;") + logger.write("CREATE #{view_name}") + else + logger.write("REFRESH #{email} : #{ex.message}\n") + end end active_channel.send(true) @@ -145,19 +153,30 @@ def refresh_feeds(db, logger, max_threads = 1) max_channel.send(max_threads) end -def pull_top_videos(config, db) - if config.dl_api_key - DetectLanguage.configure do |dl_config| - dl_config.api_key = config.dl_api_key.not_nil! +def subscribe_to_feeds(db, logger, key, config) + if config.use_pubsub_feeds + spawn do + loop do + db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs| + ucid = rs.read(String) + response = subscribe_pubsub(ucid, key, config) + + if response.status_code >= 400 + logger.write("#{ucid} : #{response.body}\n") + end + end + + sleep 1.minute + Fiber.yield + end end - filter = true end +end - filter ||= false - +def pull_top_videos(config, db) loop do begin - top = rank_videos(db, 40, filter, YT_URL) + top = rank_videos(db, 40) rescue ex next end @@ -185,11 +204,11 @@ end def pull_popular_videos(db) loop do - subscriptions = PG_DB.query_all("SELECT channel FROM \ + subscriptions = db.query_all("SELECT channel FROM \ (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \ GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String) - videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM \ + videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM \ channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \ ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index a56f468a..011c5722 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -43,8 +43,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) mix_title = playlist["title"].as_s contents = playlist["contents"].as_a - until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id - contents.shift + if contents.map { |video| video["playlistPanelVideoRenderer"]["videoId"] }.includes? video_id + until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id + contents.shift + end end videos = [] of MixVideo @@ -52,7 +54,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) @@ -94,7 +99,10 @@ def template_mix(mix) html += <<-END_HTML
  • - +
    + +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    +

    #{video["title"]}

    #{video["author"]} diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 220a0ef7..9f844ce6 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -161,117 +161,6 @@ def produce_playlist_url(id, index) return url end -def produce_channel_playlists_url(ucid, cursor, sort = "newest") - cursor = Base64.urlsafe_encode(cursor, false) - - meta = IO::Memory.new - meta.write(Bytes[0x12, 0x09]) - meta.print("playlists") - - # TODO: Look at 0x01, 0x00 - case sort - when "oldest", "oldest_created" - meta.write(Bytes[0x18, 0x02]) - when "newest", "newest_created" - meta.write(Bytes[0x18, 0x03]) - when "last", "last_added" - meta.write(Bytes[0x18, 0x04]) - end - - meta.write(Bytes[0x20, 0x01]) - meta.write(Bytes[0x30, 0x02]) - meta.write(Bytes[0x38, 0x01]) - meta.write(Bytes[0x60, 0x01]) - meta.write(Bytes[0x6a, 0x00]) - - meta.write(Bytes[0x7a, cursor.size]) - meta.print(cursor) - - meta.write(Bytes[0xb8, 0x01, 0x00]) - - meta.rewind - meta = Base64.urlsafe_encode(meta.to_slice) - meta = URI.escape(meta) - - continuation = IO::Memory.new - continuation.write(Bytes[0x12, ucid.size]) - continuation.print(ucid) - - continuation.write(Bytes[0x1a]) - continuation.write(write_var_int(meta.size)) - continuation.print(meta) - - continuation.rewind - continuation = continuation.gets_to_end - - wrapper = IO::Memory.new - wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]) - wrapper.write(write_var_int(continuation.size)) - wrapper.print(continuation) - wrapper.rewind - - wrapper = Base64.urlsafe_encode(wrapper.to_slice) - wrapper = URI.escape(wrapper) - - url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en" - - return url -end - -def extract_channel_playlists_cursor(url) - wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"] - - wrapper = URI.unescape(wrapper) - wrapper = Base64.decode(wrapper) - - # 0xe2 0xa9 0x85 0xb2 0x02 - wrapper += 5 - - continuation_size = read_var_int(wrapper[0, 4]) - wrapper += write_var_int(continuation_size).size - continuation = wrapper[0, continuation_size] - - # 0x12 - continuation += 1 - ucid_size = continuation[0] - continuation += 1 - ucid = continuation[0, ucid_size] - continuation += ucid_size - - # 0x1a - continuation += 1 - meta_size = read_var_int(continuation[0, 4]) - continuation += write_var_int(meta_size).size - meta = continuation[0, meta_size] - continuation += meta_size - - meta = String.new(meta) - meta = URI.unescape(meta) - meta = Base64.decode(meta) - - # 0x12 0x09 playlists - meta += 11 - - until meta[0] == 0x7a - tag = read_var_int(meta[0, 4]) - meta += write_var_int(tag).size - value = meta[0] - meta += 1 - end - - # 0x7a - meta += 1 - cursor_size = meta[0] - meta += 1 - cursor = meta[0, cursor_size] - - cursor = String.new(cursor) - cursor = URI.unescape(cursor) - cursor = Base64.decode_string(cursor) - - return cursor -end - def fetch_playlist(plid, locale) client = make_client(YT_URL) @@ -345,7 +234,10 @@ def template_playlist(playlist) html += <<-END_HTML

  • - +
    + +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    +

    #{video["title"]}

    #{video["author"]} diff --git a/src/invidious/search.cr b/src/invidious/search.cr index ce29abf2..ec97cf85 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -188,7 +188,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte end end - if body.size > 0 + if !body.empty? token = head + "\x12" + body.size.unsafe_chr + body else token = head diff --git a/src/invidious/signatures.cr b/src/invidious/signatures.cr index b2ed89d2..8b760398 100644 --- a/src/invidious/signatures.cr +++ b/src/invidious/signatures.cr @@ -39,7 +39,12 @@ def fetch_decrypt_function(id = "CvFH_6DNRCY") return decrypt_function end -def decrypt_signature(a, code) +def decrypt_signature(fmt, code) + if !fmt["s"]? + return "" + end + + a = fmt["s"] a = a.split("") code.each do |item| @@ -53,7 +58,8 @@ def decrypt_signature(a, code) end end - return a.join("") + signature = a.join("") + return "&#{fmt["sp"]?}=#{signature}" end def splice(a, b) diff --git a/src/invidious/users.cr b/src/invidious/users.cr index d45c5af4..42468228 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -12,7 +12,6 @@ class User end add_mapping({ - id: Array(String), updated: Time, notifications: Array(String), subscriptions: Array(String), @@ -126,49 +125,55 @@ 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]}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ + db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ SELECT * FROM channel_videos WHERE \ - ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \ + ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ORDER BY published DESC;") rescue ex 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]}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ + db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ SELECT * FROM channel_videos WHERE \ - ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \ + ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \ ORDER BY published DESC;") rescue ex 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) @@ -242,7 +247,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale) raise translate(locale, "Invalid challenge") end - challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge) + challenge = OpenSSL::HMAC.digest(:sha256, key, challenge) challenge = Base64.urlsafe_encode(challenge) if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 2045dd2c..1e4679fc 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -263,7 +263,7 @@ class Video end def keywords - keywords = self.player_response["videoDetails"]["keywords"]?.try &.as_a + keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a keywords ||= [] of String return keywords @@ -271,9 +271,51 @@ class Video def fmt_stream(decrypt_function) streams = [] of HTTP::Params - self.info["url_encoded_fmt_stream_map"].split(",") do |string| - if !string.empty? - streams << HTTP::Params.parse(string) + + if fmt_streams = self.player_response["streamingData"]?.try &.["formats"]? + fmt_streams.as_a.each do |fmt_stream| + if !fmt_stream.as_h? + next + end + + fmt = {} of String => String + + fmt["lmt"] = fmt_stream["lastModified"]?.try &.as_s || "0" + fmt["projection_type"] = "1" + fmt["type"] = fmt_stream["mimeType"].as_s + fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0" + fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0" + fmt["itag"] = fmt_stream["itag"].as_i.to_s + fmt["url"] = fmt_stream["url"].as_s + fmt["quality"] = fmt_stream["quality"].as_s + + if fmt_stream["width"]? + fmt["size"] = "#{fmt_stream["width"]}x#{fmt_stream["height"]}" + fmt["height"] = fmt_stream["height"].as_i.to_s + end + + if fmt_stream["fps"]? + fmt["fps"] = fmt_stream["fps"].as_i.to_s + end + + if fmt_stream["qualityLabel"]? + fmt["quality_label"] = fmt_stream["qualityLabel"].as_s + end + + params = HTTP::Params.new + fmt.each do |key, value| + params[key] = value + end + + streams << params + end + + streams.sort_by! { |stream| stream["height"].to_i }.reverse! + elsif fmt_stream = self.info["url_encoded_fmt_stream_map"]? + fmt_stream.split(",").each do |string| + if !string.empty? + streams << HTTP::Params.parse(string) + end end end @@ -286,10 +328,8 @@ class Video end end - if streams[0]? && streams[0]["s"]? - streams.each do |fmt| - fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function) - end + streams.each do |fmt| + fmt["url"] += decrypt_signature(fmt, decrypt_function) end return streams @@ -298,80 +338,54 @@ class Video def adaptive_fmts(decrypt_function) adaptive_fmts = [] of HTTP::Params - if self.info.has_key?("adaptive_fmts") - self.info["adaptive_fmts"].split(",") do |string| - adaptive_fmts << HTTP::Params.parse(string) - end - elsif self.info.has_key?("dashmpd") - client = make_client(YT_URL) - response = client.get(self.info["dashmpd"]) - document = XML.parse_html(response.body) - - document.xpath_nodes(%q(//adaptationset)).each do |adaptation_set| - mime_type = adaptation_set["mimetype"] - - document.xpath_nodes(%q(.//representation)).each do |representation| - codecs = representation["codecs"] - itag = representation["id"] - bandwidth = representation["bandwidth"] - url = representation.xpath_node(%q(.//baseurl)).not_nil!.content - - clen = url.match(/clen\/(?\d+)/).try &.["clen"] - clen ||= "0" - lmt = url.match(/lmt\/(?\d+)/).try &.["lmt"] - lmt ||= "#{((Time.now + 1.hour).to_unix_f.to_f64 * 1000000).to_i64}" - - segment_list = representation.xpath_node(%q(.//segmentlist)).not_nil! - init = segment_list.xpath_node(%q(.//initialization)) - - # TODO: Replace with sane defaults when byteranges are absent - if init && !init["sourceurl"].starts_with? "sq" - init = init["sourceurl"].lchop("range/") - - index = segment_list.xpath_node(%q(.//segmenturl)).not_nil!["media"] - index = index.lchop("range/") - index = "#{init.split("-")[1].to_i + 1}-#{index.split("-")[0].to_i}" - else - init = "0-0" - index = "1-1" - end - - params = { - "type" => ["#{mime_type}; codecs=\"#{codecs}\""], - "url" => [url], - "projection_type" => ["1"], - "index" => [index], - "init" => [init], - "xtags" => [] of String, - "lmt" => [lmt], - "clen" => [clen], - "bitrate" => [bandwidth], - "itag" => [itag], - } - - if mime_type == "video/mp4" - width = representation["width"]? - height = representation["height"]? - fps = representation["framerate"]? - - metadata = itag_to_metadata?(itag) - if metadata - width ||= metadata["width"]? - height ||= metadata["height"]? - fps ||= metadata["fps"]? - end - - if width && height - params["size"] = ["#{width}x#{height}"] - end - - if width - params["quality_label"] = ["#{height}p"] - end - end - - adaptive_fmts << HTTP::Params.new(params) + if fmts = self.player_response["streamingData"]?.try &.["adaptiveFormats"]? + fmts.as_a.each do |adaptive_fmt| + if !adaptive_fmt.as_h? + next end + + fmt = {} of String => String + + if init = adaptive_fmt["initRange"]? + fmt["init"] = "#{init["start"]}-#{init["end"]}" + end + fmt["init"] ||= "0-0" + + fmt["lmt"] = adaptive_fmt["lastModified"]?.try &.as_s || "0" + fmt["projection_type"] = "1" + fmt["type"] = adaptive_fmt["mimeType"].as_s + fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0" + fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0" + fmt["itag"] = adaptive_fmt["itag"].as_i.to_s + fmt["url"] = adaptive_fmt["url"].as_s + + if index = adaptive_fmt["indexRange"]? + fmt["index"] = "#{index["start"]}-#{index["end"]}" + end + fmt["index"] ||= "0-0" + + if adaptive_fmt["width"]? + fmt["size"] = "#{adaptive_fmt["width"]}x#{adaptive_fmt["height"]}" + end + + if adaptive_fmt["fps"]? + fmt["fps"] = adaptive_fmt["fps"].as_i.to_s + end + + if adaptive_fmt["qualityLabel"]? + fmt["quality_label"] = adaptive_fmt["qualityLabel"].as_s + end + + params = HTTP::Params.new + fmt.each do |key, value| + params[key] = value + end + + adaptive_fmts << params + end + elsif fmts = self.info["adaptive_fmts"]? + fmts.split(",") do |string| + adaptive_fmts << HTTP::Params.parse(string) end end @@ -381,23 +395,21 @@ class Video end end - if adaptive_fmts[0]? && adaptive_fmts[0]["s"]? - adaptive_fmts.each do |fmt| - fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function) - end + adaptive_fmts.each do |fmt| + fmt["url"] += decrypt_signature(fmt, decrypt_function) end return adaptive_fmts end def video_streams(adaptive_fmts) - video_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("video") ? s : nil } + video_streams = adaptive_fmts.select { |s| s["type"].starts_with? "video" } return video_streams end def audio_streams(adaptive_fmts) - audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio") ? s : nil } + audio_streams = adaptive_fmts.select { |s| s["type"].starts_with? "audio" } audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse! audio_streams.each do |stream| stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s @@ -624,7 +636,10 @@ def fetch_video(id, proxies, region) # Try to pull streams from embed URL if info["reason"]? - embed_info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&ps=default&eurl=&gl=US&hl=en&disable_polymer=1").body) + embed_page = client.get("/embed/#{id}").body + sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? + sts ||= "" + embed_info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&disable_polymer=1&sts=#{sts}").body) if !embed_info["reason"]? embed_info.each do |key, value|