Merge branch 'master' into api-only

This commit is contained in:
Omar Roth 2019-03-03 23:22:34 -06:00
commit 51158c8c45
34 changed files with 1513 additions and 730 deletions

View File

@ -34,8 +34,17 @@ Onion links:
[Alternative Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) [Alternative Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances)
## Screenshots
| Player | Preferences | Subscriptions |
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| [<img src="screenshots/01_player.png?raw=true" height="140" width="280">](screenshots/01_player.png?raw=true) | [<img src="screenshots/02_preferences.png?raw=true" height="140" width="280">](screenshots/02_preferences.png?raw=true) | [<img src="screenshots/03_subscriptions.png?raw=true" height="140" width="280">](screenshots/03_subscriptions.png?raw=true) |
| [<img src="screenshots/04_description.png?raw=true" height="140" width="280">](screenshots/04_description.png?raw=true) | [<img src="screenshots/05_preferences.png?raw=true" height="140" width="280">](screenshots/05_preferences.png?raw=true) | [<img src="screenshots/06_subscriptions.png?raw=true" height="140" width="280">](screenshots/06_subscriptions.png?raw=true) |
## Installation ## Installation
See [Invidious-Updater](https://github.com/tmiland/Invidious-Updater) for a self-contained script that can automatically install and update Invidious.
### Docker: ### Docker:
#### Build and start cluster: #### Build and start cluster:
@ -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/videos.sql
$ psql invidious < /home/invidious/invidious/config/sql/channel_videos.sql $ psql invidious < /home/invidious/invidious/config/sql/channel_videos.sql
$ psql invidious < /home/invidious/invidious/config/sql/users.sql $ psql invidious < /home/invidious/invidious/config/sql/users.sql
$ psql invidious < /home/invidious/invidious/config/sql/session_ids.sql
$ psql invidious < /home/invidious/invidious/config/sql/nonces.sql $ psql invidious < /home/invidious/invidious/config/sql/nonces.sql
$ exit $ exit
``` ```
@ -107,7 +117,7 @@ $ exit
```bash ```bash
$ sudo -i -u invidious $ sudo -i -u invidious
$ cd invidious $ cd invidious
$ shards $ shards update && shards install
$ crystal build src/invidious.cr --release $ crystal build src/invidious.cr --release
# test compiled binary # test compiled binary
$ ./invidious # stop with ctrl c $ ./invidious # stop with ctrl c
@ -115,6 +125,7 @@ $ exit
``` ```
#### systemd service #### systemd service
```bash ```bash
$ sudo cp /home/invidious/invidious/invidious.service /etc/systemd/system/invidious.service $ sudo cp /home/invidious/invidious/invidious.service /etc/systemd/system/invidious.service
$ sudo systemctl enable 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/videos.sql
$ psql invidious < config/sql/channel_videos.sql $ psql invidious < config/sql/channel_videos.sql
$ psql invidious < config/sql/users.sql $ psql invidious < config/sql/users.sql
$ psql invidious < config/sql/session_ids.sql
$ psql invidious < config/sql/nonces.sql $ psql invidious < config/sql/nonces.sql
# Setup Invidious # Setup Invidious
$ shards $ shards update && shards install
$ crystal build src/invidious.cr --release $ crystal build src/invidious.cr --release
``` ```
## Update Invidious ## Update Invidious
You can find information about how to update in the wiki: [Updating](https://github.com/omarroth/invidious/wiki/Updating).
You can see how to update Invidious [here](https://github.com/omarroth/invidious/wiki/Updating).
## Usage: ## Usage:
@ -178,16 +191,19 @@ $ ./sentry
``` ```
## Documentation ## Documentation
[Documentation](https://github.com/omarroth/invidious/wiki) can be found in the wiki. [Documentation](https://github.com/omarroth/invidious/wiki) can be found in the wiki.
## Extensions ## Extensions
Extensions for Invidious and for integrating Invidious into other projects [are in the wiki](https://github.com/omarroth/invidious/wiki/Extensions)
[Extensions](https://github.com/omarroth/invidious/wiki/Extensions) can be found in the wiki, as well as documentation for integrating it into other projects.
## Made with Invidious ## Made with Invidious
- [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy. - [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy.
- [CloudTube](https://github.com/cloudrac3r/cadencegq): Website featuring pastebin, image host, and YouTube player - [CloudTube](https://github.com/cloudrac3r/cadencegq): Website featuring pastebin, image host, and YouTube player
- [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. - [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists.
- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube.
## Contributing ## Contributing

View File

@ -10,4 +10,4 @@ db:
dbname: invidious dbname: invidious
full_refresh: false full_refresh: false
https_only: false https_only: false
domain: invidio.us domain:

View File

@ -1,4 +0,0 @@
#!/bin/sh
psql invidious -c "ALTER TABLE channels ADD COLUMN deleted bool;"
psql invidious -c "UPDATE channels SET deleted = false;"

View File

@ -9,7 +9,7 @@ ADD . /invidious
WORKDIR /invidious WORKDIR /invidious
RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \ RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \
shards && \ shards update && shards install && \
crystal build src/invidious.cr crystal build src/invidious.cr
CMD [ "/invidious/invidious" ] CMD [ "/invidious/invidious" ]

View File

@ -16,6 +16,7 @@ if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
su postgres -c 'psql invidious < config/sql/videos.sql' su postgres -c 'psql invidious < config/sql/videos.sql'
su postgres -c 'psql invidious < config/sql/channel_videos.sql' su postgres -c 'psql invidious < config/sql/channel_videos.sql'
su postgres -c 'psql invidious < config/sql/users.sql' su postgres -c 'psql invidious < config/sql/users.sql'
su postgres -c 'psql invidious < config/sql/session_ids.sql'
su postgres -c 'psql invidious < config/sql/nonces.sql' su postgres -c 'psql invidious < config/sql/nonces.sql'
touch /var/lib/postgresql/data/setupFinished touch /var/lib/postgresql/data/setupFinished
echo "### invidious database setup finished" echo "### invidious database setup finished"

View File

@ -82,6 +82,14 @@
"Manage subscriptions": "إدارة المشتركين", "Manage subscriptions": "إدارة المشتركين",
"Watch history": "سجل المشاهدة", "Watch history": "سجل المشاهدة",
"Delete account": "حذف الحساب", "Delete account": "حذف الحساب",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "حفظ التفضيلات", "Save preferences": "حفظ التفضيلات",
"Subscription manager": "مدير الإشتراكات", "Subscription manager": "مدير الإشتراكات",
"`x` subscriptions": "`x` مشتركين", "`x` subscriptions": "`x` مشتركين",
@ -280,5 +288,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 ❤": "",
"Audio mode": "",
"Video mode": ""
} }

View File

@ -82,6 +82,14 @@
"Manage subscriptions": "Abonnements verwalten", "Manage subscriptions": "Abonnements verwalten",
"Watch history": "Verlauf", "Watch history": "Verlauf",
"Delete account": "Account löschen", "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", "Save preferences": "Einstellungen speichern",
"Subscription manager": "Abonnementverwaltung", "Subscription manager": "Abonnementverwaltung",
"`x` subscriptions": "`x` Abonnements", "`x` subscriptions": "`x` Abonnements",
@ -264,9 +272,9 @@
"`x` hours": "`x` Stunden", "`x` hours": "`x` Stunden",
"`x` minutes": "`x` Minuten", "`x` minutes": "`x` Minuten",
"`x` seconds": "`x` Sekunden", "`x` seconds": "`x` Sekunden",
"Fallback comments: ": "", "Fallback comments: ": "Alternative Kommentare: ",
"Popular": "Populär", "Popular": "Populär",
"Top": "", "Top": "Top",
"About": "Über", "About": "Über",
"Rating: ": "Bewertung: ", "Rating: ": "Bewertung: ",
"Language: ": "Sprache: ", "Language: ": "Sprache: ",
@ -280,5 +288,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 ❤": "",
"Audio mode": "",
"Video mode": ""
} }

View File

@ -80,6 +80,14 @@
"Manage subscriptions": "Manage subscriptions", "Manage subscriptions": "Manage subscriptions",
"Watch history": "Watch history", "Watch history": "Watch history",
"Delete account": "Delete account", "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", "Save preferences": "Save preferences",
"Subscription manager": "Subscription manager", "Subscription manager": "Subscription manager",
"`x` subscriptions": "`x` subscriptions", "`x` subscriptions": "`x` subscriptions",
@ -274,5 +282,7 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(edited)", "(edited)": "(edited)",
"Youtube permalink of the comment": "Youtube permalink of the comment", "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"
} }

View File

@ -1,11 +1,11 @@
{ {
"`x` subscribers": "", "`x` subscribers": "`x` harpidedun",
"`x` videos": "", "`x` videos": "`x` bideo",
"LIVE": "", "LIVE": "ZUZENEAN",
"Shared `x` ago": "", "Shared `x` ago": "Duela `x` partekatua",
"Unsubscribe": "", "Unsubscribe": "Harpidetza kendu",
"Subscribe": "Harpidetu", "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", "View channel on YouTube": "Ikusi kanala YouTuben",
"newest": "berrienak", "newest": "berrienak",
"oldest": "zaharrenak", "oldest": "zaharrenak",
@ -24,22 +24,22 @@
"Import NewPipe data (.zip)": "NewPipeko datuak inportatu (.zip)", "Import NewPipe data (.zip)": "NewPipeko datuak inportatu (.zip)",
"Export": "Esportatu", "Export": "Esportatu",
"Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala", "Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Harpidetzak OPML bezala esportatu (NewPipe eta FreeTuberako)",
"Export data as JSON": "", "Export data as JSON": "Datuak JSON bezala esportatu",
"Delete account?": "Kontua ezabatu?", "Delete account?": "Kontua ezabatu?",
"History": "Historia", "History": "Historia",
"Previous page": "Aurreko orria", "Previous page": "Aurreko orria",
"An alternative front-end to YouTube": "", "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
"JavaScript license information": "", "JavaScript license information": "JavaScript lizentzia informazioa",
"source": "", "source": "iturburua",
"Login": "", "Login": "Saioa hasi",
"Login/Register": "", "Login/Register": "Saioa hasi/Izena eman",
"Login to Google": "", "Login to Google": "Googlekin hasi saioa",
"User ID:": "", "User ID:": "Erabiltzaile IDa:",
"Password:": "", "Password:": "Pasahitza:",
"Time (h:mm:ss):": "", "Time (h:mm:ss):": "Denbora (o:mm:ss):",
"Text CAPTCHA": "", "Text CAPTCHA": "Testu CAPTCHA",
"Image CAPTCHA": "", "Image CAPTCHA": "Irudi CAPTCHA",
"Sign In": "", "Sign In": "",
"Register": "", "Register": "",
"Email:": "", "Email:": "",
@ -80,6 +80,14 @@
"Manage subscriptions": "", "Manage subscriptions": "",
"Watch history": "", "Watch history": "",
"Delete account": "", "Delete account": "",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"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 ❤": "",
"Audio mode": "",
"Video mode": ""
} }

View File

@ -1,152 +1,159 @@
{ {
"`x` subscribers": "`x` souscripteurs", "`x` subscribers": "`x` abonnés",
"`x` videos": "`x` vidéos", "`x` videos": "`x` vidéos",
"LIVE": "LIVE", "LIVE": "EN DIRECT",
"Shared `x` ago": "Partagé il y a `x`", "Shared `x` ago": "Partagé il y a `x`",
"Unsubscribe": "Se désabonner", "Unsubscribe": "Se désabonner",
"Subscribe": "S'abonner", "Subscribe": "S'abonner",
"Login to subscribe to `x`": "Se connecter pour s'abonner à `x`", "Login to subscribe to `x`": "Vous devez vous connecter pour vous abonner à `x`",
"View channel on YouTube": "Voir la chaîne sur YouTube", "View channel on YouTube": "Voir la chaîne sur YouTube",
"newest": "récent", "newest": "Date d'ajout (la plus récente)",
"oldest": "aînée", "oldest": "Date d'ajout (la plus ancienne)",
"popular": "appréciés", "popular": "Les plus populaires",
"Preview page": "Page de prévisualisation",
"Next page": "Page suivante", "Next page": "Page suivante",
"Clear watch history?": "L'histoire de la montre est claire?", "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
"Yes": "Oui", "Yes": "Oui",
"No": "Aucun", "No": "Non",
"Import and Export Data": "Importation et exportation de données", "Import and Export Data": "Importer et Exporter les Données",
"Import": "Importation", "Import": "Importer",
"Import Invidious data": "Importation de données invalides", "Import Invidious data": "Importer des données Invidious",
"Import YouTube subscriptions": "Importer des abonnements YouTube", "Import YouTube subscriptions": "Importer des abonnements YouTube",
"Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)", "Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
"Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)", "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
"Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)", "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
"Export": "Exporter", "Export": "Exporter",
"Export subscriptions as OPML": "Exporter les abonnements comme OPML", "Export subscriptions as OPML": "Exporter les abonnements en OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements comme OPML (pour NewPipe & FreeTube)", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON", "Export data as JSON": "Exporter les données au format JSON",
"Delete account?": "Supprimer un compte ?", "Delete account?": "Supprimer votre compte ?",
"History": "Histoire", "History": "Historique",
"Previous page": "Page précédente", "Previous page": "Page précédente",
"An alternative front-end to YouTube": "Un frontal alternatif à YouTube", "An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
"JavaScript license information": "Informations sur la licence JavaScript", "JavaScript license information": "Informations sur les licences JavaScript",
"source": "origine", "source": "source",
"Login": "Connexion", "Login": "Connexion",
"Login/Register": "Connexion/S'inscrire", "Login/Register": "Connexion/S'inscrire",
"Login to Google": "Se connecter à Google", "Login to Google": "Se connecter à Google",
"User ID:": "ID utilisateur:", "User ID:": "ID utilisateur :",
"Password:": "Mot de passe:", "Password:": "Mot de passe :",
"Time (h:mm:ss):": "Temps (h:mm:ss):", "Time (h:mm:ss):": "Heure (h:mm:ss) :",
"Text CAPTCHA": "Texte CAPTCHA", "Text CAPTCHA": "CAPTCHA Texte",
"Image CAPTCHA": "Image CAPTCHA", "Image CAPTCHA": "CAPTCHA Image",
"Sign In": "S'identifier", "Sign In": "S'identifier",
"Register": "S'inscrire", "Register": "S'inscrire",
"Email:": "Courriel:", "Email:": "Email :",
"Google verification code:": "Code de vérification Google:", "Google verification code:": "Code de vérification Google :",
"Preferences": "Préférences", "Preferences": "Préférences",
"Player preferences": "Joueur préférences", "Player preferences": "Préférences du Lecteur",
"Always loop: ": "Toujours en boucle: ", "Always loop: ": "Lire en boucle : ",
"Autoplay: ": "Autoplay: ", "Autoplay: ": "Lire Automatiquement : ",
"Autoplay next video: ": "Lecture automatique de la vidéo suivante: ", "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
"Listen by default: ": "Écouter par défaut: ", "Listen by default: ": "Audio Uniquement par défaut : ",
"Default speed: ": "Vitesse par défaut: ", "Default speed: ": "Vitesse par défaut : ",
"Preferred video quality: ": "Qualité vidéo préférée: ", "Preferred video quality: ": "Qualité vidéo souhaitée : ",
"Player volume: ": "Volume de lecteur: ", "Player volume: ": "Volume du lecteur : ",
"Default comments: ": "Commentaires par défaut: ", "Default comments: ": "Source des Commentaires : ",
"Default captions: ": "Légendes par défaut: ", "Default captions: ": "Sous-titres principal : ",
"Fallback captions: ": "Légendes de repli: ", "Fallback captions: ": "Sous-titres secondaire : ",
"Show related videos? ": "Voir les vidéos liées à ce sujet? ", "Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
"Visual preferences": "Préférences visuelles", "Visual preferences": "Préférences visuelles",
"Dark mode: ": "Mode sombre: ", "Dark mode: ": "Mode Sombre : ",
"Thin mode: ": "Mode Thin: ", "Thin mode: ": "Mode Simplifié : ",
"Subscription preferences": "Préférences d'abonnement", "Subscription preferences": "Préférences de la page d'abonnements",
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers le flux: ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
"Number of videos shown in feed: ": "Nombre de vidéos montrées dans le flux: ", "Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
"Sort videos by: ": "Trier les vidéos par: ", "Sort videos by: ": "Trier les vidéos par : ",
"published": "publié", "published": "publication",
"published - reverse": "publié - reverse", "published - reverse": "publication - inversé",
"alphabetically": "alphabétiquement", "alphabetically": "alphabétiquement",
"alphabetically - reverse": "alphabétiquement - contraire", "alphabetically - reverse": "alphabétiquement - inversé",
"channel name": "nom du canal", "channel name": "nom de la chaîne",
"channel name - reverse": "nom du canal - contraire", "channel name - reverse": "nom de la chaîne - inversé",
"Only show latest video from channel: ": "Afficher uniquement les dernières vidéos de la chaîne: ", "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne : ",
"Only show latest unwatched video from channel: ": "Afficher uniquement les dernières vidéos non regardées de la chaîne: ", "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
"Only show unwatched: ": "Afficher uniquement les images non surveillées: ", "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): ", "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Data preferences": "Préférences de données", "Data preferences": "Préférences liées aux données",
"Clear watch history": "Historique clair de la montre", "Clear watch history": "Supprimer l'historique des vidéos regardées",
"Import/Export data": "Données d'importation/exportation", "Import/Export data": "Importer/exporter les données",
"Manage subscriptions": "Gérer les abonnements", "Manage subscriptions": "Gérer les abonnements",
"Watch history": "Historique des montres", "Watch history": "Historique de visionnage",
"Delete account": "Supprimer un compte", "Delete account": "Supprimer votre compte",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "Enregistrer les préférences", "Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement", "Subscription manager": "Gestionnaire d'abonnement",
"`x` subscriptions": "`x` abonnements", "`x` subscriptions": "`x` abonnements",
"Import/Export": "Importer/Exporter", "Import/Export": "Importer/Exporter",
"unsubscribe": "se désabonner", "unsubscribe": "se désabonner",
"Subscriptions": "Abonnements", "Subscriptions": "Abonnements",
"`x` unseen notifications": "`x` notifications invisibles", "`x` unseen notifications": "`x` notifications non vues",
"search": "perquisition", "search": "Rechercher",
"Sign out": "Déconnexion", "Sign out": "Déconnexion",
"Released under the AGPLv3 by Omar Roth.": "Publié sous l'AGPLv3 par Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
"Source available here.": "Source disponible ici.", "Source available here.": "Code Source.",
"View JavaScript license information.": "Voir les informations de licence JavaScript.", "View JavaScript license information.": "Voir les informations des licences JavaScript.",
"Trending": "Tendances", "Trending": "Tendances",
"Watch video on Youtube": "Voir la vidéo sur Youtube", "Watch video on Youtube": "Voir la vidéo sur Youtube",
"Genre: ": "Genre: ", "Genre: ": "Genre : ",
"License: ": "Licence: ", "License: ": "Licence : ",
"Family friendly? ": "Convivialité familiale? ", "Family friendly? ": "Tout Public ? ",
"Wilson score: ": "Wilson marque: ", "Wilson score: ": "Score de Wilson : ",
"Engagement: ": "Fiançailles: ", "Engagement: ": "Poucentage de spectateur aillant aimé Liker ou Disliker la vidéo : ",
"Whitelisted regions: ": "Régions en liste blanche: ", "Whitelisted regions: ": "Régions en liste blanche : ",
"Blacklisted regions: ": "Régions sur liste noire: ", "Blacklisted regions: ": "Régions sur liste noire : ",
"Shared `x`": "Partagée `x`", "Shared `x`": "Partagée `x`",
"Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Hi! On dirait que vous avez désactivé JavaScript. Cliquez ici pour voir les commentaires, gardez à l'esprit que le chargement peut prendre un peu plus de temps.", "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Il semblerait que JavaScript sois désactivé. Cliquez ici pour voir les commentaires. Gardez à l'esprit que le chargement peut prendre plus de temps.",
"View YouTube comments": "Voir les commentaires sur YouTube", "View YouTube comments": "Voir les commentaires YouTube",
"View more comments on Reddit": "Voir plus de commentaires sur Reddit", "View more comments on Reddit": "Voir plus de commentaires sur Reddit",
"View `x` comments": "Voir `x` commentaires", "View `x` comments": "Voir `x` commentaires",
"View Reddit comments": "Voir Reddit commentaires", "View Reddit comments": "Voir les commentaires Reddit",
"Hide replies": "Masquer les réponses", "Hide replies": "Masquer les réponses",
"Show replies": "Afficher les réponses", "Show replies": "Afficher les réponses",
"Incorrect password": "Mot de passe incorrect", "Incorrect password": "Mot de passe incorrect",
"Quota exceeded, try again in a few hours": "Quota dépassé, réessayez dans quelques heures", "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassé, réessayez dans quelques heures",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.",
"Invalid TFA code": "Code TFA invalide", "Invalid TFA code": "Code d'authentification à deux facteurs invalide",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Login failed. This may be because two-factor authentication is not enabled on your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.",
"Invalid answer": "Réponse non valide", "Invalid answer": "Réponse non valide",
"Invalid CAPTCHA": "CAPTCHA invalide", "Invalid CAPTCHA": "CAPTCHA invalide",
"CAPTCHA is a required field": "CAPTCHA est un champ obligatoire", "CAPTCHA is a required field": "Veuillez rentrez un CAPTCHA",
"User ID is a required field": "Utilisateur ID est un champ obligatoire", "User ID is a required field": "Veuillez rentrez un Identifiant Utilisateur",
"Password is a required field": "Mot de passe est un champ obligatoire", "Password is a required field": "Veuillez rentrez un Mot de passe",
"Invalid username or password": "Nom d'utilisateur ou mot de passe invalide", "Invalid username or password": "Nom d'utilisateur ou mot de passe invalide",
"Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant 'S'identifier avec Google'", "Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant \"S'identifier avec Google\"",
"Password cannot be empty": "Le mot de passe ne peut pas être vide", "Password cannot be empty": "Le mot de passe ne peut pas être vide",
"Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères.", "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères",
"Please sign in": "Veuillez ouvrir une session", "Please sign in": "Veuillez vous connecter",
"Invidious Private Feed for `x`": "Flux privé Invidious pour `x`", "Invidious Private Feed for `x`": "Flux RSS privé pour `x`",
"channel:`x`": "chenal:`x`", "channel:`x`": "chaîne:`x`",
"Deleted or invalid channel": "Canal supprimé ou non valide", "Deleted or invalid channel": "Chaîne supprimée ou invalide",
"This channel does not exist.": "Ce canal n'existe pas.", "This channel does not exist.": "Cette chaine n'existe pas.",
"Could not get channel info.": "Impossible d'obtenir des informations sur les chaînes.", "Could not get channel info.": "Impossible de charger les informations de cette chaîne.",
"Could not fetch comments": "Impossible d'aller chercher les commentaires", "Could not fetch comments": "Impossible de charger les commentaires",
"View `x` replies": "Voir `x` réponses", "View `x` replies": "Voir `x` réponses",
"`x` ago": "il y a `x`", "`x` ago": "il y a `x`",
"Load more": "Charger plus", "Load more": "Charger plus",
"`x` points": "`x` points", "`x` points": "`x` points",
"Could not create mix.": "Impossible de créer du mixage.", "Could not create mix.": "Impossible de charger cette liste de lecture.",
"Playlist is empty": "La liste de lecture est vide", "Playlist is empty": "La liste de lecture est vide",
"Invalid playlist.": "Liste de lecture invalide.", "Invalid playlist.": "Liste de lecture invalide.",
"Playlist does not exist.": "La liste de lecture n'existe pas.", "Playlist does not exist.": "La liste de lecture n'existe pas.",
"Could not pull trending pages.": "Impossible de tirer les pages de tendances.", "Could not pull trending pages.": "Impossible de charger les pages de tendances.",
"Hidden field \"challenge\" is a required field": "Champ caché \"contestation\" est un champ obligatoire", "Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field",
"Hidden field \"token\" is a required field": "Champ caché \"jeton\" est un champ obligatoire", "Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field",
"Invalid challenge": "Contestation non valide", "Invalid challenge": "Invalid challenge",
"Invalid token": "Jeton non valide", "Invalid token": "Invalid token",
"Invalid user": "Iutilisateur non valide", "Invalid user": "Invalid user",
"Token is expired, please try again": "Le jeton est expiré, veuillez réessayer", "Token is expired, please try again": "Token is expired, please try again",
"English": "Anglais", "English": "Anglais",
"English (auto-generated)": "Anglais (auto-généré)", "English (auto-generated)": "Anglais (générés automatiquement)",
"Afrikaans": "Afrikaans", "Afrikaans": "Afrikaans",
"Albanian": "Albanais", "Albanian": "Albanais",
"Amharic": "Amharique", "Amharic": "Amharique",
@ -258,21 +265,23 @@
"`x` hours": "`x` heures", "`x` hours": "`x` heures",
"`x` minutes": "`x` minutes", "`x` minutes": "`x` minutes",
"`x` seconds": "`x` secondes", "`x` seconds": "`x` secondes",
"Fallback comments: ": "Commentaires de repli: ", "Fallback comments: ": "Commentaires secondaires : ",
"Popular": "Populaire", "Popular": "Populaire",
"Top": "Haut", "Top": "Top",
"About": "Sur", "About": "A Propos",
"Rating: ": "Évaluation: ", "Rating: ": "Évaluation : ",
"Language: ": "Langue: ", "Language: ": "Langue : ",
"Default": "", "Default": "Défaut",
"Music": "", "Music": "Musique",
"Gaming": "", "Gaming": "Jeux Vidéo",
"News": "", "News": "Actualités",
"Movies": "", "Movies": "Films",
"Download": "", "Download": "Télécharger",
"Download as: ": "", "Download as: ": "Télécharger en : ",
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "", "(edited)": "(modifié)",
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
"`x` marked it with a ❤": "" "`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
"Audio mode": "Mode Audio",
"Video mode": "Mode Vidéo"
} }

287
locales/it.json Normal file
View File

@ -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"
}

View File

@ -80,6 +80,14 @@
"Manage subscriptions": "Behandle abonnementer", "Manage subscriptions": "Behandle abonnementer",
"Watch history": "Visningshistorikk", "Watch history": "Visningshistorikk",
"Delete account": "Slett konto", "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", "Save preferences": "Lagre innstillinger",
"Subscription manager": "Abonnementsbehandler", "Subscription manager": "Abonnementsbehandler",
"`x` subscriptions": "`x` abonnementer", "`x` subscriptions": "`x` abonnementer",
@ -264,15 +272,17 @@
"About": "Om", "About": "Om",
"Rating: ": "Vurdering: ", "Rating: ": "Vurdering: ",
"Language: ": "Språk: ", "Language: ": "Språk: ",
"Default": "", "Default": "Forvalg",
"Music": "", "Music": "Musikk",
"Gaming": "", "Gaming": "Spill",
"News": "", "News": "Nyheter",
"Movies": "", "Movies": "Filmer",
"Download": "", "Download": "Last ned",
"Download as: ": "", "Download as: ": "Last ned som: ",
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "", "(edited)": "(redigert)",
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
"`x` marked it with a ❤": "" "`x` marked it with a ❤": "`x` levnet et ❤",
"Audio mode": "Lydmodus",
"Video mode": "Video-modus"
} }

View File

@ -80,6 +80,14 @@
"Manage subscriptions": "Abonnees beheren", "Manage subscriptions": "Abonnees beheren",
"Watch history": "Kijkgeschiedenis", "Watch history": "Kijkgeschiedenis",
"Delete account": "Account verwijderen", "Delete account": "Account verwijderen",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "Opslaan voorkeuren", "Save preferences": "Opslaan voorkeuren",
"Subscription manager": "Abonnees beheerder", "Subscription manager": "Abonnees beheerder",
"`x` subscriptions": "`x` abonnees", "`x` subscriptions": "`x` abonnees",
@ -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 ❤": "",
"Audio mode": "",
"Video mode": ""
} }

View File

@ -29,7 +29,7 @@
"Delete account?": "Usunąć konto?", "Delete account?": "Usunąć konto?",
"History": "Historia", "History": "Historia",
"Previous page": "Poprzednia strona", "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", "JavaScript license information": "Informacja o licencji JavaScript",
"source": "źródło", "source": "źródło",
"Login": "Zaloguj", "Login": "Zaloguj",
@ -80,6 +80,14 @@
"Manage subscriptions": "Organizuj subskrybcje", "Manage subscriptions": "Organizuj subskrybcje",
"Watch history": "Historia", "Watch history": "Historia",
"Delete account": "Usuń konto", "Delete account": "Usuń konto",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "Zapisz preferencje", "Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji", "Subscription manager": "Manager subskrybcji",
"`x` subscriptions": "`x` subskrybcji", "`x` subscriptions": "`x` subskrybcji",
@ -145,107 +153,107 @@
"Invalid token": "Niepoprawny token", "Invalid token": "Niepoprawny token",
"Invalid user": "Niepoprawny użytkownik", "Invalid user": "Niepoprawny użytkownik",
"Token is expired, please try again": "Token wygasł, spróbuj ponownie", "Token is expired, please try again": "Token wygasł, spróbuj ponownie",
"English": "", "English": "angielski",
"English (auto-generated)": "", "English (auto-generated)": "angielski (automatycznie generowane)",
"Afrikaans": "", "Afrikaans": "",
"Albanian": "", "Albanian": "albański",
"Amharic": "", "Amharic": "",
"Arabic": "", "Arabic": "arabski",
"Armenian": "", "Armenian": "",
"Azerbaijani": "", "Azerbaijani": "",
"Bangla": "", "Bangla": "",
"Basque": "", "Basque": "",
"Belarusian": "", "Belarusian": "białoruski",
"Bosnian": "", "Bosnian": "bośniacki",
"Bulgarian": "", "Bulgarian": "bułgarski",
"Burmese": "", "Burmese": "birmański",
"Catalan": "", "Catalan": "kataloński",
"Cebuano": "", "Cebuano": "",
"Chinese (Simplified)": "", "Chinese (Simplified)": "chiński (uproszczony)",
"Chinese (Traditional)": "", "Chinese (Traditional)": "chiński (tradycyjny)",
"Corsican": "", "Corsican": "korsykański",
"Croatian": "", "Croatian": "chorwacki",
"Czech": "", "Czech": "czeski",
"Danish": "", "Danish": "duński",
"Dutch": "", "Dutch": "holenderski",
"Esperanto": "", "Esperanto": "esperanto",
"Estonian": "", "Estonian": "estoński",
"Filipino": "", "Filipino": "filipiński",
"Finnish": "", "Finnish": "fiński",
"French": "", "French": "francuski",
"Galician": "", "Galician": "galicyjski",
"Georgian": "", "Georgian": "gruziński",
"German": "", "German": "niemiecki",
"Greek": "", "Greek": "grecki",
"Gujarati": "", "Gujarati": "",
"Haitian Creole": "", "Haitian Creole": "",
"Hausa": "", "Hausa": "",
"Hawaiian": "", "Hawaiian": "hawajski",
"Hebrew": "", "Hebrew": "hebrajski",
"Hindi": "", "Hindi": "hindi",
"Hmong": "", "Hmong": "",
"Hungarian": "", "Hungarian": "węgierski",
"Icelandic": "", "Icelandic": "islandzki",
"Igbo": "", "Igbo": "",
"Indonesian": "", "Indonesian": "indonezyjski",
"Irish": "", "Irish": "irlandzki",
"Italian": "", "Italian": "włoski",
"Japanese": "", "Japanese": "japoński",
"Javanese": "", "Javanese": "jawajski",
"Kannada": "", "Kannada": "",
"Kazakh": "", "Kazakh": "kazachski",
"Khmer": "", "Khmer": "",
"Korean": "", "Korean": "koreański",
"Kurdish": "", "Kurdish": "kurdyjski",
"Kyrgyz": "", "Kyrgyz": "kirgiski",
"Lao": "", "Lao": "",
"Latin": "", "Latin": "łaciński",
"Latvian": "", "Latvian": "łotewski",
"Lithuanian": "", "Lithuanian": "litewski",
"Luxembourgish": "", "Luxembourgish": "luksemburski",
"Macedonian": "", "Macedonian": "macedoński",
"Malagasy": "", "Malagasy": "malgaski",
"Malay": "", "Malay": "malajski",
"Malayalam": "", "Malayalam": "",
"Maltese": "", "Maltese": "maltański",
"Maori": "", "Maori": "",
"Marathi": "", "Marathi": "",
"Mongolian": "", "Mongolian": "mongolski",
"Nepali": "", "Nepali": "nepalski",
"Norwegian": "", "Norwegian": "norweski",
"Nyanja": "", "Nyanja": "",
"Pashto": "", "Pashto": "",
"Persian": "", "Persian": "perski",
"Polish": "", "Polish": "polski",
"Portuguese": "", "Portuguese": "portugalski",
"Punjabi": "", "Punjabi": "",
"Romanian": "", "Romanian": "rumuński",
"Russian": "", "Russian": "rosyjski",
"Samoan": "", "Samoan": "",
"Scottish Gaelic": "", "Scottish Gaelic": "",
"Serbian": "", "Serbian": "serbski",
"Shona": "", "Shona": "",
"Sindhi": "", "Sindhi": "",
"Sinhala": "", "Sinhala": "",
"Slovak": "", "Slovak": "słowacki",
"Slovenian": "", "Slovenian": "słoweński",
"Somali": "", "Somali": "somalijski",
"Southern Sotho": "", "Southern Sotho": "",
"Spanish": "", "Spanish": "hiszpański",
"Spanish (Latin America)": "", "Spanish (Latin America)": "hiszpański (ameryka łacińska)",
"Sundanese": "", "Sundanese": "",
"Swahili": "", "Swahili": "",
"Swedish": "", "Swedish": "szwedzki",
"Tajik": "", "Tajik": "",
"Tamil": "", "Tamil": "",
"Telugu": "", "Telugu": "",
"Thai": "", "Thai": "tajski",
"Turkish": "", "Turkish": "turecki",
"Ukrainian": "", "Ukrainian": "ukraiński",
"Urdu": "", "Urdu": "",
"Uzbek": "", "Uzbek": "uzbecki",
"Vietnamese": "", "Vietnamese": "wietnamski",
"Welsh": "", "Welsh": "walijski",
"Western Frisian": "", "Western Frisian": "",
"Xhosa": "", "Xhosa": "",
"Yiddish": "", "Yiddish": "",
@ -258,21 +266,23 @@
"`x` hours": "`x` godzin", "`x` hours": "`x` godzin",
"`x` minutes": "`x` minut", "`x` minutes": "`x` minut",
"`x` seconds": "`x` sekund", "`x` seconds": "`x` sekund",
"Fallback comments: ": "", "Fallback comments: ": "Zastępcze komentarze: ",
"Popular": "", "Popular": "Popularne",
"Top": "", "Top": "Na czasie",
"About": "", "About": "Informacje",
"Rating: ": "", "Rating: ": "Ocena: ",
"Language: ": "", "Language: ": "Język: ",
"Default": "", "Default": "",
"Music": "", "Music": "Muzyka",
"Gaming": "", "Gaming": "Gry",
"News": "", "News": "Wiadomości",
"Movies": "", "Movies": "Filmy",
"Download": "", "Download": "Pobierz",
"Download as: ": "", "Download as: ": "Pobierz jako: ",
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "", "(edited)": "(edytowany)",
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube",
"`x` marked it with a ❤": "" "`x` marked it with a ❤": "",
"Audio mode": "Tryb audio",
"Video mode": "Tryb wideo"
} }

View File

@ -82,6 +82,14 @@
"Manage subscriptions": "Управление подписками", "Manage subscriptions": "Управление подписками",
"Watch history": "История просмотров", "Watch history": "История просмотров",
"Delete account": "Удалить аккаунт", "Delete account": "Удалить аккаунт",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "Сохранить настройки", "Save preferences": "Сохранить настройки",
"Subscription manager": "Менеджер подписок", "Subscription manager": "Менеджер подписок",
"`x` subscriptions": "`x` подписок", "`x` subscriptions": "`x` подписок",
@ -159,103 +167,103 @@
"Arabic": "Арабский", "Arabic": "Арабский",
"Armenian": "Армянский", "Armenian": "Армянский",
"Azerbaijani": "Азербайджанский", "Azerbaijani": "Азербайджанский",
"Bangla": "", "Bangla": "Бенгальский",
"Basque": "", "Basque": "Баскский",
"Belarusian": "", "Belarusian": "Белорусский",
"Bosnian": "", "Bosnian": "Боснийский",
"Bulgarian": "", "Bulgarian": "Болгарский",
"Burmese": "", "Burmese": "Бирманский",
"Catalan": "", "Catalan": "Каталонский",
"Cebuano": "", "Cebuano": "Себуанский",
"Chinese (Simplified)": "", "Chinese (Simplified)": "Китайский (упрощенный)",
"Chinese (Traditional)": "", "Chinese (Traditional)": "Китайский (традиционный)",
"Corsican": "", "Corsican": "Корсиканский",
"Croatian": "", "Croatian": "Хорватский",
"Czech": "", "Czech": "Чешский",
"Danish": "", "Danish": "Датский",
"Dutch": "", "Dutch": "Нидерландский",
"Esperanto": "", "Esperanto": "Эсперанто",
"Estonian": "", "Estonian": "Эстонский",
"Filipino": "", "Filipino": "Филиппинский",
"Finnish": "", "Finnish": "Финский",
"French": "", "French": "Французский",
"Galician": "", "Galician": "Галисийский",
"Georgian": "", "Georgian": "Грузинский",
"German": "", "German": "Немецкий",
"Greek": "", "Greek": "Греческий",
"Gujarati": "", "Gujarati": "Гуджаратский",
"Haitian Creole": "", "Haitian Creole": "Гаит. креольский",
"Hausa": "", "Hausa": "Хауса",
"Hawaiian": "", "Hawaiian": "Гавайский",
"Hebrew": "", "Hebrew": "Иврит",
"Hindi": "", "Hindi": "Хинди",
"Hmong": "", "Hmong": "Хмонг (мяо)",
"Hungarian": "", "Hungarian": "Венгерский",
"Icelandic": "", "Icelandic": "Исландский",
"Igbo": "", "Igbo": "Игбо",
"Indonesian": "", "Indonesian": "Индонезийский",
"Irish": "", "Irish": "Ирландский",
"Italian": "", "Italian": "Итальянский",
"Japanese": "", "Japanese": "Японский",
"Javanese": "", "Javanese": "Яванский",
"Kannada": "", "Kannada": "Каннада",
"Kazakh": "", "Kazakh": "Казахский",
"Khmer": "", "Khmer": "Кхмерский",
"Korean": "", "Korean": "Корейский",
"Kurdish": "", "Kurdish": "Курдский",
"Kyrgyz": "", "Kyrgyz": "Киргизский",
"Lao": "", "Lao": "Лаосский",
"Latin": "", "Latin": "Латинский",
"Latvian": "", "Latvian": "Латышский",
"Lithuanian": "", "Lithuanian": "Литовский",
"Luxembourgish": "", "Luxembourgish": "Люксембургский",
"Macedonian": "", "Macedonian": "Македонский",
"Malagasy": "", "Malagasy": "Малагасийский",
"Malay": "", "Malay": "Малайский",
"Malayalam": "", "Malayalam": "Малаялам",
"Maltese": "", "Maltese": "Мальтийский",
"Maori": "", "Maori": "Маори",
"Marathi": "", "Marathi": "Маратхи",
"Mongolian": "", "Mongolian": "Монгольская",
"Nepali": "", "Nepali": "Непальский",
"Norwegian": "", "Norwegian": "Норвежский",
"Nyanja": "", "Nyanja": "Ньянджа",
"Pashto": "", "Pashto": "Пушту",
"Persian": "", "Persian": "Персидский",
"Polish": "", "Polish": "Польский",
"Portuguese": "", "Portuguese": "Португальский",
"Punjabi": "", "Punjabi": "Панджаби",
"Romanian": "", "Romanian": "Румынский",
"Russian": "", "Russian": "Русский",
"Samoan": "", "Samoan": "Самоанский",
"Scottish Gaelic": "", "Scottish Gaelic": "Шотландский (гэльский)",
"Serbian": "", "Serbian": "Сербский",
"Shona": "", "Shona": "Шона",
"Sindhi": "", "Sindhi": "Синдхи",
"Sinhala": "", "Sinhala": "Сингальский",
"Slovak": "", "Slovak": "Словацкий",
"Slovenian": "", "Slovenian": "Словенский",
"Somali": "", "Somali": "Сомалийский",
"Southern Sotho": "", "Southern Sotho": "Сесото (южный сото)",
"Spanish": "", "Spanish": "Испанский",
"Spanish (Latin America)": "", "Spanish (Latin America)": "Испанский (Латинская Америка)",
"Sundanese": "", "Sundanese": "Сунданский",
"Swahili": "", "Swahili": "Суахили",
"Swedish": "", "Swedish": "Шведский",
"Tajik": "", "Tajik": "Таджикский",
"Tamil": "", "Tamil": "Тамильский",
"Telugu": "", "Telugu": "Телугу",
"Thai": "", "Thai": "Тайский",
"Turkish": "", "Turkish": "Турецкий",
"Ukrainian": "", "Ukrainian": "Украинский",
"Urdu": "", "Urdu": "Урду",
"Uzbek": "", "Uzbek": "Узбекский",
"Vietnamese": "", "Vietnamese": "Вьетнамский",
"Welsh": "", "Welsh": "Валлийский",
"Western Frisian": "", "Western Frisian": "Западнофризский",
"Xhosa": "", "Xhosa": "Коса",
"Yiddish": "", "Yiddish": "Идиш",
"Yoruba": "", "Yoruba": "Йоруба",
"Zulu": "Зулусский", "Zulu": "Зулусский",
"`x` years": "`x` лет", "`x` years": "`x` лет",
"`x` months": "`x` месяцев", "`x` months": "`x` месяцев",
@ -277,8 +285,10 @@
"Movies": "Фильмы", "Movies": "Фильмы",
"Download": "Скачать", "Download": "Скачать",
"Download as: ": "Скачать как: ", "Download as: ": "Скачать как: ",
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "", "(edited)": "(изменено)",
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "Прямая ссылка на YouTube",
"`x` marked it with a ❤": "" "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим",
"Video mode": "Видео режим"
} }

BIN
screenshots/01_player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -1,5 +1,5 @@
name: invidious name: invidious
version: 0.14.0 version: 0.14.1
authors: authors:
- Omar Roth <omarroth@hotmail.com> - Omar Roth <omarroth@hotmail.com>
@ -9,16 +9,13 @@ targets:
main: src/invidious.cr main: src/invidious.cr
dependencies: dependencies:
detect_language:
github: detectlanguage/detectlanguage-crystal
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
commit: afd17fc
pg: pg:
github: will/crystal-pg github: will/crystal-pg
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
crystal: 0.27.1 crystal: 0.27.2
license: AGPLv3 license: AGPLv3

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
require "detect_language"
require "digest/md5" require "digest/md5"
require "file_utils" require "file_utils"
require "kemal" require "kemal"
@ -29,44 +28,40 @@ require "./invidious/helpers/*"
require "./invidious/*" require "./invidious/*"
CONFIG = Config.from_yaml(File.read("config/config.yml")) CONFIG = Config.from_yaml(File.read("config/config.yml"))
HMAC_KEY = CONFIG.hmac_key || Random::Secure.random_bytes(32) HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
crawl_threads = CONFIG.crawl_threads
channel_threads = CONFIG.channel_threads
feed_threads = CONFIG.feed_threads
video_threads = CONFIG.video_threads
config = CONFIG
logger = Invidious::LogHandler.new logger = Invidious::LogHandler.new
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" 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 begin
crawl_threads = number.to_i config.crawl_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
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 begin
channel_threads = number.to_i config.channel_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
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 begin
feed_threads = number.to_i config.feed_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
end end
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 begin
video_threads = number.to_i config.video_threads = number.to_i
rescue ex rescue ex
puts "THREADS must be integer" puts "THREADS must be integer"
exit exit
@ -78,23 +73,34 @@ Kemal.config.extra_options do |parser|
end end
end end
Kemal::CLI.new Kemal::CLI.new ARGV
YT_URL = URI.parse("https://www.youtube.com") YT_URL = URI.parse("https://www.youtube.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
LOGIN_URL = URI.parse("https://accounts.google.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 = { LOCALES = {
"ar" => load_locale("ar"), "ar" => load_locale("ar"),
"de" => load_locale("de"), "de" => load_locale("de"),
"en-US" => load_locale("en-US"), "en-US" => load_locale("en-US"),
"eu" => load_locale("eu"),
"fr" => load_locale("fr"), "fr" => load_locale("fr"),
"it" => load_locale("it"),
"nb_NO" => load_locale("nb_NO"), "nb_NO" => load_locale("nb_NO"),
"nl" => load_locale("nl"), "nl" => load_locale("nl"),
"pl" => load_locale("pl"), "pl" => load_locale("pl"),
"ru" => load_locale("ru"), "ru" => load_locale("ru"),
} }
statistics = {
"error" => "Statistics are not availabile.",
}
decrypt_function = [] of {name: String, value: Int32} decrypt_function = [] of {name: String, value: Int32}
spawn do spawn do
update_decrypt_function do |function| update_decrypt_function do |function|
@ -114,6 +120,25 @@ before_all do |env|
end end
# API Endpoints # 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| get "/api/v1/captions/:id" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("locale").as(String)]?
@ -293,7 +318,7 @@ get "/api/v1/insights/:id" do |env|
env.response.content_type = "application/json" env.response.content_type = "application/json"
error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_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) client = make_client(YT_URL)
headers = HTTP::Headers.new headers = HTTP::Headers.new
@ -412,7 +437,7 @@ get "/api/v1/videos/:id" do |env|
json.field "description", description json.field "description", description
json.field "descriptionHtml", video.description json.field "descriptionHtml", video.description
json.field "published", video.published.to_unix 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 "keywords", video.keywords
json.field "viewCount", video.views json.field "viewCount", video.views
@ -459,7 +484,7 @@ get "/api/v1/videos/:id" do |env|
end end
if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? 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 = env.request.query_params
host_params.delete_all("v") host_params.delete_all("v")
@ -620,7 +645,7 @@ get "/api/v1/trending" do |env|
json.field "authorUrl", "/channel/#{video.ucid}" json.field "authorUrl", "/channel/#{video.ucid}"
json.field "published", video.published.to_unix 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 "description", video.description
json.field "descriptionHtml", video.description_html json.field "descriptionHtml", video.description_html
json.field "liveNow", video.live_now json.field "liveNow", video.live_now
@ -655,11 +680,16 @@ get "/api/v1/channels/:ucid" do |env|
end end
page = 1 page = 1
begin if auto_generated
videos, count = get_60_videos(ucid, page, auto_generated, sort_by) videos = [] of SearchVideo
rescue ex count = 0
error_message = {"error" => ex.message}.to_json else
halt env, status_code: 500, response: error_message 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 end
client = make_client(YT_URL) client = make_client(YT_URL)
@ -762,6 +792,7 @@ get "/api/v1/channels/:ucid" do |env|
json.field "joined", joined.to_unix json.field "joined", joined.to_unix
json.field "paid", paid json.field "paid", paid
json.field "autoGenerated", auto_generated
json.field "isFamilyFriendly", is_family_friendly json.field "isFamilyFriendly", is_family_friendly
json.field "description", description json.field "description", description
json.field "descriptionHtml", description_html json.field "descriptionHtml", description_html
@ -794,7 +825,7 @@ get "/api/v1/channels/:ucid" do |env|
json.field "viewCount", video.views json.field "viewCount", video.views
json.field "published", video.published.to_unix 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 "lengthSeconds", video.length_seconds
json.field "liveNow", video.live_now json.field "liveNow", video.live_now
json.field "paid", video.paid json.field "paid", video.paid
@ -848,7 +879,8 @@ end
ucid = env.params.url["ucid"] ucid = env.params.url["ucid"]
page = env.params.query["page"]?.try &.to_i? page = env.params.query["page"]?.try &.to_i?
page ||= 1 page ||= 1
sort_by = env.params.query["sort_by"]?.try &.downcase sort_by = env.params.query["sort"]?.try &.downcase
sort_by ||= env.params.query["sort_by"]?.try &.downcase
sort_by ||= "newest" sort_by ||= "newest"
begin begin
@ -891,7 +923,7 @@ end
json.field "viewCount", video.views json.field "viewCount", video.views
json.field "published", video.published.to_unix 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 "lengthSeconds", video.length_seconds
json.field "liveNow", video.live_now json.field "liveNow", video.live_now
json.field "paid", video.paid json.field "paid", video.paid
@ -909,6 +941,127 @@ end
end 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| get "/api/v1/channels/search/:ucid" do |env|
locale = LOCALES[env.get("locale").as(String)]? 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 "viewCount", item.views
json.field "published", item.published.to_unix 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 "lengthSeconds", item.length_seconds
json.field "liveNow", item.live_now json.field "liveNow", item.live_now
json.field "paid", item.paid json.field "paid", item.paid
@ -1031,7 +1184,7 @@ get "/api/v1/search" do |env|
date = env.params.query["date"]?.try &.downcase date = env.params.query["date"]?.try &.downcase
date ||= "" date ||= ""
duration = env.params.query["date"]?.try &.downcase duration = env.params.query["duration"]?.try &.downcase
duration ||= "" duration ||= ""
features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } 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 "viewCount", item.views
json.field "published", item.published.to_unix 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 "lengthSeconds", item.length_seconds
json.field "liveNow", item.live_now json.field "liveNow", item.live_now
json.field "paid", item.paid json.field "paid", item.paid
@ -1253,7 +1406,7 @@ get "/api/v1/mixes/:rdid" do |env|
rdid = env.params.url["rdid"] rdid = env.params.url["rdid"]
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
continuation ||= rdid.lchop("RD") continuation ||= rdid.lchop("RD")[0, 11]
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
@ -1355,8 +1508,8 @@ get "/api/manifest/dash/id/:id" do |env|
halt env, status_code: 403 halt env, status_code: 403
end end
if video.info["dashmpd"]? if dashmpd = video.player_response["streamingData"]["dashManifestUrl"]?.try &.as_s
manifest = client.get(video.info["dashmpd"]).body manifest = client.get(dashmpd).body
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
url = baseurl.lchop("<BaseURL>") url = baseurl.lchop("<BaseURL>")
@ -1445,7 +1598,7 @@ get "/api/manifest/hls_variant/*" do |env|
env.response.content_type = "application/x-mpegURL" env.response.content_type = "application/x-mpegURL"
env.response.headers.add("Access-Control-Allow-Origin", "*") 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 = manifest.body
manifest.gsub("https://www.youtube.com", host_url) 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 halt env, status_code: manifest.status_code
end 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.body.gsub("https://www.youtube.com", host_url)
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.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, # YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version # so we have a mechanism here to redirect to the latest version
get "/latest_version" do |env| get "/latest_version" do |env|
id = env.params.query["id"]? if env.params.query["download_widget"]?
itag = env.params.query["itag"]? 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"]? region = env.params.query["region"]?
local = env.params.query["local"]? local ||= env.params.query["local"]?
local ||= "false" local ||= "false"
local = local == "true" local = local == "true"
@ -1504,6 +1665,10 @@ get "/latest_version" do |env|
url = URI.parse(url).full_path.not_nil! url = URI.parse(url).full_path.not_nil!
end end
if title
url += "&title=#{title}"
end
env.redirect url env.redirect url
end end
@ -1565,7 +1730,7 @@ end
get "/videoplayback" do |env| get "/videoplayback" do |env|
query_params = env.params.query query_params = env.params.query
fvip = query_params["fvip"] fvip = query_params["fvip"]? || "3"
mn = query_params["mn"].split(",")[-1] mn = query_params["mn"].split(",")[-1]
host = "https://r#{fvip}---#{mn}.googlevideo.com" host = "https://r#{fvip}---#{mn}.googlevideo.com"
url = "/videoplayback?#{query_params.to_s}" url = "/videoplayback?#{query_params.to_s}"
@ -1601,13 +1766,18 @@ get "/videoplayback" do |env|
end end
if response.status_code >= 400 if response.status_code >= 400
halt env, status_code: 403 halt env, status_code: response.status_code
end end
client = make_client(URI.parse(host), proxies, region) client = make_client(URI.parse(host), proxies, region)
client.get(url, headers) do |response| client.get(url, headers) do |response|
env.response.status_code = response.status_code 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| response.headers.each do |key, value|
env.response.headers[key] = value env.response.headers[key] = value
end end

View File

@ -1,9 +1,10 @@
class InvidiousChannel class InvidiousChannel
add_mapping({ add_mapping({
id: String, id: String,
author: String, author: String,
updated: Time, updated: Time,
deleted: Bool, deleted: Bool,
subscribed: Time?,
}) })
end end
@ -15,10 +16,7 @@ class ChannelVideo
updated: Time, updated: Time,
ucid: String, ucid: String,
author: String, author: String,
length_seconds: { length_seconds: {type: Int32, default: 0},
type: Int32,
default: 0,
},
}) })
end end
@ -50,13 +48,11 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
end end
def get_channel(id, db, refresh = true, pull_all_videos = true) 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) 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) channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
if refresh && Time.now - channel.updated > 10.minutes 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 channel_array = channel.to_a
args = arg_array(channel_array) 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) ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", channel_array)
end end
else 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 channel_array = channel.to_a
args = arg_array(channel_array) args = arg_array(channel_array)
@ -74,7 +70,9 @@ def get_channel(id, db, refresh = true, pull_all_videos = true)
return channel return channel
end 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 = client.get("/feeds/videos.xml?channel_id=#{ucid}").body
rss = XML.parse_html(rss) 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) db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
end end
channel = InvidiousChannel.new(ucid, author, Time.now, false) channel = InvidiousChannel.new(ucid, author, Time.now, false, nil)
return channel return channel
end 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") def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest")
if auto_generated if auto_generated
seed = Time.unix(1525757349) seed = Time.unix(1525757349)
@ -260,6 +335,132 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
return url return url
end end
def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false)
if !auto_generated
cursor = Base64.urlsafe_encode(cursor, false)
end
meta = IO::Memory.new
if auto_generated
meta.write(Bytes[0x08, 0x0a])
end
meta.write(Bytes[0x12, 0x09])
meta.print("playlists")
if auto_generated
meta.write(Bytes[0x20, 0x32])
else
# TODO: Look at 0x01, 0x00
case sort
when "oldest", "oldest_created"
meta.write(Bytes[0x18, 0x02])
when "newest", "newest_created"
meta.write(Bytes[0x18, 0x03])
when "last", "last_added"
meta.write(Bytes[0x18, 0x04])
end
meta.write(Bytes[0x20, 0x01])
end
meta.write(Bytes[0x30, 0x02])
meta.write(Bytes[0x38, 0x01])
meta.write(Bytes[0x60, 0x01])
meta.write(Bytes[0x6a, 0x00])
meta.write(Bytes[0x7a, cursor.size])
meta.print(cursor)
meta.write(Bytes[0xb8, 0x01, 0x00])
meta.rewind
meta = Base64.urlsafe_encode(meta.to_slice)
meta = URI.escape(meta)
continuation = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a])
continuation.write(write_var_int(meta.size))
continuation.print(meta)
continuation.rewind
continuation = continuation.gets_to_end
wrapper = IO::Memory.new
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
wrapper.write(write_var_int(continuation.size))
wrapper.print(continuation)
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
wrapper = URI.escape(wrapper)
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
return url
end
def extract_channel_playlists_cursor(url, auto_generated)
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
wrapper = URI.unescape(wrapper)
wrapper = Base64.decode(wrapper)
# 0xe2 0xa9 0x85 0xb2 0x02
wrapper += 5
continuation_size = read_var_int(wrapper[0, 4])
wrapper += write_var_int(continuation_size).size
continuation = wrapper[0, continuation_size]
# 0x12
continuation += 1
ucid_size = continuation[0]
continuation += 1
ucid = continuation[0, ucid_size]
continuation += ucid_size
# 0x1a
continuation += 1
meta_size = read_var_int(continuation[0, 4])
continuation += write_var_int(meta_size).size
meta = continuation[0, meta_size]
continuation += meta_size
meta = String.new(meta)
meta = URI.unescape(meta)
meta = Base64.decode(meta)
# 0x12 0x09 playlists
meta += 11
until meta[0] == 0x7a
tag = read_var_int(meta[0, 4])
meta += write_var_int(tag).size
value = meta[0]
meta += 1
end
# 0x7a
meta += 1
cursor_size = meta[0]
meta += 1
cursor = meta[0, cursor_size]
cursor = String.new(cursor)
if !auto_generated
cursor = URI.unescape(cursor)
cursor = Base64.decode_string(cursor)
end
return cursor
end
def get_about_info(ucid, locale) def get_about_info(ucid, locale)
client = make_client(YT_URL) client = make_client(YT_URL)
@ -290,7 +491,7 @@ def get_about_info(ucid, locale)
sub_count ||= 0 sub_count ||= 0
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
ucid = about.xpath_node(%q(//link[@rel="canonical"])).not_nil!["href"].split("/")[-1] ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
# Auto-generated channels # Auto-generated channels
# https://support.google.com/youtube/answer/2579942 # https://support.google.com/youtube/answer/2579942
@ -334,3 +535,21 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
return videos, count return videos, count
end 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

View File

@ -184,7 +184,7 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, region)
json.field "content", content json.field "content", content
json.field "contentHtml", content_html json.field "contentHtml", content_html
json.field "published", published.to_unix 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 "likeCount", node_comment["likeCount"]
json.field "commentId", node_comment["commentId"] json.field "commentId", node_comment["commentId"]
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
@ -252,7 +252,7 @@ end
def fetch_reddit_comments(id) def fetch_reddit_comments(id)
client = make_client(REDDIT_URL) 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)" query = "(url:3D#{id}%20OR%20url:#{id})%20(site:youtube.com%20OR%20site:youtu.be)"
search_results = client.get("/search.json?q=#{query}", headers) search_results = client.get("/search.json?q=#{query}", headers)
@ -310,7 +310,7 @@ def template_youtube_comments(comments, locale)
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a> <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b> </b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p> <p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64)))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span> <span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
| |
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a> <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a>
| |
@ -324,7 +324,7 @@ def template_youtube_comments(comments, locale)
<div class="creator-heart"> <div class="creator-heart">
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
<div class="creator-heart-small-hearted"> <div class="creator-heart-small-hearted">
<div class="creator-heart-small-container">🖤</div> <div class="icon ion-ios-heart creator-heart-small-container"></div>
</div> </div>
</div> </div>
</span> </span>
@ -375,7 +375,7 @@ def template_reddit_comments(root, locale)
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a> <a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b> <b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
#{translate(locale, "`x` points", number_with_separator(score))} #{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))}
</p> </p>
<div> <div>
#{body_html} #{body_html}

View File

@ -1,9 +1,9 @@
class Config class Config
YAML.mapping({ 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) 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) 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 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 db: NamedTuple( # Database configuration
user: String, user: String,
password: String, password: String,
@ -11,11 +11,19 @@ user: String,
port: Int32, port: Int32,
dbname: String, dbname: String,
), ),
dl_api_key: String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional 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:// 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 hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
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
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 end
@ -66,7 +74,7 @@ class DenyFrame < Kemal::Handler
end end
end end
def rank_videos(db, n, filter, url) def rank_videos(db, n)
top = [] of {Float64, String} top = [] of {Float64, String}
db.query("SELECT id, wilson_score, published FROM videos WHERE views > 5000 ORDER BY published DESC LIMIT 1000") do |rs| 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.reverse!
top = top.map { |a, b| b } top = top.map { |a, b| b }
if filter return top[0..n - 1]
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
end end
def login_req(login_form, f_req) def login_req(login_form, f_req)
@ -166,29 +140,11 @@ def extract_videos(nodeset, ucid = nil)
videos.map { |video| video.as(SearchVideo) } videos.map { |video| video.as(SearchVideo) }
end end
def extract_items(nodeset, ucid = nil) def extract_items(nodeset, ucid = nil, author_name = nil)
# TODO: Make this a 'common', so it makes more sense to be used here # TODO: Make this a 'common', so it makes more sense to be used here
items = [] of SearchItem items = [] of SearchItem
nodeset.each do |node| 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)) anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if !anchor if !anchor
next next
@ -196,6 +152,22 @@ def extract_items(nodeset, ucid = nil)
title = anchor.content.strip title = anchor.content.strip
id = anchor["href"] 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 = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
description_html, description = html_to_content(description_html) description_html, description = html_to_content(description_html)
@ -354,3 +326,94 @@ def extract_items(nodeset, ucid = nil)
return items return items
end end
def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
items = [] of SearchPlaylist
nodeset.each do |shelf|
shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")]))
if !shelf_anchor
next
end
title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")]))
if title
title = title.content.strip
end
title ||= ""
id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"]
if !id
next
end
is_playlist = false
videos = [] of SearchPlaylistVideo
shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node|
type = child_node.xpath_node(%q(./div))
if !type
next
end
case type["class"]
when .includes? "yt-lockup-video"
is_playlist = true
anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if anchor
video_title = anchor.content.strip
video_id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
end
video_title ||= ""
video_id ||= ""
anchor = child_node.xpath_node(%q(.//span[@class="video-time"]))
if anchor
length_seconds = decode_length_seconds(anchor.content)
end
length_seconds ||= 0
videos << SearchPlaylistVideo.new(
video_title,
video_id,
length_seconds
)
when .includes? "yt-lockup-playlist"
anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if anchor
playlist_title = anchor.content.strip
params = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)
plid = params["list"]
end
playlist_title ||= ""
plid ||= ""
items << SearchPlaylist.new(
playlist_title,
plid,
author_name,
ucid,
50,
Array(SearchPlaylistVideo).new
)
end
end
if is_playlist
plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
items << SearchPlaylist.new(
title,
plid,
author_name,
ucid,
videos.size,
videos
)
end
end
return items
end

View File

@ -136,31 +136,26 @@ def decode_date(string : String)
return Time.now - delta return Time.now - delta
end end
def recode_date(time : Time) def recode_date(time : Time, locale)
span = Time.now - time span = Time.now - time
if span.total_days > 365.0 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 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 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 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 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 elsif span.total_seconds > 60.0
span = {span.total_minutes, "minute"} span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s)
else else
span = {span.total_seconds, "second"} span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s)
end end
span = {span[0].to_i, span[1]} return span
if span[0] > 1
span = {span[0], span[1] + "s"}
end
return span.join(" ")
end end
def number_with_separator(number) def number_with_separator(number)
@ -205,7 +200,12 @@ def make_host_url(ssl, host)
scheme = "http://" scheme = "http://"
end end
return "#{scheme}#{host}" if host
host = host.lchop(".")
return "#{scheme}#{host}"
else
return ""
end
end end
def get_referer(env, fallback = "/") def get_referer(env, fallback = "/")

View File

@ -55,7 +55,7 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
active_channel = Channel(Bool).new active_channel = Channel(Bool).new
loop do 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 rs.each do
id = rs.read(String) id = rs.read(String)
@ -68,13 +68,12 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
active_threads += 1 active_threads += 1
spawn do spawn do
begin begin
client = make_client(YT_URL) channel = fetch_channel(id, db, full_refresh)
channel = fetch_channel(id, client, 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 rescue ex
if ex.message == "Deleted or invalid channel" 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 end
logger.write("#{id} : #{ex.message}\n") logger.write("#{id} : #{ex.message}\n")
end end
@ -132,7 +131,16 @@ def refresh_feeds(db, logger, max_threads = 1)
begin begin
db.exec("REFRESH MATERIALIZED VIEW #{view_name}") db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
rescue ex 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 end
active_channel.send(true) active_channel.send(true)
@ -145,19 +153,30 @@ def refresh_feeds(db, logger, max_threads = 1)
max_channel.send(max_threads) max_channel.send(max_threads)
end end
def pull_top_videos(config, db) def subscribe_to_feeds(db, logger, key, config)
if config.dl_api_key if config.use_pubsub_feeds
DetectLanguage.configure do |dl_config| spawn do
dl_config.api_key = config.dl_api_key.not_nil! 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 end
filter = true
end end
end
filter ||= false def pull_top_videos(config, db)
loop do loop do
begin begin
top = rank_videos(db, 40, filter, YT_URL) top = rank_videos(db, 40)
rescue ex rescue ex
next next
end end
@ -185,11 +204,11 @@ end
def pull_popular_videos(db) def pull_popular_videos(db)
loop do 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 \ (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String) 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)}) \ channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse

View File

@ -43,8 +43,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
mix_title = playlist["title"].as_s mix_title = playlist["title"].as_s
contents = playlist["contents"].as_a contents = playlist["contents"].as_a
until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id if contents.map { |video| video["playlistPanelVideoRenderer"]["videoId"] }.includes? video_id
contents.shift until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id
contents.shift
end
end end
videos = [] of MixVideo videos = [] of MixVideo
@ -52,7 +54,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
item = item["playlistPanelVideoRenderer"] item = item["playlistPanelVideoRenderer"]
id = item["videoId"].as_s id = item["videoId"].as_s
title = item["title"]["simpleText"].as_s title = item["title"]?.try &.["simpleText"].as_s
if !title
next
end
author = item["longBylineText"]["runs"][0]["text"].as_s author = item["longBylineText"]["runs"][0]["text"].as_s
ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s) length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
@ -94,7 +99,10 @@ def template_mix(mix)
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<img style="width:100%;" src="/vi/#{video["videoId"]}/mqdefault.jpg"> <div class="thumbnail">
<img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p> <p style="width:100%">#{video["title"]}</p>
<p> <p>
<b style="width: 100%">#{video["author"]}</b> <b style="width: 100%">#{video["author"]}</b>

View File

@ -161,117 +161,6 @@ def produce_playlist_url(id, index)
return url return url
end end
def produce_channel_playlists_url(ucid, cursor, sort = "newest")
cursor = Base64.urlsafe_encode(cursor, false)
meta = IO::Memory.new
meta.write(Bytes[0x12, 0x09])
meta.print("playlists")
# TODO: Look at 0x01, 0x00
case sort
when "oldest", "oldest_created"
meta.write(Bytes[0x18, 0x02])
when "newest", "newest_created"
meta.write(Bytes[0x18, 0x03])
when "last", "last_added"
meta.write(Bytes[0x18, 0x04])
end
meta.write(Bytes[0x20, 0x01])
meta.write(Bytes[0x30, 0x02])
meta.write(Bytes[0x38, 0x01])
meta.write(Bytes[0x60, 0x01])
meta.write(Bytes[0x6a, 0x00])
meta.write(Bytes[0x7a, cursor.size])
meta.print(cursor)
meta.write(Bytes[0xb8, 0x01, 0x00])
meta.rewind
meta = Base64.urlsafe_encode(meta.to_slice)
meta = URI.escape(meta)
continuation = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a])
continuation.write(write_var_int(meta.size))
continuation.print(meta)
continuation.rewind
continuation = continuation.gets_to_end
wrapper = IO::Memory.new
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
wrapper.write(write_var_int(continuation.size))
wrapper.print(continuation)
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
wrapper = URI.escape(wrapper)
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
return url
end
def extract_channel_playlists_cursor(url)
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
wrapper = URI.unescape(wrapper)
wrapper = Base64.decode(wrapper)
# 0xe2 0xa9 0x85 0xb2 0x02
wrapper += 5
continuation_size = read_var_int(wrapper[0, 4])
wrapper += write_var_int(continuation_size).size
continuation = wrapper[0, continuation_size]
# 0x12
continuation += 1
ucid_size = continuation[0]
continuation += 1
ucid = continuation[0, ucid_size]
continuation += ucid_size
# 0x1a
continuation += 1
meta_size = read_var_int(continuation[0, 4])
continuation += write_var_int(meta_size).size
meta = continuation[0, meta_size]
continuation += meta_size
meta = String.new(meta)
meta = URI.unescape(meta)
meta = Base64.decode(meta)
# 0x12 0x09 playlists
meta += 11
until meta[0] == 0x7a
tag = read_var_int(meta[0, 4])
meta += write_var_int(tag).size
value = meta[0]
meta += 1
end
# 0x7a
meta += 1
cursor_size = meta[0]
meta += 1
cursor = meta[0, cursor_size]
cursor = String.new(cursor)
cursor = URI.unescape(cursor)
cursor = Base64.decode_string(cursor)
return cursor
end
def fetch_playlist(plid, locale) def fetch_playlist(plid, locale)
client = make_client(YT_URL) client = make_client(YT_URL)
@ -345,7 +234,10 @@ def template_playlist(playlist)
html += <<-END_HTML html += <<-END_HTML
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}"> <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}">
<img style="width:100%;" src="/vi/#{video["videoId"]}/mqdefault.jpg"> <div class="thumbnail">
<img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p> <p style="width:100%">#{video["title"]}</p>
<p> <p>
<b style="width: 100%">#{video["author"]}</b> <b style="width: 100%">#{video["author"]}</b>

View File

@ -188,7 +188,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
end end
end end
if body.size > 0 if !body.empty?
token = head + "\x12" + body.size.unsafe_chr + body token = head + "\x12" + body.size.unsafe_chr + body
else else
token = head token = head

View File

@ -39,7 +39,12 @@ def fetch_decrypt_function(id = "CvFH_6DNRCY")
return decrypt_function return decrypt_function
end end
def decrypt_signature(a, code) def decrypt_signature(fmt, code)
if !fmt["s"]?
return ""
end
a = fmt["s"]
a = a.split("") a = a.split("")
code.each do |item| code.each do |item|
@ -53,7 +58,8 @@ def decrypt_signature(a, code)
end end
end end
return a.join("") signature = a.join("")
return "&#{fmt["sp"]?}=#{signature}"
end end
def splice(a, b) def splice(a, b)

View File

@ -12,7 +12,6 @@ class User
end end
add_mapping({ add_mapping({
id: Array(String),
updated: Time, updated: Time,
notifications: Array(String), notifications: Array(String),
subscriptions: Array(String), subscriptions: Array(String),
@ -126,49 +125,55 @@ class Preferences
end end
def get_user(sid, headers, db, refresh = true) def get_user(sid, headers, db, refresh = true)
if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE $1 = ANY(id))", sid, as: Bool) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
if refresh && Time.now - user.updated > 1.minute if refresh && Time.now - user.updated > 1.minute
user = fetch_user(sid, headers, db) user, sid = fetch_user(sid, headers, db)
user_array = user.to_a user_array = user.to_a
user_array[5] = user_array[5].to_json user_array[4] = user_array[4].to_json
args = arg_array(user_array) args = arg_array(user_array)
db.exec("INSERT INTO users VALUES (#{args}) \ db.exec("INSERT INTO users VALUES (#{args}) \
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array) ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
begin begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}" view_name = "subscriptions_#{sha256(user.email)[0..7]}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \ 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;") ORDER BY published DESC;")
rescue ex rescue ex
end end
end end
else else
user = fetch_user(sid, headers, db) user, sid = fetch_user(sid, headers, db)
user_array = user.to_a user_array = user.to_a
user_array[5] = user_array[5].to_json user_array[4] = user_array[4].to_json
args = arg_array(user.to_a) args = arg_array(user.to_a)
db.exec("INSERT INTO users VALUES (#{args}) \ db.exec("INSERT INTO users VALUES (#{args}) \
ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array) ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
begin begin
view_name = "subscriptions_#{sha256(user.email)[0..7]}" view_name = "subscriptions_#{sha256(user.email)[0..7]}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \ 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;") ORDER BY published DESC;")
rescue ex rescue ex
end end
end end
return user return user, sid
end end
def fetch_user(sid, headers, db) def fetch_user(sid, headers, db)
@ -196,17 +201,17 @@ def fetch_user(sid, headers, db)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new([sid], Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String) user = User.new(Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
return user return user, sid
end end
def create_user(sid, email, password) def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10) password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new([sid], Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String) user = User.new(Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
return user return user, sid
end end
def create_response(user_id, operation, key, db, expire = 6.hours) def create_response(user_id, operation, key, db, expire = 6.hours)
@ -242,7 +247,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
raise translate(locale, "Invalid challenge") raise translate(locale, "Invalid challenge")
end end
challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge) challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
challenge = Base64.urlsafe_encode(challenge) challenge = Base64.urlsafe_encode(challenge)
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool) if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)

View File

@ -263,7 +263,7 @@ class Video
end end
def keywords def keywords
keywords = self.player_response["videoDetails"]["keywords"]?.try &.as_a keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
keywords ||= [] of String keywords ||= [] of String
return keywords return keywords
@ -271,9 +271,51 @@ class Video
def fmt_stream(decrypt_function) def fmt_stream(decrypt_function)
streams = [] of HTTP::Params streams = [] of HTTP::Params
self.info["url_encoded_fmt_stream_map"].split(",") do |string|
if !string.empty? if fmt_streams = self.player_response["streamingData"]?.try &.["formats"]?
streams << HTTP::Params.parse(string) 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
end end
@ -286,10 +328,8 @@ class Video
end end
end end
if streams[0]? && streams[0]["s"]? streams.each do |fmt|
streams.each do |fmt| fmt["url"] += decrypt_signature(fmt, decrypt_function)
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end end
return streams return streams
@ -298,80 +338,54 @@ class Video
def adaptive_fmts(decrypt_function) def adaptive_fmts(decrypt_function)
adaptive_fmts = [] of HTTP::Params adaptive_fmts = [] of HTTP::Params
if self.info.has_key?("adaptive_fmts") if fmts = self.player_response["streamingData"]?.try &.["adaptiveFormats"]?
self.info["adaptive_fmts"].split(",") do |string| fmts.as_a.each do |adaptive_fmt|
adaptive_fmts << HTTP::Params.parse(string) if !adaptive_fmt.as_h?
end next
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\/(?<clen>\d+)/).try &.["clen"]
clen ||= "0"
lmt = url.match(/lmt\/(?<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)
end 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
end end
@ -381,23 +395,21 @@ class Video
end end
end end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]? adaptive_fmts.each do |fmt|
adaptive_fmts.each do |fmt| fmt["url"] += decrypt_signature(fmt, decrypt_function)
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
end
end end
return adaptive_fmts return adaptive_fmts
end end
def video_streams(adaptive_fmts) 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 return video_streams
end end
def audio_streams(adaptive_fmts) 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.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |stream| audio_streams.each do |stream|
stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s 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 # Try to pull streams from embed URL
if info["reason"]? 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*(?<sts>\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"]? if !embed_info["reason"]?
embed_info.each do |key, value| embed_info.each do |key, value|