mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-11-03 21:58:29 -06:00 
			
		
		
		
	Merge branch 'master' into api-only
This commit is contained in:
		
						commit
						51158c8c45
					
				
							
								
								
									
										24
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								README.md
									
									
									
									
									
								
							@ -34,8 +34,17 @@ Onion links:
 | 
			
		||||
 | 
			
		||||
[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
 | 
			
		||||
 | 
			
		||||
See [Invidious-Updater](https://github.com/tmiland/Invidious-Updater) for a self-contained script that can automatically install and update Invidious.
 | 
			
		||||
 | 
			
		||||
### Docker:
 | 
			
		||||
 | 
			
		||||
#### Build and start cluster:
 | 
			
		||||
@ -98,6 +107,7 @@ $ psql invidious < /home/invidious/invidious/config/sql/channels.sql
 | 
			
		||||
$ psql invidious < /home/invidious/invidious/config/sql/videos.sql
 | 
			
		||||
$ psql invidious < /home/invidious/invidious/config/sql/channel_videos.sql
 | 
			
		||||
$ psql invidious < /home/invidious/invidious/config/sql/users.sql
 | 
			
		||||
$ psql invidious < /home/invidious/invidious/config/sql/session_ids.sql
 | 
			
		||||
$ psql invidious < /home/invidious/invidious/config/sql/nonces.sql
 | 
			
		||||
$ exit
 | 
			
		||||
```
 | 
			
		||||
@ -107,7 +117,7 @@ $ exit
 | 
			
		||||
```bash
 | 
			
		||||
$ sudo -i -u invidious
 | 
			
		||||
$ cd invidious
 | 
			
		||||
$ shards
 | 
			
		||||
$ shards update && shards install
 | 
			
		||||
$ crystal build src/invidious.cr --release
 | 
			
		||||
# test compiled binary
 | 
			
		||||
$ ./invidious # stop with ctrl c
 | 
			
		||||
@ -115,6 +125,7 @@ $ exit
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### systemd service
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
$ sudo cp /home/invidious/invidious/invidious.service /etc/systemd/system/invidious.service
 | 
			
		||||
$ sudo systemctl enable invidious.service
 | 
			
		||||
@ -138,15 +149,17 @@ $ psql invidious < config/sql/channels.sql
 | 
			
		||||
$ psql invidious < config/sql/videos.sql
 | 
			
		||||
$ psql invidious < config/sql/channel_videos.sql
 | 
			
		||||
$ psql invidious < config/sql/users.sql
 | 
			
		||||
$ psql invidious < config/sql/session_ids.sql
 | 
			
		||||
$ psql invidious < config/sql/nonces.sql
 | 
			
		||||
 | 
			
		||||
# Setup Invidious
 | 
			
		||||
$ shards
 | 
			
		||||
$ shards update && shards install
 | 
			
		||||
$ crystal build src/invidious.cr --release
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Update Invidious
 | 
			
		||||
You can find information about how to update in the wiki: [Updating](https://github.com/omarroth/invidious/wiki/Updating).
 | 
			
		||||
 | 
			
		||||
You can see how to update Invidious [here](https://github.com/omarroth/invidious/wiki/Updating).
 | 
			
		||||
 | 
			
		||||
## Usage:
 | 
			
		||||
 | 
			
		||||
@ -178,16 +191,19 @@ $ ./sentry
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
[Documentation](https://github.com/omarroth/invidious/wiki) can be found in the wiki.
 | 
			
		||||
 | 
			
		||||
## Extensions
 | 
			
		||||
Extensions for Invidious and for integrating Invidious into other projects [are in the wiki](https://github.com/omarroth/invidious/wiki/Extensions)
 | 
			
		||||
 | 
			
		||||
[Extensions](https://github.com/omarroth/invidious/wiki/Extensions) can be found in the wiki, as well as documentation for integrating it into other projects.
 | 
			
		||||
 | 
			
		||||
## Made with Invidious
 | 
			
		||||
 | 
			
		||||
- [FreeTube](https://github.com/FreeTubeApp/FreeTube): An Open Source YouTube app for privacy.
 | 
			
		||||
- [CloudTube](https://github.com/cloudrac3r/cadencegq): Website featuring pastebin, image host, and YouTube player
 | 
			
		||||
- [PeerTubeify](https://gitlab.com/Ealhad/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists.
 | 
			
		||||
- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A materialistic music player that streams music from YouTube.
 | 
			
		||||
 | 
			
		||||
## Contributing
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -10,4 +10,4 @@ db:
 | 
			
		||||
  dbname: invidious
 | 
			
		||||
full_refresh: false
 | 
			
		||||
https_only: false
 | 
			
		||||
domain: invidio.us
 | 
			
		||||
domain:
 | 
			
		||||
 | 
			
		||||
@ -1,4 +0,0 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
 | 
			
		||||
psql invidious -c "ALTER TABLE channels ADD COLUMN deleted bool;"
 | 
			
		||||
psql invidious -c "UPDATE channels SET deleted = false;"
 | 
			
		||||
@ -9,7 +9,7 @@ ADD . /invidious
 | 
			
		||||
WORKDIR /invidious
 | 
			
		||||
 | 
			
		||||
RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \
 | 
			
		||||
    shards && \
 | 
			
		||||
    shards update && shards install && \
 | 
			
		||||
    crystal build src/invidious.cr
 | 
			
		||||
 | 
			
		||||
CMD [ "/invidious/invidious" ]
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
 | 
			
		||||
    su postgres -c 'psql invidious < config/sql/videos.sql'
 | 
			
		||||
    su postgres -c 'psql invidious < config/sql/channel_videos.sql'
 | 
			
		||||
    su postgres -c 'psql invidious < config/sql/users.sql'
 | 
			
		||||
    su postgres -c 'psql invidious < config/sql/session_ids.sql'
 | 
			
		||||
    su postgres -c 'psql invidious < config/sql/nonces.sql'
 | 
			
		||||
    touch /var/lib/postgresql/data/setupFinished
 | 
			
		||||
    echo "### invidious database setup finished"
 | 
			
		||||
 | 
			
		||||
@ -82,6 +82,14 @@
 | 
			
		||||
  "Manage subscriptions": "إدارة المشتركين",
 | 
			
		||||
  "Watch history": "سجل المشاهدة",
 | 
			
		||||
  "Delete account": "حذف الحساب",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "حفظ التفضيلات",
 | 
			
		||||
  "Subscription manager": "مدير الإشتراكات",
 | 
			
		||||
  "`x` subscriptions": "`x` مشتركين",
 | 
			
		||||
@ -280,5 +288,7 @@
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "`x` marked it with a ❤": "",
 | 
			
		||||
  "Audio mode": "",
 | 
			
		||||
  "Video mode": ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -82,6 +82,14 @@
 | 
			
		||||
  "Manage subscriptions": "Abonnements verwalten",
 | 
			
		||||
  "Watch history": "Verlauf",
 | 
			
		||||
  "Delete account": "Account löschen",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Einstellungen speichern",
 | 
			
		||||
  "Subscription manager": "Abonnementverwaltung",
 | 
			
		||||
  "`x` subscriptions": "`x` Abonnements",
 | 
			
		||||
@ -264,9 +272,9 @@
 | 
			
		||||
  "`x` hours": "`x` Stunden",
 | 
			
		||||
  "`x` minutes": "`x` Minuten",
 | 
			
		||||
  "`x` seconds": "`x` Sekunden",
 | 
			
		||||
  "Fallback comments: ": "",
 | 
			
		||||
  "Fallback comments: ": "Alternative Kommentare: ",
 | 
			
		||||
  "Popular": "Populär",
 | 
			
		||||
  "Top": "",
 | 
			
		||||
  "Top": "Top",
 | 
			
		||||
  "About": "Über",
 | 
			
		||||
  "Rating: ": "Bewertung: ",
 | 
			
		||||
  "Language: ": "Sprache: ",
 | 
			
		||||
@ -280,5 +288,7 @@
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "`x` marked it with a ❤": "",
 | 
			
		||||
  "Audio mode": "",
 | 
			
		||||
  "Video mode": ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -80,6 +80,14 @@
 | 
			
		||||
  "Manage subscriptions": "Manage subscriptions",
 | 
			
		||||
  "Watch history": "Watch history",
 | 
			
		||||
  "Delete account": "Delete account",
 | 
			
		||||
  "Administrator preferences": "Administrator preferences",
 | 
			
		||||
  "Default homepage: ": "Default homepage: ",
 | 
			
		||||
  "Feed menu: ": "Feed menu: ",
 | 
			
		||||
  "Top enabled? ": "Top enabled? ",
 | 
			
		||||
  "CAPTCHA enabled? ": "CAPTCHA enabled? ",
 | 
			
		||||
  "Login enabled? ": "Login enabled? ",
 | 
			
		||||
  "Registration enabled? ": "Registration enabled? ",
 | 
			
		||||
  "Report statistics? ": "Report statistics? ",
 | 
			
		||||
  "Save preferences": "Save preferences",
 | 
			
		||||
  "Subscription manager": "Subscription manager",
 | 
			
		||||
  "`x` subscriptions": "`x` subscriptions",
 | 
			
		||||
@ -274,5 +282,7 @@
 | 
			
		||||
  "%A %B %-d, %Y": "%A %B %-d, %Y",
 | 
			
		||||
  "(edited)": "(edited)",
 | 
			
		||||
  "Youtube permalink of the comment": "Youtube permalink of the comment",
 | 
			
		||||
  "`x` marked it with a ❤": "`x` marked it with a ❤"
 | 
			
		||||
  "`x` marked it with a ❤": "`x` marked it with a ❤",
 | 
			
		||||
  "Audio mode": "Audio mode",
 | 
			
		||||
  "Video mode": "Video mode"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,11 @@
 | 
			
		||||
{
 | 
			
		||||
  "`x` subscribers": "",
 | 
			
		||||
  "`x` videos": "",
 | 
			
		||||
  "LIVE": "",
 | 
			
		||||
  "Shared `x` ago": "",
 | 
			
		||||
  "Unsubscribe": "",
 | 
			
		||||
  "`x` subscribers": "`x` harpidedun",
 | 
			
		||||
  "`x` videos": "`x` bideo",
 | 
			
		||||
  "LIVE": "ZUZENEAN",
 | 
			
		||||
  "Shared `x` ago": "Duela `x` partekatua",
 | 
			
		||||
  "Unsubscribe": "Harpidetza kendu",
 | 
			
		||||
  "Subscribe": "Harpidetu",
 | 
			
		||||
  "Login to subscribe to `x`": "",
 | 
			
		||||
  "Login to subscribe to `x`": "Saioa hasi `x`(e)ra harpidetzeko",
 | 
			
		||||
  "View channel on YouTube": "Ikusi kanala YouTuben",
 | 
			
		||||
  "newest": "berrienak",
 | 
			
		||||
  "oldest": "zaharrenak",
 | 
			
		||||
@ -24,22 +24,22 @@
 | 
			
		||||
  "Import NewPipe data (.zip)": "NewPipeko datuak inportatu (.zip)",
 | 
			
		||||
  "Export": "Esportatu",
 | 
			
		||||
  "Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala",
 | 
			
		||||
  "Export subscriptions as OPML (for NewPipe & FreeTube)": "",
 | 
			
		||||
  "Export data as JSON": "",
 | 
			
		||||
  "Export subscriptions as OPML (for NewPipe & FreeTube)": "Harpidetzak OPML bezala esportatu (NewPipe eta FreeTuberako)",
 | 
			
		||||
  "Export data as JSON": "Datuak JSON bezala esportatu",
 | 
			
		||||
  "Delete account?": "Kontua ezabatu?",
 | 
			
		||||
  "History": "Historia",
 | 
			
		||||
  "Previous page": "Aurreko orria",
 | 
			
		||||
  "An alternative front-end to YouTube": "",
 | 
			
		||||
  "JavaScript license information": "",
 | 
			
		||||
  "source": "",
 | 
			
		||||
  "Login": "",
 | 
			
		||||
  "Login/Register": "",
 | 
			
		||||
  "Login to Google": "",
 | 
			
		||||
  "User ID:": "",
 | 
			
		||||
  "Password:": "",
 | 
			
		||||
  "Time (h:mm:ss):": "",
 | 
			
		||||
  "Text CAPTCHA": "",
 | 
			
		||||
  "Image CAPTCHA": "",
 | 
			
		||||
  "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
 | 
			
		||||
  "JavaScript license information": "JavaScript lizentzia informazioa",
 | 
			
		||||
  "source": "iturburua",
 | 
			
		||||
  "Login": "Saioa hasi",
 | 
			
		||||
  "Login/Register": "Saioa hasi/Izena eman",
 | 
			
		||||
  "Login to Google": "Googlekin hasi saioa",
 | 
			
		||||
  "User ID:": "Erabiltzaile IDa:",
 | 
			
		||||
  "Password:": "Pasahitza:",
 | 
			
		||||
  "Time (h:mm:ss):": "Denbora (o:mm:ss):",
 | 
			
		||||
  "Text CAPTCHA": "Testu CAPTCHA",
 | 
			
		||||
  "Image CAPTCHA": "Irudi CAPTCHA",
 | 
			
		||||
  "Sign In": "",
 | 
			
		||||
  "Register": "",
 | 
			
		||||
  "Email:": "",
 | 
			
		||||
@ -80,6 +80,14 @@
 | 
			
		||||
  "Manage subscriptions": "",
 | 
			
		||||
  "Watch history": "",
 | 
			
		||||
  "Delete account": "",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "",
 | 
			
		||||
  "Subscription manager": "",
 | 
			
		||||
  "`x` subscriptions": "",
 | 
			
		||||
@ -274,5 +282,7 @@
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "`x` marked it with a ❤": "",
 | 
			
		||||
  "Audio mode": "",
 | 
			
		||||
  "Video mode": ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										233
									
								
								locales/fr.json
									
									
									
									
									
								
							
							
						
						
									
										233
									
								
								locales/fr.json
									
									
									
									
									
								
							@ -1,152 +1,159 @@
 | 
			
		||||
{
 | 
			
		||||
  "`x` subscribers": "`x` souscripteurs",
 | 
			
		||||
  "`x` subscribers": "`x` abonnés",
 | 
			
		||||
  "`x` videos": "`x` vidéos",
 | 
			
		||||
  "LIVE": "LIVE",
 | 
			
		||||
  "LIVE": "EN DIRECT",
 | 
			
		||||
  "Shared `x` ago": "Partagé il y a `x`",
 | 
			
		||||
  "Unsubscribe": "Se désabonner",
 | 
			
		||||
  "Subscribe": "S'abonner",
 | 
			
		||||
  "Login to subscribe to `x`": "Se connecter pour s'abonner à `x`",
 | 
			
		||||
  "Login to subscribe to `x`": "Vous devez vous connecter pour vous abonner à `x`",
 | 
			
		||||
  "View channel on YouTube": "Voir la chaîne sur YouTube",
 | 
			
		||||
  "newest": "récent",
 | 
			
		||||
  "oldest": "aînée",
 | 
			
		||||
  "popular": "appréciés",
 | 
			
		||||
  "Preview page": "Page de prévisualisation",
 | 
			
		||||
  "newest": "Date d'ajout (la plus récente)",
 | 
			
		||||
  "oldest": "Date d'ajout (la plus ancienne)",
 | 
			
		||||
  "popular": "Les plus populaires",
 | 
			
		||||
  "Next page": "Page suivante",
 | 
			
		||||
  "Clear watch history?": "L'histoire de la montre est claire?",
 | 
			
		||||
  "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
 | 
			
		||||
  "Yes": "Oui",
 | 
			
		||||
  "No": "Aucun",
 | 
			
		||||
  "Import and Export Data": "Importation et exportation de données",
 | 
			
		||||
  "Import": "Importation",
 | 
			
		||||
  "Import Invidious data": "Importation de données invalides",
 | 
			
		||||
  "No": "Non",
 | 
			
		||||
  "Import and Export Data": "Importer et Exporter les Données",
 | 
			
		||||
  "Import": "Importer",
 | 
			
		||||
  "Import Invidious data": "Importer des données Invidious",
 | 
			
		||||
  "Import YouTube subscriptions": "Importer des abonnements YouTube",
 | 
			
		||||
  "Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)",
 | 
			
		||||
  "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)",
 | 
			
		||||
  "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)",
 | 
			
		||||
  "Export": "Exporter",
 | 
			
		||||
  "Export subscriptions as OPML": "Exporter les abonnements comme OPML",
 | 
			
		||||
  "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements comme OPML (pour NewPipe & FreeTube)",
 | 
			
		||||
  "Export subscriptions as OPML": "Exporter les abonnements en OPML",
 | 
			
		||||
  "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
 | 
			
		||||
  "Export data as JSON": "Exporter les données au format JSON",
 | 
			
		||||
  "Delete account?": "Supprimer un compte ?",
 | 
			
		||||
  "History": "Histoire",
 | 
			
		||||
  "Delete account?": "Supprimer votre compte ?",
 | 
			
		||||
  "History": "Historique",
 | 
			
		||||
  "Previous page": "Page précédente",
 | 
			
		||||
  "An alternative front-end to YouTube": "Un frontal alternatif à YouTube",
 | 
			
		||||
  "JavaScript license information": "Informations sur la licence JavaScript",
 | 
			
		||||
  "source": "origine",
 | 
			
		||||
  "An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
 | 
			
		||||
  "JavaScript license information": "Informations sur les licences JavaScript",
 | 
			
		||||
  "source": "source",
 | 
			
		||||
  "Login": "Connexion",
 | 
			
		||||
  "Login/Register": "Connexion/S'inscrire",
 | 
			
		||||
  "Login to Google": "Se connecter à Google",
 | 
			
		||||
  "User ID:": "ID utilisateur:",
 | 
			
		||||
  "Password:": "Mot de passe:",
 | 
			
		||||
  "Time (h:mm:ss):": "Temps (h:mm:ss):",
 | 
			
		||||
  "Text CAPTCHA": "Texte CAPTCHA",
 | 
			
		||||
  "Image CAPTCHA": "Image CAPTCHA",
 | 
			
		||||
  "User ID:": "ID utilisateur :",
 | 
			
		||||
  "Password:": "Mot de passe :",
 | 
			
		||||
  "Time (h:mm:ss):": "Heure (h:mm:ss) :",
 | 
			
		||||
  "Text CAPTCHA": "CAPTCHA Texte",
 | 
			
		||||
  "Image CAPTCHA": "CAPTCHA Image",
 | 
			
		||||
  "Sign In": "S'identifier",
 | 
			
		||||
  "Register": "S'inscrire",
 | 
			
		||||
  "Email:": "Courriel:",
 | 
			
		||||
  "Google verification code:": "Code de vérification Google:",
 | 
			
		||||
  "Email:": "Email :",
 | 
			
		||||
  "Google verification code:": "Code de vérification Google :",
 | 
			
		||||
  "Preferences": "Préférences",
 | 
			
		||||
  "Player preferences": "Joueur préférences",
 | 
			
		||||
  "Always loop: ": "Toujours en boucle: ",
 | 
			
		||||
  "Autoplay: ": "Autoplay: ",
 | 
			
		||||
  "Autoplay next video: ": "Lecture automatique de la vidéo suivante: ",
 | 
			
		||||
  "Listen by default: ": "Écouter par défaut: ",
 | 
			
		||||
  "Default speed: ": "Vitesse par défaut: ",
 | 
			
		||||
  "Preferred video quality: ": "Qualité vidéo préférée: ",
 | 
			
		||||
  "Player volume: ": "Volume de lecteur: ",
 | 
			
		||||
  "Default comments: ": "Commentaires par défaut: ",
 | 
			
		||||
  "Default captions: ": "Légendes par défaut: ",
 | 
			
		||||
  "Fallback captions: ": "Légendes de repli: ",
 | 
			
		||||
  "Show related videos? ": "Voir les vidéos liées à ce sujet? ",
 | 
			
		||||
  "Player preferences": "Préférences du Lecteur",
 | 
			
		||||
  "Always loop: ": "Lire en boucle : ",
 | 
			
		||||
  "Autoplay: ": "Lire Automatiquement : ",
 | 
			
		||||
  "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
 | 
			
		||||
  "Listen by default: ": "Audio Uniquement par défaut : ",
 | 
			
		||||
  "Default speed: ": "Vitesse par défaut : ",
 | 
			
		||||
  "Preferred video quality: ": "Qualité vidéo souhaitée : ",
 | 
			
		||||
  "Player volume: ": "Volume du lecteur : ",
 | 
			
		||||
  "Default comments: ": "Source des Commentaires : ",
 | 
			
		||||
  "Default captions: ": "Sous-titres principal : ",
 | 
			
		||||
  "Fallback captions: ": "Sous-titres secondaire : ",
 | 
			
		||||
  "Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
 | 
			
		||||
  "Visual preferences": "Préférences visuelles",
 | 
			
		||||
  "Dark mode: ": "Mode sombre: ",
 | 
			
		||||
  "Thin mode: ": "Mode Thin: ",
 | 
			
		||||
  "Subscription preferences": "Préférences d'abonnement",
 | 
			
		||||
  "Redirect homepage to feed: ": "Rediriger la page d'accueil vers le flux: ",
 | 
			
		||||
  "Number of videos shown in feed: ": "Nombre de vidéos montrées dans le flux: ",
 | 
			
		||||
  "Sort videos by: ": "Trier les vidéos par: ",
 | 
			
		||||
  "published": "publié",
 | 
			
		||||
  "published - reverse": "publié - reverse",
 | 
			
		||||
  "Dark mode: ": "Mode Sombre : ",
 | 
			
		||||
  "Thin mode: ": "Mode Simplifié : ",
 | 
			
		||||
  "Subscription preferences": "Préférences de la page d'abonnements",
 | 
			
		||||
  "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
 | 
			
		||||
  "Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
 | 
			
		||||
  "Sort videos by: ": "Trier les vidéos par : ",
 | 
			
		||||
  "published": "publication",
 | 
			
		||||
  "published - reverse": "publication - inversé",
 | 
			
		||||
  "alphabetically": "alphabétiquement",
 | 
			
		||||
  "alphabetically - reverse": "alphabétiquement - contraire",
 | 
			
		||||
  "channel name": "nom du canal",
 | 
			
		||||
  "channel name - reverse": "nom du canal - contraire",
 | 
			
		||||
  "Only show latest video from channel: ": "Afficher uniquement les dernières vidéos de la chaîne: ",
 | 
			
		||||
  "Only show latest unwatched video from channel: ": "Afficher uniquement les dernières vidéos non regardées de la chaîne: ",
 | 
			
		||||
  "Only show unwatched: ": "Afficher uniquement les images non surveillées: ",
 | 
			
		||||
  "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a): ",
 | 
			
		||||
  "Data preferences": "Préférences de données",
 | 
			
		||||
  "Clear watch history": "Historique clair de la montre",
 | 
			
		||||
  "Import/Export data": "Données d'importation/exportation",
 | 
			
		||||
  "alphabetically - reverse": "alphabétiquement - inversé",
 | 
			
		||||
  "channel name": "nom de la chaîne",
 | 
			
		||||
  "channel name - reverse": "nom de la chaîne - inversé",
 | 
			
		||||
  "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne : ",
 | 
			
		||||
  "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
 | 
			
		||||
  "Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ",
 | 
			
		||||
  "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
 | 
			
		||||
  "Data preferences": "Préférences liées aux données",
 | 
			
		||||
  "Clear watch history": "Supprimer l'historique des vidéos regardées",
 | 
			
		||||
  "Import/Export data": "Importer/exporter les données",
 | 
			
		||||
  "Manage subscriptions": "Gérer les abonnements",
 | 
			
		||||
  "Watch history": "Historique des montres",
 | 
			
		||||
  "Delete account": "Supprimer un compte",
 | 
			
		||||
  "Watch history": "Historique de visionnage",
 | 
			
		||||
  "Delete account": "Supprimer votre compte",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Enregistrer les préférences",
 | 
			
		||||
  "Subscription manager": "Gestionnaire d'abonnement",
 | 
			
		||||
  "`x` subscriptions": "`x` abonnements",
 | 
			
		||||
  "Import/Export": "Importer/Exporter",
 | 
			
		||||
  "unsubscribe": "se désabonner",
 | 
			
		||||
  "Subscriptions": "Abonnements",
 | 
			
		||||
  "`x` unseen notifications": "`x` notifications invisibles",
 | 
			
		||||
  "search": "perquisition",
 | 
			
		||||
  "`x` unseen notifications": "`x` notifications non vues",
 | 
			
		||||
  "search": "Rechercher",
 | 
			
		||||
  "Sign out": "Déconnexion",
 | 
			
		||||
  "Released under the AGPLv3 by Omar Roth.": "Publié sous l'AGPLv3 par Omar Roth.",
 | 
			
		||||
  "Source available here.": "Source disponible ici.",
 | 
			
		||||
  "View JavaScript license information.": "Voir les informations de licence JavaScript.",
 | 
			
		||||
  "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
 | 
			
		||||
  "Source available here.": "Code Source.",
 | 
			
		||||
  "View JavaScript license information.": "Voir les informations des licences JavaScript.",
 | 
			
		||||
  "Trending": "Tendances",
 | 
			
		||||
  "Watch video on Youtube": "Voir la vidéo sur Youtube",
 | 
			
		||||
  "Genre: ": "Genre: ",
 | 
			
		||||
  "License: ": "Licence: ",
 | 
			
		||||
  "Family friendly? ": "Convivialité familiale? ",
 | 
			
		||||
  "Wilson score: ": "Wilson marque: ",
 | 
			
		||||
  "Engagement: ": "Fiançailles: ",
 | 
			
		||||
  "Whitelisted regions: ": "Régions en liste blanche: ",
 | 
			
		||||
  "Blacklisted regions: ": "Régions sur liste noire: ",
 | 
			
		||||
  "Genre: ": "Genre : ",
 | 
			
		||||
  "License: ": "Licence : ",
 | 
			
		||||
  "Family friendly? ": "Tout Public ? ",
 | 
			
		||||
  "Wilson score: ": "Score de Wilson : ",
 | 
			
		||||
  "Engagement: ": "Poucentage de spectateur aillant aimé Liker ou Disliker la vidéo : ",
 | 
			
		||||
  "Whitelisted regions: ": "Régions en liste blanche : ",
 | 
			
		||||
  "Blacklisted regions: ": "Régions sur liste noire : ",
 | 
			
		||||
  "Shared `x`": "Partagée `x`",
 | 
			
		||||
  "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Hi! On dirait que vous avez désactivé JavaScript. Cliquez ici pour voir les commentaires, gardez à l'esprit que le chargement peut prendre un peu plus de temps.",
 | 
			
		||||
  "View YouTube comments": "Voir les commentaires sur YouTube",
 | 
			
		||||
  "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Il semblerait que JavaScript sois désactivé. Cliquez ici pour voir les commentaires. Gardez à l'esprit que le chargement peut prendre plus de temps.",
 | 
			
		||||
  "View YouTube comments": "Voir les commentaires YouTube",
 | 
			
		||||
  "View more comments on Reddit": "Voir plus de commentaires sur Reddit",
 | 
			
		||||
  "View `x` comments": "Voir `x` commentaires",
 | 
			
		||||
  "View Reddit comments": "Voir Reddit commentaires",
 | 
			
		||||
  "View Reddit comments": "Voir les commentaires Reddit",
 | 
			
		||||
  "Hide replies": "Masquer les réponses",
 | 
			
		||||
  "Show replies": "Afficher les réponses",
 | 
			
		||||
  "Incorrect password": "Mot de passe incorrect",
 | 
			
		||||
  "Quota exceeded, try again in a few hours": "Quota dépassé, réessayez dans quelques heures",
 | 
			
		||||
  "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassé, réessayez dans quelques heures",
 | 
			
		||||
  "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.",
 | 
			
		||||
  "Invalid TFA code": "Code TFA invalide",
 | 
			
		||||
  "Invalid TFA code": "Code d'authentification à deux facteurs invalide",
 | 
			
		||||
  "Login failed. This may be because two-factor authentication is not enabled on your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.",
 | 
			
		||||
  "Invalid answer": "Réponse non valide",
 | 
			
		||||
  "Invalid CAPTCHA": "CAPTCHA invalide",
 | 
			
		||||
  "CAPTCHA is a required field": "CAPTCHA est un champ obligatoire",
 | 
			
		||||
  "User ID is a required field": "Utilisateur ID est un champ obligatoire",
 | 
			
		||||
  "Password is a required field": "Mot de passe est un champ obligatoire",
 | 
			
		||||
  "CAPTCHA is a required field": "Veuillez rentrez un CAPTCHA",
 | 
			
		||||
  "User ID is a required field": "Veuillez rentrez un Identifiant Utilisateur",
 | 
			
		||||
  "Password is a required field": "Veuillez rentrez un Mot de passe",
 | 
			
		||||
  "Invalid username or password": "Nom d'utilisateur ou mot de passe invalide",
 | 
			
		||||
  "Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant 'S'identifier avec Google'",
 | 
			
		||||
  "Please sign in using 'Sign in with Google'": "Veuillez vous connecter en utilisant \"S'identifier avec Google\"",
 | 
			
		||||
  "Password cannot be empty": "Le mot de passe ne peut pas être vide",
 | 
			
		||||
  "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères.",
 | 
			
		||||
  "Please sign in": "Veuillez ouvrir une session",
 | 
			
		||||
  "Invidious Private Feed for `x`": "Flux privé Invidious pour `x`",
 | 
			
		||||
  "channel:`x`": "chenal:`x`",
 | 
			
		||||
  "Deleted or invalid channel": "Canal supprimé ou non valide",
 | 
			
		||||
  "This channel does not exist.": "Ce canal n'existe pas.",
 | 
			
		||||
  "Could not get channel info.": "Impossible d'obtenir des informations sur les chaînes.",
 | 
			
		||||
  "Could not fetch comments": "Impossible d'aller chercher les commentaires",
 | 
			
		||||
  "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères",
 | 
			
		||||
  "Please sign in": "Veuillez vous connecter",
 | 
			
		||||
  "Invidious Private Feed for `x`": "Flux RSS privé pour `x`",
 | 
			
		||||
  "channel:`x`": "chaîne:`x`",
 | 
			
		||||
  "Deleted or invalid channel": "Chaîne supprimée ou invalide",
 | 
			
		||||
  "This channel does not exist.": "Cette chaine n'existe pas.",
 | 
			
		||||
  "Could not get channel info.": "Impossible de charger les informations de cette chaîne.",
 | 
			
		||||
  "Could not fetch comments": "Impossible de charger les commentaires",
 | 
			
		||||
  "View `x` replies": "Voir `x` réponses",
 | 
			
		||||
  "`x` ago": "il y a `x`",
 | 
			
		||||
  "Load more": "Charger plus",
 | 
			
		||||
  "`x` points": "`x` points",
 | 
			
		||||
  "Could not create mix.": "Impossible de créer du mixage.",
 | 
			
		||||
  "Could not create mix.": "Impossible de charger cette liste de lecture.",
 | 
			
		||||
  "Playlist is empty": "La liste de lecture est vide",
 | 
			
		||||
  "Invalid playlist.": "Liste de lecture invalide.",
 | 
			
		||||
  "Playlist does not exist.": "La liste de lecture n'existe pas.",
 | 
			
		||||
  "Could not pull trending pages.": "Impossible de tirer les pages de tendances.",
 | 
			
		||||
  "Hidden field \"challenge\" is a required field": "Champ caché \"contestation\" est un champ obligatoire",
 | 
			
		||||
  "Hidden field \"token\" is a required field": "Champ caché \"jeton\" est un champ obligatoire",
 | 
			
		||||
  "Invalid challenge": "Contestation non valide",
 | 
			
		||||
  "Invalid token": "Jeton non valide",
 | 
			
		||||
  "Invalid user": "Iutilisateur non valide",
 | 
			
		||||
  "Token is expired, please try again": "Le jeton est expiré, veuillez réessayer",
 | 
			
		||||
  "Could not pull trending pages.": "Impossible de charger les pages de tendances.",
 | 
			
		||||
  "Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field",
 | 
			
		||||
  "Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field",
 | 
			
		||||
  "Invalid challenge": "Invalid challenge",
 | 
			
		||||
  "Invalid token": "Invalid token",
 | 
			
		||||
  "Invalid user": "Invalid user",
 | 
			
		||||
  "Token is expired, please try again": "Token is expired, please try again",
 | 
			
		||||
  "English": "Anglais",
 | 
			
		||||
  "English (auto-generated)": "Anglais (auto-généré)",
 | 
			
		||||
  "English (auto-generated)": "Anglais (générés automatiquement)",
 | 
			
		||||
  "Afrikaans": "Afrikaans",
 | 
			
		||||
  "Albanian": "Albanais",
 | 
			
		||||
  "Amharic": "Amharique",
 | 
			
		||||
@ -258,21 +265,23 @@
 | 
			
		||||
  "`x` hours": "`x` heures",
 | 
			
		||||
  "`x` minutes": "`x` minutes",
 | 
			
		||||
  "`x` seconds": "`x` secondes",
 | 
			
		||||
  "Fallback comments: ": "Commentaires de repli: ",
 | 
			
		||||
  "Fallback comments: ": "Commentaires secondaires : ",
 | 
			
		||||
  "Popular": "Populaire",
 | 
			
		||||
  "Top": "Haut",
 | 
			
		||||
  "About": "Sur",
 | 
			
		||||
  "Rating: ": "Évaluation: ",
 | 
			
		||||
  "Language: ": "Langue: ",
 | 
			
		||||
  "Default": "",
 | 
			
		||||
  "Music": "",
 | 
			
		||||
  "Gaming": "",
 | 
			
		||||
  "News": "",
 | 
			
		||||
  "Movies": "",
 | 
			
		||||
  "Download": "",
 | 
			
		||||
  "Download as: ": "",
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "Top": "Top",
 | 
			
		||||
  "About": "A Propos",
 | 
			
		||||
  "Rating: ": "Évaluation : ",
 | 
			
		||||
  "Language: ": "Langue : ",
 | 
			
		||||
  "Default": "Défaut",
 | 
			
		||||
  "Music": "Musique",
 | 
			
		||||
  "Gaming": "Jeux Vidéo",
 | 
			
		||||
  "News": "Actualités",
 | 
			
		||||
  "Movies": "Films",
 | 
			
		||||
  "Download": "Télécharger",
 | 
			
		||||
  "Download as: ": "Télécharger en : ",
 | 
			
		||||
  "%A %B %-d, %Y": "%A %-d %B %Y",
 | 
			
		||||
  "(edited)": "(modifié)",
 | 
			
		||||
  "Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
 | 
			
		||||
  "`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
 | 
			
		||||
  "Audio mode": "Mode Audio",
 | 
			
		||||
  "Video mode": "Mode Vidéo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										287
									
								
								locales/it.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								locales/it.json
									
									
									
									
									
										Normal 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"
 | 
			
		||||
}
 | 
			
		||||
@ -80,6 +80,14 @@
 | 
			
		||||
  "Manage subscriptions": "Behandle abonnementer",
 | 
			
		||||
  "Watch history": "Visningshistorikk",
 | 
			
		||||
  "Delete account": "Slett konto",
 | 
			
		||||
  "Administrator preferences": "Administratorinnstillinger",
 | 
			
		||||
  "Default homepage: ": "Forvalgt hjemmeside: ",
 | 
			
		||||
  "Feed menu: ": "Flyt-meny: ",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "CAPTCHA påskrudd? ",
 | 
			
		||||
  "Login enabled? ": "Innlogging påskrudd? ",
 | 
			
		||||
  "Registration enabled? ": "Registrering påskrudd? ",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Lagre innstillinger",
 | 
			
		||||
  "Subscription manager": "Abonnementsbehandler",
 | 
			
		||||
  "`x` subscriptions": "`x` abonnementer",
 | 
			
		||||
@ -264,15 +272,17 @@
 | 
			
		||||
  "About": "Om",
 | 
			
		||||
  "Rating: ": "Vurdering: ",
 | 
			
		||||
  "Language: ": "Språk: ",
 | 
			
		||||
  "Default": "",
 | 
			
		||||
  "Music": "",
 | 
			
		||||
  "Gaming": "",
 | 
			
		||||
  "News": "",
 | 
			
		||||
  "Movies": "",
 | 
			
		||||
  "Download": "",
 | 
			
		||||
  "Download as: ": "",
 | 
			
		||||
  "Default": "Forvalg",
 | 
			
		||||
  "Music": "Musikk",
 | 
			
		||||
  "Gaming": "Spill",
 | 
			
		||||
  "News": "Nyheter",
 | 
			
		||||
  "Movies": "Filmer",
 | 
			
		||||
  "Download": "Last ned",
 | 
			
		||||
  "Download as: ": "Last ned som: ",
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "(edited)": "(redigert)",
 | 
			
		||||
  "Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
 | 
			
		||||
  "`x` marked it with a ❤": "`x` levnet et ❤",
 | 
			
		||||
  "Audio mode": "Lydmodus",
 | 
			
		||||
  "Video mode": "Video-modus"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -80,6 +80,14 @@
 | 
			
		||||
  "Manage subscriptions": "Abonnees beheren",
 | 
			
		||||
  "Watch history": "Kijkgeschiedenis",
 | 
			
		||||
  "Delete account": "Account verwijderen",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Opslaan voorkeuren",
 | 
			
		||||
  "Subscription manager": "Abonnees beheerder",
 | 
			
		||||
  "`x` subscriptions": "`x` abonnees",
 | 
			
		||||
@ -274,5 +282,7 @@
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "`x` marked it with a ❤": "",
 | 
			
		||||
  "Audio mode": "",
 | 
			
		||||
  "Video mode": ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										178
									
								
								locales/pl.json
									
									
									
									
									
								
							
							
						
						
									
										178
									
								
								locales/pl.json
									
									
									
									
									
								
							@ -29,7 +29,7 @@
 | 
			
		||||
  "Delete account?": "Usunąć konto?",
 | 
			
		||||
  "History": "Historia",
 | 
			
		||||
  "Previous page": "Poprzednia strona",
 | 
			
		||||
  "An alternative front-end to YouTube": "",
 | 
			
		||||
  "An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
 | 
			
		||||
  "JavaScript license information": "Informacja o licencji JavaScript",
 | 
			
		||||
  "source": "źródło",
 | 
			
		||||
  "Login": "Zaloguj",
 | 
			
		||||
@ -80,6 +80,14 @@
 | 
			
		||||
  "Manage subscriptions": "Organizuj subskrybcje",
 | 
			
		||||
  "Watch history": "Historia",
 | 
			
		||||
  "Delete account": "Usuń konto",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Zapisz preferencje",
 | 
			
		||||
  "Subscription manager": "Manager subskrybcji",
 | 
			
		||||
  "`x` subscriptions": "`x` subskrybcji",
 | 
			
		||||
@ -145,107 +153,107 @@
 | 
			
		||||
  "Invalid token": "Niepoprawny token",
 | 
			
		||||
  "Invalid user": "Niepoprawny użytkownik",
 | 
			
		||||
  "Token is expired, please try again": "Token wygasł, spróbuj ponownie",
 | 
			
		||||
  "English": "",
 | 
			
		||||
  "English (auto-generated)": "",
 | 
			
		||||
  "English": "angielski",
 | 
			
		||||
  "English (auto-generated)": "angielski (automatycznie generowane)",
 | 
			
		||||
  "Afrikaans": "",
 | 
			
		||||
  "Albanian": "",
 | 
			
		||||
  "Albanian": "albański",
 | 
			
		||||
  "Amharic": "",
 | 
			
		||||
  "Arabic": "",
 | 
			
		||||
  "Arabic": "arabski",
 | 
			
		||||
  "Armenian": "",
 | 
			
		||||
  "Azerbaijani": "",
 | 
			
		||||
  "Bangla": "",
 | 
			
		||||
  "Basque": "",
 | 
			
		||||
  "Belarusian": "",
 | 
			
		||||
  "Bosnian": "",
 | 
			
		||||
  "Bulgarian": "",
 | 
			
		||||
  "Burmese": "",
 | 
			
		||||
  "Catalan": "",
 | 
			
		||||
  "Belarusian": "białoruski",
 | 
			
		||||
  "Bosnian": "bośniacki",
 | 
			
		||||
  "Bulgarian": "bułgarski",
 | 
			
		||||
  "Burmese": "birmański",
 | 
			
		||||
  "Catalan": "kataloński",
 | 
			
		||||
  "Cebuano": "",
 | 
			
		||||
  "Chinese (Simplified)": "",
 | 
			
		||||
  "Chinese (Traditional)": "",
 | 
			
		||||
  "Corsican": "",
 | 
			
		||||
  "Croatian": "",
 | 
			
		||||
  "Czech": "",
 | 
			
		||||
  "Danish": "",
 | 
			
		||||
  "Dutch": "",
 | 
			
		||||
  "Esperanto": "",
 | 
			
		||||
  "Estonian": "",
 | 
			
		||||
  "Filipino": "",
 | 
			
		||||
  "Finnish": "",
 | 
			
		||||
  "French": "",
 | 
			
		||||
  "Galician": "",
 | 
			
		||||
  "Georgian": "",
 | 
			
		||||
  "German": "",
 | 
			
		||||
  "Greek": "",
 | 
			
		||||
  "Chinese (Simplified)": "chiński (uproszczony)",
 | 
			
		||||
  "Chinese (Traditional)": "chiński (tradycyjny)",
 | 
			
		||||
  "Corsican": "korsykański",
 | 
			
		||||
  "Croatian": "chorwacki",
 | 
			
		||||
  "Czech": "czeski",
 | 
			
		||||
  "Danish": "duński",
 | 
			
		||||
  "Dutch": "holenderski",
 | 
			
		||||
  "Esperanto": "esperanto",
 | 
			
		||||
  "Estonian": "estoński",
 | 
			
		||||
  "Filipino": "filipiński",
 | 
			
		||||
  "Finnish": "fiński",
 | 
			
		||||
  "French": "francuski",
 | 
			
		||||
  "Galician": "galicyjski",
 | 
			
		||||
  "Georgian": "gruziński",
 | 
			
		||||
  "German": "niemiecki",
 | 
			
		||||
  "Greek": "grecki",
 | 
			
		||||
  "Gujarati": "",
 | 
			
		||||
  "Haitian Creole": "",
 | 
			
		||||
  "Hausa": "",
 | 
			
		||||
  "Hawaiian": "",
 | 
			
		||||
  "Hebrew": "",
 | 
			
		||||
  "Hindi": "",
 | 
			
		||||
  "Hawaiian": "hawajski",
 | 
			
		||||
  "Hebrew": "hebrajski",
 | 
			
		||||
  "Hindi": "hindi",
 | 
			
		||||
  "Hmong": "",
 | 
			
		||||
  "Hungarian": "",
 | 
			
		||||
  "Icelandic": "",
 | 
			
		||||
  "Hungarian": "węgierski",
 | 
			
		||||
  "Icelandic": "islandzki",
 | 
			
		||||
  "Igbo": "",
 | 
			
		||||
  "Indonesian": "",
 | 
			
		||||
  "Irish": "",
 | 
			
		||||
  "Italian": "",
 | 
			
		||||
  "Japanese": "",
 | 
			
		||||
  "Javanese": "",
 | 
			
		||||
  "Indonesian": "indonezyjski",
 | 
			
		||||
  "Irish": "irlandzki",
 | 
			
		||||
  "Italian": "włoski",
 | 
			
		||||
  "Japanese": "japoński",
 | 
			
		||||
  "Javanese": "jawajski",
 | 
			
		||||
  "Kannada": "",
 | 
			
		||||
  "Kazakh": "",
 | 
			
		||||
  "Kazakh": "kazachski",
 | 
			
		||||
  "Khmer": "",
 | 
			
		||||
  "Korean": "",
 | 
			
		||||
  "Kurdish": "",
 | 
			
		||||
  "Kyrgyz": "",
 | 
			
		||||
  "Korean": "koreański",
 | 
			
		||||
  "Kurdish": "kurdyjski",
 | 
			
		||||
  "Kyrgyz": "kirgiski",
 | 
			
		||||
  "Lao": "",
 | 
			
		||||
  "Latin": "",
 | 
			
		||||
  "Latvian": "",
 | 
			
		||||
  "Lithuanian": "",
 | 
			
		||||
  "Luxembourgish": "",
 | 
			
		||||
  "Macedonian": "",
 | 
			
		||||
  "Malagasy": "",
 | 
			
		||||
  "Malay": "",
 | 
			
		||||
  "Latin": "łaciński",
 | 
			
		||||
  "Latvian": "łotewski",
 | 
			
		||||
  "Lithuanian": "litewski",
 | 
			
		||||
  "Luxembourgish": "luksemburski",
 | 
			
		||||
  "Macedonian": "macedoński",
 | 
			
		||||
  "Malagasy": "malgaski",
 | 
			
		||||
  "Malay": "malajski",
 | 
			
		||||
  "Malayalam": "",
 | 
			
		||||
  "Maltese": "",
 | 
			
		||||
  "Maltese": "maltański",
 | 
			
		||||
  "Maori": "",
 | 
			
		||||
  "Marathi": "",
 | 
			
		||||
  "Mongolian": "",
 | 
			
		||||
  "Nepali": "",
 | 
			
		||||
  "Norwegian": "",
 | 
			
		||||
  "Mongolian": "mongolski",
 | 
			
		||||
  "Nepali": "nepalski",
 | 
			
		||||
  "Norwegian": "norweski",
 | 
			
		||||
  "Nyanja": "",
 | 
			
		||||
  "Pashto": "",
 | 
			
		||||
  "Persian": "",
 | 
			
		||||
  "Polish": "",
 | 
			
		||||
  "Portuguese": "",
 | 
			
		||||
  "Persian": "perski",
 | 
			
		||||
  "Polish": "polski",
 | 
			
		||||
  "Portuguese": "portugalski",
 | 
			
		||||
  "Punjabi": "",
 | 
			
		||||
  "Romanian": "",
 | 
			
		||||
  "Russian": "",
 | 
			
		||||
  "Romanian": "rumuński",
 | 
			
		||||
  "Russian": "rosyjski",
 | 
			
		||||
  "Samoan": "",
 | 
			
		||||
  "Scottish Gaelic": "",
 | 
			
		||||
  "Serbian": "",
 | 
			
		||||
  "Serbian": "serbski",
 | 
			
		||||
  "Shona": "",
 | 
			
		||||
  "Sindhi": "",
 | 
			
		||||
  "Sinhala": "",
 | 
			
		||||
  "Slovak": "",
 | 
			
		||||
  "Slovenian": "",
 | 
			
		||||
  "Somali": "",
 | 
			
		||||
  "Slovak": "słowacki",
 | 
			
		||||
  "Slovenian": "słoweński",
 | 
			
		||||
  "Somali": "somalijski",
 | 
			
		||||
  "Southern Sotho": "",
 | 
			
		||||
  "Spanish": "",
 | 
			
		||||
  "Spanish (Latin America)": "",
 | 
			
		||||
  "Spanish": "hiszpański",
 | 
			
		||||
  "Spanish (Latin America)": "hiszpański (ameryka łacińska)",
 | 
			
		||||
  "Sundanese": "",
 | 
			
		||||
  "Swahili": "",
 | 
			
		||||
  "Swedish": "",
 | 
			
		||||
  "Swedish": "szwedzki",
 | 
			
		||||
  "Tajik": "",
 | 
			
		||||
  "Tamil": "",
 | 
			
		||||
  "Telugu": "",
 | 
			
		||||
  "Thai": "",
 | 
			
		||||
  "Turkish": "",
 | 
			
		||||
  "Ukrainian": "",
 | 
			
		||||
  "Thai": "tajski",
 | 
			
		||||
  "Turkish": "turecki",
 | 
			
		||||
  "Ukrainian": "ukraiński",
 | 
			
		||||
  "Urdu": "",
 | 
			
		||||
  "Uzbek": "",
 | 
			
		||||
  "Vietnamese": "",
 | 
			
		||||
  "Welsh": "",
 | 
			
		||||
  "Uzbek": "uzbecki",
 | 
			
		||||
  "Vietnamese": "wietnamski",
 | 
			
		||||
  "Welsh": "walijski",
 | 
			
		||||
  "Western Frisian": "",
 | 
			
		||||
  "Xhosa": "",
 | 
			
		||||
  "Yiddish": "",
 | 
			
		||||
@ -258,21 +266,23 @@
 | 
			
		||||
  "`x` hours": "`x` godzin",
 | 
			
		||||
  "`x` minutes": "`x` minut",
 | 
			
		||||
  "`x` seconds": "`x` sekund",
 | 
			
		||||
  "Fallback comments: ": "",
 | 
			
		||||
  "Popular": "",
 | 
			
		||||
  "Top": "",
 | 
			
		||||
  "About": "",
 | 
			
		||||
  "Rating: ": "",
 | 
			
		||||
  "Language: ": "",
 | 
			
		||||
  "Fallback comments: ": "Zastępcze komentarze: ",
 | 
			
		||||
  "Popular": "Popularne",
 | 
			
		||||
  "Top": "Na czasie",
 | 
			
		||||
  "About": "Informacje",
 | 
			
		||||
  "Rating: ": "Ocena: ",
 | 
			
		||||
  "Language: ": "Język: ",
 | 
			
		||||
  "Default": "",
 | 
			
		||||
  "Music": "",
 | 
			
		||||
  "Gaming": "",
 | 
			
		||||
  "News": "",
 | 
			
		||||
  "Movies": "",
 | 
			
		||||
  "Download": "",
 | 
			
		||||
  "Download as: ": "",
 | 
			
		||||
  "Music": "Muzyka",
 | 
			
		||||
  "Gaming": "Gry",
 | 
			
		||||
  "News": "Wiadomości",
 | 
			
		||||
  "Movies": "Filmy",
 | 
			
		||||
  "Download": "Pobierz",
 | 
			
		||||
  "Download as: ": "Pobierz jako: ",
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "(edited)": "(edytowany)",
 | 
			
		||||
  "Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube",
 | 
			
		||||
  "`x` marked it with a ❤": "",
 | 
			
		||||
  "Audio mode": "Tryb audio",
 | 
			
		||||
  "Video mode": "Tryb wideo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										212
									
								
								locales/ru.json
									
									
									
									
									
								
							
							
						
						
									
										212
									
								
								locales/ru.json
									
									
									
									
									
								
							@ -82,6 +82,14 @@
 | 
			
		||||
  "Manage subscriptions": "Управление подписками",
 | 
			
		||||
  "Watch history": "История просмотров",
 | 
			
		||||
  "Delete account": "Удалить аккаунт",
 | 
			
		||||
  "Administrator preferences": "",
 | 
			
		||||
  "Default homepage: ": "",
 | 
			
		||||
  "Feed menu: ": "",
 | 
			
		||||
  "Top enabled? ": "",
 | 
			
		||||
  "CAPTCHA enabled? ": "",
 | 
			
		||||
  "Login enabled? ": "",
 | 
			
		||||
  "Registration enabled? ": "",
 | 
			
		||||
  "Report statistics? ": "",
 | 
			
		||||
  "Save preferences": "Сохранить настройки",
 | 
			
		||||
  "Subscription manager": "Менеджер подписок",
 | 
			
		||||
  "`x` subscriptions": "`x` подписок",
 | 
			
		||||
@ -159,103 +167,103 @@
 | 
			
		||||
  "Arabic": "Арабский",
 | 
			
		||||
  "Armenian": "Армянский",
 | 
			
		||||
  "Azerbaijani": "Азербайджанский",
 | 
			
		||||
  "Bangla": "",
 | 
			
		||||
  "Basque": "",
 | 
			
		||||
  "Belarusian": "",
 | 
			
		||||
  "Bosnian": "",
 | 
			
		||||
  "Bulgarian": "",
 | 
			
		||||
  "Burmese": "",
 | 
			
		||||
  "Catalan": "",
 | 
			
		||||
  "Cebuano": "",
 | 
			
		||||
  "Chinese (Simplified)": "",
 | 
			
		||||
  "Chinese (Traditional)": "",
 | 
			
		||||
  "Corsican": "",
 | 
			
		||||
  "Croatian": "",
 | 
			
		||||
  "Czech": "",
 | 
			
		||||
  "Danish": "",
 | 
			
		||||
  "Dutch": "",
 | 
			
		||||
  "Esperanto": "",
 | 
			
		||||
  "Estonian": "",
 | 
			
		||||
  "Filipino": "",
 | 
			
		||||
  "Finnish": "",
 | 
			
		||||
  "French": "",
 | 
			
		||||
  "Galician": "",
 | 
			
		||||
  "Georgian": "",
 | 
			
		||||
  "German": "",
 | 
			
		||||
  "Greek": "",
 | 
			
		||||
  "Gujarati": "",
 | 
			
		||||
  "Haitian Creole": "",
 | 
			
		||||
  "Hausa": "",
 | 
			
		||||
  "Hawaiian": "",
 | 
			
		||||
  "Hebrew": "",
 | 
			
		||||
  "Hindi": "",
 | 
			
		||||
  "Hmong": "",
 | 
			
		||||
  "Hungarian": "",
 | 
			
		||||
  "Icelandic": "",
 | 
			
		||||
  "Igbo": "",
 | 
			
		||||
  "Indonesian": "",
 | 
			
		||||
  "Irish": "",
 | 
			
		||||
  "Italian": "",
 | 
			
		||||
  "Japanese": "",
 | 
			
		||||
  "Javanese": "",
 | 
			
		||||
  "Kannada": "",
 | 
			
		||||
  "Kazakh": "",
 | 
			
		||||
  "Khmer": "",
 | 
			
		||||
  "Korean": "",
 | 
			
		||||
  "Kurdish": "",
 | 
			
		||||
  "Kyrgyz": "",
 | 
			
		||||
  "Lao": "",
 | 
			
		||||
  "Latin": "",
 | 
			
		||||
  "Latvian": "",
 | 
			
		||||
  "Lithuanian": "",
 | 
			
		||||
  "Luxembourgish": "",
 | 
			
		||||
  "Macedonian": "",
 | 
			
		||||
  "Malagasy": "",
 | 
			
		||||
  "Malay": "",
 | 
			
		||||
  "Malayalam": "",
 | 
			
		||||
  "Maltese": "",
 | 
			
		||||
  "Maori": "",
 | 
			
		||||
  "Marathi": "",
 | 
			
		||||
  "Mongolian": "",
 | 
			
		||||
  "Nepali": "",
 | 
			
		||||
  "Norwegian": "",
 | 
			
		||||
  "Nyanja": "",
 | 
			
		||||
  "Pashto": "",
 | 
			
		||||
  "Persian": "",
 | 
			
		||||
  "Polish": "",
 | 
			
		||||
  "Portuguese": "",
 | 
			
		||||
  "Punjabi": "",
 | 
			
		||||
  "Romanian": "",
 | 
			
		||||
  "Russian": "",
 | 
			
		||||
  "Samoan": "",
 | 
			
		||||
  "Scottish Gaelic": "",
 | 
			
		||||
  "Serbian": "",
 | 
			
		||||
  "Shona": "",
 | 
			
		||||
  "Sindhi": "",
 | 
			
		||||
  "Sinhala": "",
 | 
			
		||||
  "Slovak": "",
 | 
			
		||||
  "Slovenian": "",
 | 
			
		||||
  "Somali": "",
 | 
			
		||||
  "Southern Sotho": "",
 | 
			
		||||
  "Spanish": "",
 | 
			
		||||
  "Spanish (Latin America)": "",
 | 
			
		||||
  "Sundanese": "",
 | 
			
		||||
  "Swahili": "",
 | 
			
		||||
  "Swedish": "",
 | 
			
		||||
  "Tajik": "",
 | 
			
		||||
  "Tamil": "",
 | 
			
		||||
  "Telugu": "",
 | 
			
		||||
  "Thai": "",
 | 
			
		||||
  "Turkish": "",
 | 
			
		||||
  "Ukrainian": "",
 | 
			
		||||
  "Urdu": "",
 | 
			
		||||
  "Uzbek": "",
 | 
			
		||||
  "Vietnamese": "",
 | 
			
		||||
  "Welsh": "",
 | 
			
		||||
  "Western Frisian": "",
 | 
			
		||||
  "Xhosa": "",
 | 
			
		||||
  "Yiddish": "",
 | 
			
		||||
  "Yoruba": "",
 | 
			
		||||
  "Bangla": "Бенгальский",
 | 
			
		||||
  "Basque": "Баскский",
 | 
			
		||||
  "Belarusian": "Белорусский",
 | 
			
		||||
  "Bosnian": "Боснийский",
 | 
			
		||||
  "Bulgarian": "Болгарский",
 | 
			
		||||
  "Burmese": "Бирманский",
 | 
			
		||||
  "Catalan": "Каталонский",
 | 
			
		||||
  "Cebuano": "Себуанский",
 | 
			
		||||
  "Chinese (Simplified)": "Китайский (упрощенный)",
 | 
			
		||||
  "Chinese (Traditional)": "Китайский (традиционный)",
 | 
			
		||||
  "Corsican": "Корсиканский",
 | 
			
		||||
  "Croatian": "Хорватский",
 | 
			
		||||
  "Czech": "Чешский",
 | 
			
		||||
  "Danish": "Датский",
 | 
			
		||||
  "Dutch": "Нидерландский",
 | 
			
		||||
  "Esperanto": "Эсперанто",
 | 
			
		||||
  "Estonian": "Эстонский",
 | 
			
		||||
  "Filipino": "Филиппинский",
 | 
			
		||||
  "Finnish": "Финский",
 | 
			
		||||
  "French": "Французский",
 | 
			
		||||
  "Galician": "Галисийский",
 | 
			
		||||
  "Georgian": "Грузинский",
 | 
			
		||||
  "German": "Немецкий",
 | 
			
		||||
  "Greek": "Греческий",
 | 
			
		||||
  "Gujarati": "Гуджаратский",
 | 
			
		||||
  "Haitian Creole": "Гаит. креольский",
 | 
			
		||||
  "Hausa": "Хауса",
 | 
			
		||||
  "Hawaiian": "Гавайский",
 | 
			
		||||
  "Hebrew": "Иврит",
 | 
			
		||||
  "Hindi": "Хинди",
 | 
			
		||||
  "Hmong": "Хмонг (мяо)",
 | 
			
		||||
  "Hungarian": "Венгерский",
 | 
			
		||||
  "Icelandic": "Исландский",
 | 
			
		||||
  "Igbo": "Игбо",
 | 
			
		||||
  "Indonesian": "Индонезийский",
 | 
			
		||||
  "Irish": "Ирландский",
 | 
			
		||||
  "Italian": "Итальянский",
 | 
			
		||||
  "Japanese": "Японский",
 | 
			
		||||
  "Javanese": "Яванский",
 | 
			
		||||
  "Kannada": "Каннада",
 | 
			
		||||
  "Kazakh": "Казахский",
 | 
			
		||||
  "Khmer": "Кхмерский",
 | 
			
		||||
  "Korean": "Корейский",
 | 
			
		||||
  "Kurdish": "Курдский",
 | 
			
		||||
  "Kyrgyz": "Киргизский",
 | 
			
		||||
  "Lao": "Лаосский",
 | 
			
		||||
  "Latin": "Латинский",
 | 
			
		||||
  "Latvian": "Латышский",
 | 
			
		||||
  "Lithuanian": "Литовский",
 | 
			
		||||
  "Luxembourgish": "Люксембургский",
 | 
			
		||||
  "Macedonian": "Македонский",
 | 
			
		||||
  "Malagasy": "Малагасийский",
 | 
			
		||||
  "Malay": "Малайский",
 | 
			
		||||
  "Malayalam": "Малаялам",
 | 
			
		||||
  "Maltese": "Мальтийский",
 | 
			
		||||
  "Maori": "Маори",
 | 
			
		||||
  "Marathi": "Маратхи",
 | 
			
		||||
  "Mongolian": "Монгольская",
 | 
			
		||||
  "Nepali": "Непальский",
 | 
			
		||||
  "Norwegian": "Норвежский",
 | 
			
		||||
  "Nyanja": "Ньянджа",
 | 
			
		||||
  "Pashto": "Пушту",
 | 
			
		||||
  "Persian": "Персидский",
 | 
			
		||||
  "Polish": "Польский",
 | 
			
		||||
  "Portuguese": "Португальский",
 | 
			
		||||
  "Punjabi": "Панджаби",
 | 
			
		||||
  "Romanian": "Румынский",
 | 
			
		||||
  "Russian": "Русский",
 | 
			
		||||
  "Samoan": "Самоанский",
 | 
			
		||||
  "Scottish Gaelic": "Шотландский (гэльский)",
 | 
			
		||||
  "Serbian": "Сербский",
 | 
			
		||||
  "Shona": "Шона",
 | 
			
		||||
  "Sindhi": "Синдхи",
 | 
			
		||||
  "Sinhala": "Сингальский",
 | 
			
		||||
  "Slovak": "Словацкий",
 | 
			
		||||
  "Slovenian": "Словенский",
 | 
			
		||||
  "Somali": "Сомалийский",
 | 
			
		||||
  "Southern Sotho": "Сесото (южный сото)",
 | 
			
		||||
  "Spanish": "Испанский",
 | 
			
		||||
  "Spanish (Latin America)": "Испанский (Латинская Америка)",
 | 
			
		||||
  "Sundanese": "Сунданский",
 | 
			
		||||
  "Swahili": "Суахили",
 | 
			
		||||
  "Swedish": "Шведский",
 | 
			
		||||
  "Tajik": "Таджикский",
 | 
			
		||||
  "Tamil": "Тамильский",
 | 
			
		||||
  "Telugu": "Телугу",
 | 
			
		||||
  "Thai": "Тайский",
 | 
			
		||||
  "Turkish": "Турецкий",
 | 
			
		||||
  "Ukrainian": "Украинский",
 | 
			
		||||
  "Urdu": "Урду",
 | 
			
		||||
  "Uzbek": "Узбекский",
 | 
			
		||||
  "Vietnamese": "Вьетнамский",
 | 
			
		||||
  "Welsh": "Валлийский",
 | 
			
		||||
  "Western Frisian": "Западнофризский",
 | 
			
		||||
  "Xhosa": "Коса",
 | 
			
		||||
  "Yiddish": "Идиш",
 | 
			
		||||
  "Yoruba": "Йоруба",
 | 
			
		||||
  "Zulu": "Зулусский",
 | 
			
		||||
  "`x` years": "`x` лет",
 | 
			
		||||
  "`x` months": "`x` месяцев",
 | 
			
		||||
@ -277,8 +285,10 @@
 | 
			
		||||
  "Movies": "Фильмы",
 | 
			
		||||
  "Download": "Скачать",
 | 
			
		||||
  "Download as: ": "Скачать как: ",
 | 
			
		||||
  "%A %B %-d, %Y": "",
 | 
			
		||||
  "(edited)": "",
 | 
			
		||||
  "Youtube permalink of the comment": "",
 | 
			
		||||
  "`x` marked it with a ❤": ""
 | 
			
		||||
  "%A %B %-d, %Y": "%-d %B %Y, %A",
 | 
			
		||||
  "(edited)": "(изменено)",
 | 
			
		||||
  "Youtube permalink of the comment": "Прямая ссылка на YouTube",
 | 
			
		||||
  "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
 | 
			
		||||
  "Audio mode": "Аудио режим",
 | 
			
		||||
  "Video mode": "Видео режим"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								screenshots/01_player.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/01_player.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 889 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								screenshots/02_preferences.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/02_preferences.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 62 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								screenshots/03_subscriptions.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/03_subscriptions.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 536 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								screenshots/04_description.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/04_description.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 302 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								screenshots/05_preferences.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/05_preferences.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 61 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								screenshots/06_subscriptions.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshots/06_subscriptions.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 68 KiB  | 
@ -1,5 +1,5 @@
 | 
			
		||||
name: invidious
 | 
			
		||||
version: 0.14.0
 | 
			
		||||
version: 0.14.1
 | 
			
		||||
 | 
			
		||||
authors:
 | 
			
		||||
  - Omar Roth <omarroth@hotmail.com>
 | 
			
		||||
@ -9,16 +9,13 @@ targets:
 | 
			
		||||
    main: src/invidious.cr
 | 
			
		||||
 | 
			
		||||
dependencies:
 | 
			
		||||
  detect_language:
 | 
			
		||||
    github: detectlanguage/detectlanguage-crystal
 | 
			
		||||
  kemal:
 | 
			
		||||
    github: kemalcr/kemal
 | 
			
		||||
    commit: afd17fc
 | 
			
		||||
  pg:
 | 
			
		||||
    github: will/crystal-pg
 | 
			
		||||
  sqlite3:
 | 
			
		||||
    github: crystal-lang/crystal-sqlite3
 | 
			
		||||
 | 
			
		||||
crystal: 0.27.1
 | 
			
		||||
crystal: 0.27.2
 | 
			
		||||
 | 
			
		||||
license: AGPLv3
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										258
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										258
									
								
								src/invidious.cr
									
									
									
									
									
								
							@ -14,7 +14,6 @@
 | 
			
		||||
# You should have received a copy of the GNU Affero General Public License
 | 
			
		||||
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
require "detect_language"
 | 
			
		||||
require "digest/md5"
 | 
			
		||||
require "file_utils"
 | 
			
		||||
require "kemal"
 | 
			
		||||
@ -29,44 +28,40 @@ require "./invidious/helpers/*"
 | 
			
		||||
require "./invidious/*"
 | 
			
		||||
 | 
			
		||||
CONFIG   = Config.from_yaml(File.read("config/config.yml"))
 | 
			
		||||
HMAC_KEY = CONFIG.hmac_key || Random::Secure.random_bytes(32)
 | 
			
		||||
 | 
			
		||||
crawl_threads = CONFIG.crawl_threads
 | 
			
		||||
channel_threads = CONFIG.channel_threads
 | 
			
		||||
feed_threads = CONFIG.feed_threads
 | 
			
		||||
video_threads = CONFIG.video_threads
 | 
			
		||||
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
 | 
			
		||||
 | 
			
		||||
config = CONFIG
 | 
			
		||||
logger = Invidious::LogHandler.new
 | 
			
		||||
 | 
			
		||||
Kemal.config.extra_options do |parser|
 | 
			
		||||
  parser.banner = "Usage: invidious [arguments]"
 | 
			
		||||
  parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{crawl_threads})") do |number|
 | 
			
		||||
  parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{config.crawl_threads})") do |number|
 | 
			
		||||
    begin
 | 
			
		||||
      crawl_threads = number.to_i
 | 
			
		||||
      config.crawl_threads = number.to_i
 | 
			
		||||
    rescue ex
 | 
			
		||||
      puts "THREADS must be integer"
 | 
			
		||||
      exit
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
  parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{channel_threads})") do |number|
 | 
			
		||||
  parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number|
 | 
			
		||||
    begin
 | 
			
		||||
      channel_threads = number.to_i
 | 
			
		||||
      config.channel_threads = number.to_i
 | 
			
		||||
    rescue ex
 | 
			
		||||
      puts "THREADS must be integer"
 | 
			
		||||
      exit
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
  parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{feed_threads})") do |number|
 | 
			
		||||
  parser.on("-f THREADS", "--feed-threads=THREADS", "Number of threads for refreshing feeds (default: #{config.feed_threads})") do |number|
 | 
			
		||||
    begin
 | 
			
		||||
      feed_threads = number.to_i
 | 
			
		||||
      config.feed_threads = number.to_i
 | 
			
		||||
    rescue ex
 | 
			
		||||
      puts "THREADS must be integer"
 | 
			
		||||
      exit
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
  parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{video_threads})") do |number|
 | 
			
		||||
  parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{config.video_threads})") do |number|
 | 
			
		||||
    begin
 | 
			
		||||
      video_threads = number.to_i
 | 
			
		||||
      config.video_threads = number.to_i
 | 
			
		||||
    rescue ex
 | 
			
		||||
      puts "THREADS must be integer"
 | 
			
		||||
      exit
 | 
			
		||||
@ -78,23 +73,34 @@ Kemal.config.extra_options do |parser|
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
Kemal::CLI.new
 | 
			
		||||
Kemal::CLI.new ARGV
 | 
			
		||||
 | 
			
		||||
YT_URL     = URI.parse("https://www.youtube.com")
 | 
			
		||||
REDDIT_URL = URI.parse("https://www.reddit.com")
 | 
			
		||||
LOGIN_URL  = URI.parse("https://accounts.google.com")
 | 
			
		||||
YT_URL          = URI.parse("https://www.youtube.com")
 | 
			
		||||
REDDIT_URL      = URI.parse("https://www.reddit.com")
 | 
			
		||||
LOGIN_URL       = URI.parse("https://accounts.google.com")
 | 
			
		||||
PUBSUB_URL      = URI.parse("https://pubsubhubbub.appspot.com")
 | 
			
		||||
TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@hotmail.com.json")
 | 
			
		||||
CURRENT_COMMIT  = `git rev-list HEAD --max-count=1 --abbrev-commit`.strip
 | 
			
		||||
CURRENT_VERSION = `git describe --tags $(git rev-list --tags --max-count=1)`.strip
 | 
			
		||||
CURRENT_BRANCH  = `git status | head -1`.strip
 | 
			
		||||
 | 
			
		||||
LOCALES = {
 | 
			
		||||
  "ar"    => load_locale("ar"),
 | 
			
		||||
  "de"    => load_locale("de"),
 | 
			
		||||
  "en-US" => load_locale("en-US"),
 | 
			
		||||
  "eu"    => load_locale("eu"),
 | 
			
		||||
  "fr"    => load_locale("fr"),
 | 
			
		||||
  "it"    => load_locale("it"),
 | 
			
		||||
  "nb_NO" => load_locale("nb_NO"),
 | 
			
		||||
  "nl"    => load_locale("nl"),
 | 
			
		||||
  "pl"    => load_locale("pl"),
 | 
			
		||||
  "ru"    => load_locale("ru"),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
statistics = {
 | 
			
		||||
  "error" => "Statistics are not availabile.",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
decrypt_function = [] of {name: String, value: Int32}
 | 
			
		||||
spawn do
 | 
			
		||||
  update_decrypt_function do |function|
 | 
			
		||||
@ -114,6 +120,25 @@ before_all do |env|
 | 
			
		||||
end
 | 
			
		||||
# API Endpoints
 | 
			
		||||
 | 
			
		||||
get "/api/v1/stats" do |env|
 | 
			
		||||
  env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
  if statistics["error"]?
 | 
			
		||||
    halt env, status_code: 500, response: statistics.to_json
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if !config.statistics_enabled
 | 
			
		||||
    error_message = {"error" => "Statistics are not enabled."}.to_json
 | 
			
		||||
    halt env, status_code: 400, response: error_message
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
 | 
			
		||||
    statistics.to_pretty_json
 | 
			
		||||
  else
 | 
			
		||||
    statistics.to_json
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/v1/captions/:id" do |env|
 | 
			
		||||
  locale = LOCALES[env.get("locale").as(String)]?
 | 
			
		||||
 | 
			
		||||
@ -293,7 +318,7 @@ get "/api/v1/insights/:id" do |env|
 | 
			
		||||
  env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
  error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json
 | 
			
		||||
  halt env, status_code: 503, response: error_message
 | 
			
		||||
  halt env, status_code: 410, response: error_message
 | 
			
		||||
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
  headers = HTTP::Headers.new
 | 
			
		||||
@ -412,7 +437,7 @@ get "/api/v1/videos/:id" do |env|
 | 
			
		||||
      json.field "description", description
 | 
			
		||||
      json.field "descriptionHtml", video.description
 | 
			
		||||
      json.field "published", video.published.to_unix
 | 
			
		||||
      json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published))
 | 
			
		||||
      json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
 | 
			
		||||
      json.field "keywords", video.keywords
 | 
			
		||||
 | 
			
		||||
      json.field "viewCount", video.views
 | 
			
		||||
@ -459,7 +484,7 @@ get "/api/v1/videos/:id" do |env|
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
 | 
			
		||||
        host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain)
 | 
			
		||||
        host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
 | 
			
		||||
 | 
			
		||||
        host_params = env.request.query_params
 | 
			
		||||
        host_params.delete_all("v")
 | 
			
		||||
@ -620,7 +645,7 @@ get "/api/v1/trending" do |env|
 | 
			
		||||
          json.field "authorUrl", "/channel/#{video.ucid}"
 | 
			
		||||
 | 
			
		||||
          json.field "published", video.published.to_unix
 | 
			
		||||
          json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published))
 | 
			
		||||
          json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
 | 
			
		||||
          json.field "description", video.description
 | 
			
		||||
          json.field "descriptionHtml", video.description_html
 | 
			
		||||
          json.field "liveNow", video.live_now
 | 
			
		||||
@ -655,11 +680,16 @@ get "/api/v1/channels/:ucid" do |env|
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  page = 1
 | 
			
		||||
  begin
 | 
			
		||||
    videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    error_message = {"error" => ex.message}.to_json
 | 
			
		||||
    halt env, status_code: 500, response: error_message
 | 
			
		||||
  if auto_generated
 | 
			
		||||
    videos = [] of SearchVideo
 | 
			
		||||
    count = 0
 | 
			
		||||
  else
 | 
			
		||||
    begin
 | 
			
		||||
      videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      error_message = {"error" => ex.message}.to_json
 | 
			
		||||
      halt env, status_code: 500, response: error_message
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
@ -762,6 +792,7 @@ get "/api/v1/channels/:ucid" do |env|
 | 
			
		||||
      json.field "joined", joined.to_unix
 | 
			
		||||
      json.field "paid", paid
 | 
			
		||||
 | 
			
		||||
      json.field "autoGenerated", auto_generated
 | 
			
		||||
      json.field "isFamilyFriendly", is_family_friendly
 | 
			
		||||
      json.field "description", description
 | 
			
		||||
      json.field "descriptionHtml", description_html
 | 
			
		||||
@ -794,7 +825,7 @@ get "/api/v1/channels/:ucid" do |env|
 | 
			
		||||
 | 
			
		||||
              json.field "viewCount", video.views
 | 
			
		||||
              json.field "published", video.published.to_unix
 | 
			
		||||
              json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published))
 | 
			
		||||
              json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
 | 
			
		||||
              json.field "lengthSeconds", video.length_seconds
 | 
			
		||||
              json.field "liveNow", video.live_now
 | 
			
		||||
              json.field "paid", video.paid
 | 
			
		||||
@ -848,7 +879,8 @@ end
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
    page = env.params.query["page"]?.try &.to_i?
 | 
			
		||||
    page ||= 1
 | 
			
		||||
    sort_by = env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by = env.params.query["sort"]?.try &.downcase
 | 
			
		||||
    sort_by ||= env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by ||= "newest"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
@ -891,7 +923,7 @@ end
 | 
			
		||||
 | 
			
		||||
            json.field "viewCount", video.views
 | 
			
		||||
            json.field "published", video.published.to_unix
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published))
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
 | 
			
		||||
            json.field "lengthSeconds", video.length_seconds
 | 
			
		||||
            json.field "liveNow", video.live_now
 | 
			
		||||
            json.field "paid", video.paid
 | 
			
		||||
@ -909,6 +941,127 @@ end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route|
 | 
			
		||||
  get route do |env|
 | 
			
		||||
    locale = LOCALES[env.get("locale").as(String)]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      videos = get_latest_videos(ucid)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      error_message = {"error" => ex.message}.to_json
 | 
			
		||||
      halt env, status_code: 500, response: error_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response = JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        videos.each do |video|
 | 
			
		||||
          json.object do
 | 
			
		||||
            json.field "title", video.title
 | 
			
		||||
            json.field "videoId", video.id
 | 
			
		||||
 | 
			
		||||
            json.field "authorId", ucid
 | 
			
		||||
            json.field "authorUrl", "/channel/#{ucid}"
 | 
			
		||||
 | 
			
		||||
            json.field "videoThumbnails" do
 | 
			
		||||
              generate_thumbnails(json, video.id)
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            json.field "description", video.description
 | 
			
		||||
            json.field "descriptionHtml", video.description_html
 | 
			
		||||
 | 
			
		||||
            json.field "viewCount", video.views
 | 
			
		||||
            json.field "published", video.published.to_unix
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale))
 | 
			
		||||
            json.field "lengthSeconds", video.length_seconds
 | 
			
		||||
            json.field "liveNow", video.live_now
 | 
			
		||||
            json.field "paid", video.paid
 | 
			
		||||
            json.field "premium", video.premium
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
 | 
			
		||||
      JSON.parse(response).to_pretty_json
 | 
			
		||||
    else
 | 
			
		||||
      response
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route|
 | 
			
		||||
  get route do |env|
 | 
			
		||||
    locale = LOCALES[env.get("locale").as(String)]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
    sort_by = env.params.query["sort"]?.try &.downcase
 | 
			
		||||
    sort_by ||= env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by ||= "last"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      author, ucid, auto_generated = get_about_info(ucid, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      error_message = ex.message
 | 
			
		||||
      halt env, status_code: 500, response: error_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
 | 
			
		||||
 | 
			
		||||
    response = JSON.build do |json|
 | 
			
		||||
      json.object do
 | 
			
		||||
        json.field "playlists" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            items.each do |item|
 | 
			
		||||
              json.object do
 | 
			
		||||
                if item.is_a?(SearchPlaylist)
 | 
			
		||||
                  json.field "title", item.title
 | 
			
		||||
                  json.field "playlistId", item.id
 | 
			
		||||
 | 
			
		||||
                  json.field "author", item.author
 | 
			
		||||
                  json.field "authorId", item.ucid
 | 
			
		||||
                  json.field "authorUrl", "/channel/#{item.ucid}"
 | 
			
		||||
 | 
			
		||||
                  json.field "videoCount", item.video_count
 | 
			
		||||
                  json.field "videos" do
 | 
			
		||||
                    json.array do
 | 
			
		||||
                      item.videos.each do |video|
 | 
			
		||||
                        json.object do
 | 
			
		||||
                          json.field "title", video.title
 | 
			
		||||
                          json.field "videoId", video.id
 | 
			
		||||
                          json.field "lengthSeconds", video.length_seconds
 | 
			
		||||
 | 
			
		||||
                          json.field "videoThumbnails" do
 | 
			
		||||
                            generate_thumbnails(json, video.id)
 | 
			
		||||
                          end
 | 
			
		||||
                        end
 | 
			
		||||
                      end
 | 
			
		||||
                    end
 | 
			
		||||
                  end
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        json.field "continuation", continuation
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
 | 
			
		||||
      JSON.parse(response).to_pretty_json
 | 
			
		||||
    else
 | 
			
		||||
      response
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/v1/channels/search/:ucid" do |env|
 | 
			
		||||
  locale = LOCALES[env.get("locale").as(String)]?
 | 
			
		||||
 | 
			
		||||
@ -946,7 +1099,7 @@ get "/api/v1/channels/search/:ucid" do |env|
 | 
			
		||||
 | 
			
		||||
            json.field "viewCount", item.views
 | 
			
		||||
            json.field "published", item.published.to_unix
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published))
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published, locale))
 | 
			
		||||
            json.field "lengthSeconds", item.length_seconds
 | 
			
		||||
            json.field "liveNow", item.live_now
 | 
			
		||||
            json.field "paid", item.paid
 | 
			
		||||
@ -1031,7 +1184,7 @@ get "/api/v1/search" do |env|
 | 
			
		||||
  date = env.params.query["date"]?.try &.downcase
 | 
			
		||||
  date ||= ""
 | 
			
		||||
 | 
			
		||||
  duration = env.params.query["date"]?.try &.downcase
 | 
			
		||||
  duration = env.params.query["duration"]?.try &.downcase
 | 
			
		||||
  duration ||= ""
 | 
			
		||||
 | 
			
		||||
  features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase }
 | 
			
		||||
@ -1075,7 +1228,7 @@ get "/api/v1/search" do |env|
 | 
			
		||||
 | 
			
		||||
            json.field "viewCount", item.views
 | 
			
		||||
            json.field "published", item.published.to_unix
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published))
 | 
			
		||||
            json.field "publishedText", translate(locale, "`x` ago", recode_date(item.published, locale))
 | 
			
		||||
            json.field "lengthSeconds", item.length_seconds
 | 
			
		||||
            json.field "liveNow", item.live_now
 | 
			
		||||
            json.field "paid", item.paid
 | 
			
		||||
@ -1253,7 +1406,7 @@ get "/api/v1/mixes/:rdid" do |env|
 | 
			
		||||
  rdid = env.params.url["rdid"]
 | 
			
		||||
 | 
			
		||||
  continuation = env.params.query["continuation"]?
 | 
			
		||||
  continuation ||= rdid.lchop("RD")
 | 
			
		||||
  continuation ||= rdid.lchop("RD")[0, 11]
 | 
			
		||||
 | 
			
		||||
  format = env.params.query["format"]?
 | 
			
		||||
  format ||= "json"
 | 
			
		||||
@ -1355,8 +1508,8 @@ get "/api/manifest/dash/id/:id" do |env|
 | 
			
		||||
    halt env, status_code: 403
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if video.info["dashmpd"]?
 | 
			
		||||
    manifest = client.get(video.info["dashmpd"]).body
 | 
			
		||||
  if dashmpd = video.player_response["streamingData"]["dashManifestUrl"]?.try &.as_s
 | 
			
		||||
    manifest = client.get(dashmpd).body
 | 
			
		||||
 | 
			
		||||
    manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |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.headers.add("Access-Control-Allow-Origin", "*")
 | 
			
		||||
 | 
			
		||||
  host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain)
 | 
			
		||||
  host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
 | 
			
		||||
 | 
			
		||||
  manifest = manifest.body
 | 
			
		||||
  manifest.gsub("https://www.youtube.com", host_url)
 | 
			
		||||
@ -1459,7 +1612,7 @@ get "/api/manifest/hls_playlist/*" do |env|
 | 
			
		||||
    halt env, status_code: manifest.status_code
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, CONFIG.domain)
 | 
			
		||||
  host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
 | 
			
		||||
 | 
			
		||||
  manifest = manifest.body.gsub("https://www.youtube.com", host_url)
 | 
			
		||||
  manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)
 | 
			
		||||
@ -1474,12 +1627,20 @@ end
 | 
			
		||||
# YouTube /videoplayback links expire after 6 hours,
 | 
			
		||||
# so we have a mechanism here to redirect to the latest version
 | 
			
		||||
get "/latest_version" do |env|
 | 
			
		||||
  id = env.params.query["id"]?
 | 
			
		||||
  itag = env.params.query["itag"]?
 | 
			
		||||
  if env.params.query["download_widget"]?
 | 
			
		||||
    download_widget = JSON.parse(env.params.query["download_widget"])
 | 
			
		||||
    id = download_widget["id"].as_s
 | 
			
		||||
    itag = download_widget["itag"].as_s
 | 
			
		||||
    title = download_widget["title"].as_s
 | 
			
		||||
    local = "true"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  id ||= env.params.query["id"]?
 | 
			
		||||
  itag ||= env.params.query["itag"]?
 | 
			
		||||
 | 
			
		||||
  region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
  local = env.params.query["local"]?
 | 
			
		||||
  local ||= env.params.query["local"]?
 | 
			
		||||
  local ||= "false"
 | 
			
		||||
  local = local == "true"
 | 
			
		||||
 | 
			
		||||
@ -1504,6 +1665,10 @@ get "/latest_version" do |env|
 | 
			
		||||
    url = URI.parse(url).full_path.not_nil!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if title
 | 
			
		||||
    url += "&title=#{title}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  env.redirect url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -1565,7 +1730,7 @@ end
 | 
			
		||||
get "/videoplayback" do |env|
 | 
			
		||||
  query_params = env.params.query
 | 
			
		||||
 | 
			
		||||
  fvip = query_params["fvip"]
 | 
			
		||||
  fvip = query_params["fvip"]? || "3"
 | 
			
		||||
  mn = query_params["mn"].split(",")[-1]
 | 
			
		||||
  host = "https://r#{fvip}---#{mn}.googlevideo.com"
 | 
			
		||||
  url = "/videoplayback?#{query_params.to_s}"
 | 
			
		||||
@ -1601,13 +1766,18 @@ get "/videoplayback" do |env|
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if response.status_code >= 400
 | 
			
		||||
    halt env, status_code: 403
 | 
			
		||||
    halt env, status_code: response.status_code
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  client = make_client(URI.parse(host), proxies, region)
 | 
			
		||||
  client.get(url, headers) do |response|
 | 
			
		||||
    env.response.status_code = response.status_code
 | 
			
		||||
 | 
			
		||||
    if title = env.params.query["title"]?
 | 
			
		||||
      # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
 | 
			
		||||
      env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.escape(title)}\"; filename*=UTF-8''#{URI.escape(title)}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response.headers.each do |key, value|
 | 
			
		||||
      env.response.headers[key] = value
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,10 @@
 | 
			
		||||
class InvidiousChannel
 | 
			
		||||
  add_mapping({
 | 
			
		||||
    id:      String,
 | 
			
		||||
    author:  String,
 | 
			
		||||
    updated: Time,
 | 
			
		||||
    deleted: Bool,
 | 
			
		||||
    id:         String,
 | 
			
		||||
    author:     String,
 | 
			
		||||
    updated:    Time,
 | 
			
		||||
    deleted:    Bool,
 | 
			
		||||
    subscribed: Time?,
 | 
			
		||||
  })
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -15,10 +16,7 @@ class ChannelVideo
 | 
			
		||||
    updated:        Time,
 | 
			
		||||
    ucid:           String,
 | 
			
		||||
    author:         String,
 | 
			
		||||
    length_seconds: {
 | 
			
		||||
      type:    Int32,
 | 
			
		||||
      default: 0,
 | 
			
		||||
    },
 | 
			
		||||
    length_seconds: {type: Int32, default: 0},
 | 
			
		||||
  })
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -50,13 +48,11 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_channel(id, db, refresh = true, pull_all_videos = true)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
 | 
			
		||||
  if db.query_one?("SELECT EXISTS (SELECT true FROM channels WHERE id = $1)", id, as: Bool)
 | 
			
		||||
    channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
 | 
			
		||||
 | 
			
		||||
    if refresh && Time.now - channel.updated > 10.minutes
 | 
			
		||||
      channel = fetch_channel(id, client, db, pull_all_videos: pull_all_videos)
 | 
			
		||||
      channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
 | 
			
		||||
      channel_array = channel.to_a
 | 
			
		||||
      args = arg_array(channel_array)
 | 
			
		||||
 | 
			
		||||
@ -64,7 +60,7 @@ def get_channel(id, db, refresh = true, pull_all_videos = true)
 | 
			
		||||
        ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", channel_array)
 | 
			
		||||
    end
 | 
			
		||||
  else
 | 
			
		||||
    channel = fetch_channel(id, client, db, pull_all_videos: pull_all_videos)
 | 
			
		||||
    channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
 | 
			
		||||
    channel_array = channel.to_a
 | 
			
		||||
    args = arg_array(channel_array)
 | 
			
		||||
 | 
			
		||||
@ -74,7 +70,9 @@ def get_channel(id, db, refresh = true, pull_all_videos = true)
 | 
			
		||||
  return channel
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def fetch_channel(ucid, client, db, pull_all_videos = true, locale = nil)
 | 
			
		||||
def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
 | 
			
		||||
  rss = client.get("/feeds/videos.xml?channel_id=#{ucid}").body
 | 
			
		||||
  rss = XML.parse_html(rss)
 | 
			
		||||
 | 
			
		||||
@ -188,11 +186,88 @@ def fetch_channel(ucid, client, db, pull_all_videos = true, locale = nil)
 | 
			
		||||
    db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  channel = InvidiousChannel.new(ucid, author, Time.now, false)
 | 
			
		||||
  channel = InvidiousChannel.new(ucid, author, Time.now, false, nil)
 | 
			
		||||
 | 
			
		||||
  return channel
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def subscribe_pubsub(ucid, key, config)
 | 
			
		||||
  client = make_client(PUBSUB_URL)
 | 
			
		||||
  time = Time.now.to_unix.to_s
 | 
			
		||||
 | 
			
		||||
  host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
 | 
			
		||||
 | 
			
		||||
  body = {
 | 
			
		||||
    "hub.callback"      => "#{host_url}/feed/webhook/#{time}:#{OpenSSL::HMAC.hexdigest(:sha1, key, time)}",
 | 
			
		||||
    "hub.topic"         => "https://www.youtube.com/feeds/videos.xml?channel_id=#{ucid}",
 | 
			
		||||
    "hub.verify"        => "async",
 | 
			
		||||
    "hub.mode"          => "subscribe",
 | 
			
		||||
    "hub.lease_seconds" => "432000",
 | 
			
		||||
    "hub.secret"        => key.to_s,
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return client.post("/subscribe", form: body)
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
 | 
			
		||||
  if continuation
 | 
			
		||||
    url = produce_channel_playlists_url(ucid, continuation, sort_by, auto_generated)
 | 
			
		||||
 | 
			
		||||
    response = client.get(url)
 | 
			
		||||
    json = JSON.parse(response.body)
 | 
			
		||||
 | 
			
		||||
    if json["load_more_widget_html"].as_s.empty?
 | 
			
		||||
      return [] of SearchItem, nil
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    continuation = XML.parse_html(json["load_more_widget_html"].as_s)
 | 
			
		||||
    continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href]))
 | 
			
		||||
    if continuation
 | 
			
		||||
      continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    html = XML.parse_html(json["content_html"].as_s)
 | 
			
		||||
    nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
 | 
			
		||||
  else
 | 
			
		||||
    url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list"
 | 
			
		||||
 | 
			
		||||
    if auto_generated
 | 
			
		||||
      url += "&view=50"
 | 
			
		||||
    else
 | 
			
		||||
      url += "&view=1"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    case sort_by
 | 
			
		||||
    when "last", "last_added"
 | 
			
		||||
      #
 | 
			
		||||
    when "oldest", "oldest_created"
 | 
			
		||||
      url += "&sort=da"
 | 
			
		||||
    when "newest", "newest_created"
 | 
			
		||||
      url += "&sort=dd"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response = client.get(url)
 | 
			
		||||
    html = XML.parse_html(response.body)
 | 
			
		||||
 | 
			
		||||
    continuation = html.xpath_node(%q(//button[@data-uix-load-more-href]))
 | 
			
		||||
    if continuation
 | 
			
		||||
      continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")]))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if auto_generated
 | 
			
		||||
    items = extract_shelf_items(nodeset, ucid, author)
 | 
			
		||||
  else
 | 
			
		||||
    items = extract_items(nodeset, ucid, author)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return items, continuation
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest")
 | 
			
		||||
  if auto_generated
 | 
			
		||||
    seed = Time.unix(1525757349)
 | 
			
		||||
@ -260,6 +335,132 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
 | 
			
		||||
  return url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false)
 | 
			
		||||
  if !auto_generated
 | 
			
		||||
    cursor = Base64.urlsafe_encode(cursor, false)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  meta = IO::Memory.new
 | 
			
		||||
 | 
			
		||||
  if auto_generated
 | 
			
		||||
    meta.write(Bytes[0x08, 0x0a])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0x12, 0x09])
 | 
			
		||||
  meta.print("playlists")
 | 
			
		||||
 | 
			
		||||
  if auto_generated
 | 
			
		||||
    meta.write(Bytes[0x20, 0x32])
 | 
			
		||||
  else
 | 
			
		||||
    # TODO: Look at 0x01, 0x00
 | 
			
		||||
    case sort
 | 
			
		||||
    when "oldest", "oldest_created"
 | 
			
		||||
      meta.write(Bytes[0x18, 0x02])
 | 
			
		||||
    when "newest", "newest_created"
 | 
			
		||||
      meta.write(Bytes[0x18, 0x03])
 | 
			
		||||
    when "last", "last_added"
 | 
			
		||||
      meta.write(Bytes[0x18, 0x04])
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    meta.write(Bytes[0x20, 0x01])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0x30, 0x02])
 | 
			
		||||
  meta.write(Bytes[0x38, 0x01])
 | 
			
		||||
  meta.write(Bytes[0x60, 0x01])
 | 
			
		||||
  meta.write(Bytes[0x6a, 0x00])
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0x7a, cursor.size])
 | 
			
		||||
  meta.print(cursor)
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0xb8, 0x01, 0x00])
 | 
			
		||||
 | 
			
		||||
  meta.rewind
 | 
			
		||||
  meta = Base64.urlsafe_encode(meta.to_slice)
 | 
			
		||||
  meta = URI.escape(meta)
 | 
			
		||||
 | 
			
		||||
  continuation = IO::Memory.new
 | 
			
		||||
  continuation.write(Bytes[0x12, ucid.size])
 | 
			
		||||
  continuation.print(ucid)
 | 
			
		||||
 | 
			
		||||
  continuation.write(Bytes[0x1a])
 | 
			
		||||
  continuation.write(write_var_int(meta.size))
 | 
			
		||||
  continuation.print(meta)
 | 
			
		||||
 | 
			
		||||
  continuation.rewind
 | 
			
		||||
  continuation = continuation.gets_to_end
 | 
			
		||||
 | 
			
		||||
  wrapper = IO::Memory.new
 | 
			
		||||
  wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
 | 
			
		||||
  wrapper.write(write_var_int(continuation.size))
 | 
			
		||||
  wrapper.print(continuation)
 | 
			
		||||
  wrapper.rewind
 | 
			
		||||
 | 
			
		||||
  wrapper = Base64.urlsafe_encode(wrapper.to_slice)
 | 
			
		||||
  wrapper = URI.escape(wrapper)
 | 
			
		||||
 | 
			
		||||
  url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
 | 
			
		||||
 | 
			
		||||
  return url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_channel_playlists_cursor(url, auto_generated)
 | 
			
		||||
  wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
 | 
			
		||||
 | 
			
		||||
  wrapper = URI.unescape(wrapper)
 | 
			
		||||
  wrapper = Base64.decode(wrapper)
 | 
			
		||||
 | 
			
		||||
  # 0xe2 0xa9 0x85 0xb2 0x02
 | 
			
		||||
  wrapper += 5
 | 
			
		||||
 | 
			
		||||
  continuation_size = read_var_int(wrapper[0, 4])
 | 
			
		||||
  wrapper += write_var_int(continuation_size).size
 | 
			
		||||
  continuation = wrapper[0, continuation_size]
 | 
			
		||||
 | 
			
		||||
  # 0x12
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  ucid_size = continuation[0]
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  ucid = continuation[0, ucid_size]
 | 
			
		||||
  continuation += ucid_size
 | 
			
		||||
 | 
			
		||||
  # 0x1a
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  meta_size = read_var_int(continuation[0, 4])
 | 
			
		||||
  continuation += write_var_int(meta_size).size
 | 
			
		||||
  meta = continuation[0, meta_size]
 | 
			
		||||
  continuation += meta_size
 | 
			
		||||
 | 
			
		||||
  meta = String.new(meta)
 | 
			
		||||
  meta = URI.unescape(meta)
 | 
			
		||||
  meta = Base64.decode(meta)
 | 
			
		||||
 | 
			
		||||
  # 0x12 0x09 playlists
 | 
			
		||||
  meta += 11
 | 
			
		||||
 | 
			
		||||
  until meta[0] == 0x7a
 | 
			
		||||
    tag = read_var_int(meta[0, 4])
 | 
			
		||||
    meta += write_var_int(tag).size
 | 
			
		||||
    value = meta[0]
 | 
			
		||||
    meta += 1
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # 0x7a
 | 
			
		||||
  meta += 1
 | 
			
		||||
  cursor_size = meta[0]
 | 
			
		||||
  meta += 1
 | 
			
		||||
  cursor = meta[0, cursor_size]
 | 
			
		||||
 | 
			
		||||
  cursor = String.new(cursor)
 | 
			
		||||
 | 
			
		||||
  if !auto_generated
 | 
			
		||||
    cursor = URI.unescape(cursor)
 | 
			
		||||
    cursor = Base64.decode_string(cursor)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return cursor
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_about_info(ucid, locale)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
 | 
			
		||||
@ -290,7 +491,7 @@ def get_about_info(ucid, locale)
 | 
			
		||||
  sub_count ||= 0
 | 
			
		||||
 | 
			
		||||
  author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
 | 
			
		||||
  ucid = about.xpath_node(%q(//link[@rel="canonical"])).not_nil!["href"].split("/")[-1]
 | 
			
		||||
  ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
 | 
			
		||||
 | 
			
		||||
  # Auto-generated channels
 | 
			
		||||
  # https://support.google.com/youtube/answer/2579942
 | 
			
		||||
@ -334,3 +535,21 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
 | 
			
		||||
 | 
			
		||||
  return videos, count
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_latest_videos(ucid)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
  videos = [] of SearchVideo
 | 
			
		||||
 | 
			
		||||
  url = produce_channel_videos_url(ucid, 0)
 | 
			
		||||
  response = client.get(url)
 | 
			
		||||
  json = JSON.parse(response.body)
 | 
			
		||||
 | 
			
		||||
  if json["content_html"]? && !json["content_html"].as_s.empty?
 | 
			
		||||
    document = XML.parse_html(json["content_html"].as_s)
 | 
			
		||||
    nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
 | 
			
		||||
 | 
			
		||||
    videos = extract_videos(nodeset, ucid)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return videos
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -184,7 +184,7 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, region)
 | 
			
		||||
              json.field "content", content
 | 
			
		||||
              json.field "contentHtml", content_html
 | 
			
		||||
              json.field "published", published.to_unix
 | 
			
		||||
              json.field "publishedText", translate(locale, "`x` ago", recode_date(published))
 | 
			
		||||
              json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
 | 
			
		||||
              json.field "likeCount", node_comment["likeCount"]
 | 
			
		||||
              json.field "commentId", node_comment["commentId"]
 | 
			
		||||
              json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
 | 
			
		||||
@ -252,7 +252,7 @@ end
 | 
			
		||||
 | 
			
		||||
def fetch_reddit_comments(id)
 | 
			
		||||
  client = make_client(REDDIT_URL)
 | 
			
		||||
  headers = HTTP::Headers{"User-Agent" => "web:invidio.us:v0.14.0 (by /u/omarroth)"}
 | 
			
		||||
  headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"}
 | 
			
		||||
 | 
			
		||||
  query = "(url:3D#{id}%20OR%20url:#{id})%20(site:youtube.com%20OR%20site:youtu.be)"
 | 
			
		||||
  search_results = client.get("/search.json?q=#{query}", headers)
 | 
			
		||||
@ -310,7 +310,7 @@ def template_youtube_comments(comments, locale)
 | 
			
		||||
            <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
 | 
			
		||||
          </b> 
 | 
			
		||||
          <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>
 | 
			
		||||
          | 
 | 
			
		||||
@ -324,7 +324,7 @@ def template_youtube_comments(comments, locale)
 | 
			
		||||
              <div class="creator-heart">
 | 
			
		||||
                  <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
 | 
			
		||||
                  <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>
 | 
			
		||||
          </span>
 | 
			
		||||
@ -375,7 +375,7 @@ def template_reddit_comments(root, locale)
 | 
			
		||||
        <a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a> 
 | 
			
		||||
        <b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b> 
 | 
			
		||||
        #{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>
 | 
			
		||||
      <div>
 | 
			
		||||
      #{body_html}
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
class Config
 | 
			
		||||
  YAML.mapping({
 | 
			
		||||
    video_threads:   Int32,      # Number of threads to use for updating videos in cache (mostly non-functional)
 | 
			
		||||
    crawl_threads:   Int32,      # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
 | 
			
		||||
    channel_threads: Int32,      # Number of threads to use for crawling videos from channels (for updating subscriptions)
 | 
			
		||||
    feed_threads:    Int32,      # Number of threads to use for updating feeds
 | 
			
		||||
    video_threads:   Int32,      # Number of threads to use for updating videos in cache (mostly non-functional)
 | 
			
		||||
    db:              NamedTuple( # Database configuration
 | 
			
		||||
user: String,
 | 
			
		||||
      password: String,
 | 
			
		||||
@ -11,11 +11,19 @@ user: String,
 | 
			
		||||
      port: Int32,
 | 
			
		||||
      dbname: String,
 | 
			
		||||
    ),
 | 
			
		||||
    dl_api_key:   String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional
 | 
			
		||||
    https_only:   Bool?,   # Used to tell Invidious it is behind a proxy, so links to resources should be https://
 | 
			
		||||
    hmac_key:     String?, # HMAC signing key for CSRF tokens
 | 
			
		||||
    full_refresh: Bool,    # Used for crawling channels: threads should check all videos uploaded by a channel
 | 
			
		||||
    domain:       String,  # Domain to be used for links to resources on the site where an absolute URL is required
 | 
			
		||||
    full_refresh:         Bool,                         # Used for crawling channels: threads should check all videos uploaded by a channel
 | 
			
		||||
    https_only:           Bool?,                        # Used to tell Invidious it is behind a proxy, so links to resources should be https://
 | 
			
		||||
    hmac_key:             String?,                      # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
 | 
			
		||||
    domain:               String?,                      # Domain to be used for links to resources on the site where an absolute URL is required
 | 
			
		||||
    use_pubsub_feeds:     {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
 | 
			
		||||
    default_home:         {type: String, default: "Top"},
 | 
			
		||||
    feed_menu:            {type: Array(String), default: ["Popular", "Top", "Trending"]},
 | 
			
		||||
    top_enabled:          {type: Bool, default: true},
 | 
			
		||||
    captcha_enabled:      {type: Bool, default: true},
 | 
			
		||||
    login_enabled:        {type: Bool, default: true},
 | 
			
		||||
    registration_enabled: {type: Bool, default: true},
 | 
			
		||||
    statistics_enabled:   {type: Bool, default: false},
 | 
			
		||||
    admins:               {type: Array(String), default: [] of String},
 | 
			
		||||
  })
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -66,7 +74,7 @@ class DenyFrame < Kemal::Handler
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def rank_videos(db, n, filter, url)
 | 
			
		||||
def rank_videos(db, n)
 | 
			
		||||
  top = [] of {Float64, String}
 | 
			
		||||
 | 
			
		||||
  db.query("SELECT id, wilson_score, published FROM videos WHERE views > 5000 ORDER BY published DESC LIMIT 1000") do |rs|
 | 
			
		||||
@ -87,41 +95,7 @@ def rank_videos(db, n, filter, url)
 | 
			
		||||
  top.reverse!
 | 
			
		||||
  top = top.map { |a, b| b }
 | 
			
		||||
 | 
			
		||||
  if filter
 | 
			
		||||
    language_list = [] of String
 | 
			
		||||
    top.each do |id|
 | 
			
		||||
      if language_list.size == n
 | 
			
		||||
        break
 | 
			
		||||
      else
 | 
			
		||||
        client = make_client(url)
 | 
			
		||||
        begin
 | 
			
		||||
          video = get_video(id, db)
 | 
			
		||||
        rescue ex
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if video.language
 | 
			
		||||
          language = video.language
 | 
			
		||||
        else
 | 
			
		||||
          description = XML.parse(video.description)
 | 
			
		||||
          content = [video.title, description.content].join(" ")
 | 
			
		||||
          content = content[0, 10000]
 | 
			
		||||
 | 
			
		||||
          results = DetectLanguage.detect(content)
 | 
			
		||||
          language = results[0].language
 | 
			
		||||
 | 
			
		||||
          db.exec("UPDATE videos SET language = $1 WHERE id = $2", language, id)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if language == "en"
 | 
			
		||||
          language_list << id
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
    return language_list
 | 
			
		||||
  else
 | 
			
		||||
    return top[0..n - 1]
 | 
			
		||||
  end
 | 
			
		||||
  return top[0..n - 1]
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def login_req(login_form, f_req)
 | 
			
		||||
@ -166,29 +140,11 @@ def extract_videos(nodeset, ucid = nil)
 | 
			
		||||
  videos.map { |video| video.as(SearchVideo) }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_items(nodeset, ucid = nil)
 | 
			
		||||
def extract_items(nodeset, ucid = nil, author_name = nil)
 | 
			
		||||
  # TODO: Make this a 'common', so it makes more sense to be used here
 | 
			
		||||
  items = [] of SearchItem
 | 
			
		||||
 | 
			
		||||
  nodeset.each do |node|
 | 
			
		||||
    anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
 | 
			
		||||
    if !anchor
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if anchor["href"].starts_with? "https://www.googleadservices.com"
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
 | 
			
		||||
    if !anchor
 | 
			
		||||
      author = ""
 | 
			
		||||
      author_id = ""
 | 
			
		||||
    else
 | 
			
		||||
      author = anchor.content.strip
 | 
			
		||||
      author_id = anchor["href"].split("/")[-1]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
 | 
			
		||||
    if !anchor
 | 
			
		||||
      next
 | 
			
		||||
@ -196,6 +152,22 @@ def extract_items(nodeset, ucid = nil)
 | 
			
		||||
    title = anchor.content.strip
 | 
			
		||||
    id = anchor["href"]
 | 
			
		||||
 | 
			
		||||
    if anchor["href"].starts_with? "https://www.googleadservices.com"
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
 | 
			
		||||
    if anchor
 | 
			
		||||
      author = anchor.content.strip
 | 
			
		||||
      author_id = anchor["href"].split("/")[-1]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    author ||= author_name
 | 
			
		||||
    author_id ||= ucid
 | 
			
		||||
 | 
			
		||||
    author ||= ""
 | 
			
		||||
    author_id ||= ""
 | 
			
		||||
 | 
			
		||||
    description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
 | 
			
		||||
    description_html, description = html_to_content(description_html)
 | 
			
		||||
 | 
			
		||||
@ -354,3 +326,94 @@ def extract_items(nodeset, ucid = nil)
 | 
			
		||||
 | 
			
		||||
  return items
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
 | 
			
		||||
  items = [] of SearchPlaylist
 | 
			
		||||
 | 
			
		||||
  nodeset.each do |shelf|
 | 
			
		||||
    shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")]))
 | 
			
		||||
 | 
			
		||||
    if !shelf_anchor
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")]))
 | 
			
		||||
    if title
 | 
			
		||||
      title = title.content.strip
 | 
			
		||||
    end
 | 
			
		||||
    title ||= ""
 | 
			
		||||
 | 
			
		||||
    id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"]
 | 
			
		||||
    if !id
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    is_playlist = false
 | 
			
		||||
    videos = [] of SearchPlaylistVideo
 | 
			
		||||
 | 
			
		||||
    shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node|
 | 
			
		||||
      type = child_node.xpath_node(%q(./div))
 | 
			
		||||
      if !type
 | 
			
		||||
        next
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      case type["class"]
 | 
			
		||||
      when .includes? "yt-lockup-video"
 | 
			
		||||
        is_playlist = true
 | 
			
		||||
 | 
			
		||||
        anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
 | 
			
		||||
        if anchor
 | 
			
		||||
          video_title = anchor.content.strip
 | 
			
		||||
          video_id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
 | 
			
		||||
        end
 | 
			
		||||
        video_title ||= ""
 | 
			
		||||
        video_id ||= ""
 | 
			
		||||
 | 
			
		||||
        anchor = child_node.xpath_node(%q(.//span[@class="video-time"]))
 | 
			
		||||
        if anchor
 | 
			
		||||
          length_seconds = decode_length_seconds(anchor.content)
 | 
			
		||||
        end
 | 
			
		||||
        length_seconds ||= 0
 | 
			
		||||
 | 
			
		||||
        videos << SearchPlaylistVideo.new(
 | 
			
		||||
          video_title,
 | 
			
		||||
          video_id,
 | 
			
		||||
          length_seconds
 | 
			
		||||
        )
 | 
			
		||||
      when .includes? "yt-lockup-playlist"
 | 
			
		||||
        anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
 | 
			
		||||
        if anchor
 | 
			
		||||
          playlist_title = anchor.content.strip
 | 
			
		||||
          params = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)
 | 
			
		||||
          plid = params["list"]
 | 
			
		||||
        end
 | 
			
		||||
        playlist_title ||= ""
 | 
			
		||||
        plid ||= ""
 | 
			
		||||
 | 
			
		||||
        items << SearchPlaylist.new(
 | 
			
		||||
          playlist_title,
 | 
			
		||||
          plid,
 | 
			
		||||
          author_name,
 | 
			
		||||
          ucid,
 | 
			
		||||
          50,
 | 
			
		||||
          Array(SearchPlaylistVideo).new
 | 
			
		||||
        )
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if is_playlist
 | 
			
		||||
      plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
 | 
			
		||||
 | 
			
		||||
      items << SearchPlaylist.new(
 | 
			
		||||
        title,
 | 
			
		||||
        plid,
 | 
			
		||||
        author_name,
 | 
			
		||||
        ucid,
 | 
			
		||||
        videos.size,
 | 
			
		||||
        videos
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return items
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
@ -136,31 +136,26 @@ def decode_date(string : String)
 | 
			
		||||
  return Time.now - delta
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def recode_date(time : Time)
 | 
			
		||||
def recode_date(time : Time, locale)
 | 
			
		||||
  span = Time.now - time
 | 
			
		||||
 | 
			
		||||
  if span.total_days > 365.0
 | 
			
		||||
    span = {span.total_days / 365, "year"}
 | 
			
		||||
    span = translate(locale, "`x` years", (span.total_days.to_i / 365).to_s)
 | 
			
		||||
  elsif span.total_days > 30.0
 | 
			
		||||
    span = {span.total_days / 30, "month"}
 | 
			
		||||
    span = translate(locale, "`x` months", (span.total_days.to_i / 30).to_s)
 | 
			
		||||
  elsif span.total_days > 7.0
 | 
			
		||||
    span = {span.total_days / 7, "week"}
 | 
			
		||||
    span = translate(locale, "`x` weeks", (span.total_days.to_i / 7).to_s)
 | 
			
		||||
  elsif span.total_hours > 24.0
 | 
			
		||||
    span = {span.total_days, "day"}
 | 
			
		||||
    span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
 | 
			
		||||
  elsif span.total_minutes > 60.0
 | 
			
		||||
    span = {span.total_hours, "hour"}
 | 
			
		||||
    span = translate(locale, "`x` hours", (span.total_hours.to_i).to_s)
 | 
			
		||||
  elsif span.total_seconds > 60.0
 | 
			
		||||
    span = {span.total_minutes, "minute"}
 | 
			
		||||
    span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s)
 | 
			
		||||
  else
 | 
			
		||||
    span = {span.total_seconds, "second"}
 | 
			
		||||
    span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  span = {span[0].to_i, span[1]}
 | 
			
		||||
  if span[0] > 1
 | 
			
		||||
    span = {span[0], span[1] + "s"}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return span.join(" ")
 | 
			
		||||
  return span
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def number_with_separator(number)
 | 
			
		||||
@ -205,7 +200,12 @@ def make_host_url(ssl, host)
 | 
			
		||||
    scheme = "http://"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return "#{scheme}#{host}"
 | 
			
		||||
  if host
 | 
			
		||||
    host = host.lchop(".")
 | 
			
		||||
    return "#{scheme}#{host}"
 | 
			
		||||
  else
 | 
			
		||||
    return ""
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_referer(env, fallback = "/")
 | 
			
		||||
 | 
			
		||||
@ -55,7 +55,7 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
 | 
			
		||||
    active_channel = Channel(Bool).new
 | 
			
		||||
 | 
			
		||||
    loop do
 | 
			
		||||
      db.query("SELECT id FROM channels WHERE deleted = false ORDER BY updated") do |rs|
 | 
			
		||||
      db.query("SELECT id FROM channels ORDER BY updated") do |rs|
 | 
			
		||||
        rs.each do
 | 
			
		||||
          id = rs.read(String)
 | 
			
		||||
 | 
			
		||||
@ -68,13 +68,12 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
 | 
			
		||||
          active_threads += 1
 | 
			
		||||
          spawn do
 | 
			
		||||
            begin
 | 
			
		||||
              client = make_client(YT_URL)
 | 
			
		||||
              channel = fetch_channel(id, client, db, full_refresh)
 | 
			
		||||
              channel = fetch_channel(id, db, full_refresh)
 | 
			
		||||
 | 
			
		||||
              db.exec("UPDATE channels SET updated = $1, author = $2 WHERE id = $3", Time.now, channel.author, id)
 | 
			
		||||
              db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.now, channel.author, id)
 | 
			
		||||
            rescue ex
 | 
			
		||||
              if ex.message == "Deleted or invalid channel"
 | 
			
		||||
                db.exec("UPDATE channels SET deleted = true WHERE id = $1", id)
 | 
			
		||||
                db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.now, id)
 | 
			
		||||
              end
 | 
			
		||||
              logger.write("#{id} : #{ex.message}\n")
 | 
			
		||||
            end
 | 
			
		||||
@ -132,7 +131,16 @@ def refresh_feeds(db, logger, max_threads = 1)
 | 
			
		||||
            begin
 | 
			
		||||
              db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
 | 
			
		||||
            rescue ex
 | 
			
		||||
              logger.write("REFRESH #{email} : #{ex.message}\n")
 | 
			
		||||
              # Create view if it doesn't exist
 | 
			
		||||
              if ex.message.try &.ends_with? "does not exist"
 | 
			
		||||
                db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
 | 
			
		||||
                SELECT * FROM channel_videos WHERE \
 | 
			
		||||
                ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
 | 
			
		||||
                ORDER BY published DESC;")
 | 
			
		||||
                logger.write("CREATE #{view_name}")
 | 
			
		||||
              else
 | 
			
		||||
                logger.write("REFRESH #{email} : #{ex.message}\n")
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            active_channel.send(true)
 | 
			
		||||
@ -145,19 +153,30 @@ def refresh_feeds(db, logger, max_threads = 1)
 | 
			
		||||
  max_channel.send(max_threads)
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def pull_top_videos(config, db)
 | 
			
		||||
  if config.dl_api_key
 | 
			
		||||
    DetectLanguage.configure do |dl_config|
 | 
			
		||||
      dl_config.api_key = config.dl_api_key.not_nil!
 | 
			
		||||
def subscribe_to_feeds(db, logger, key, config)
 | 
			
		||||
  if config.use_pubsub_feeds
 | 
			
		||||
    spawn do
 | 
			
		||||
      loop do
 | 
			
		||||
        db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs|
 | 
			
		||||
          ucid = rs.read(String)
 | 
			
		||||
          response = subscribe_pubsub(ucid, key, config)
 | 
			
		||||
 | 
			
		||||
          if response.status_code >= 400
 | 
			
		||||
            logger.write("#{ucid} : #{response.body}\n")
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        sleep 1.minute
 | 
			
		||||
        Fiber.yield
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
    filter = true
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
  filter ||= false
 | 
			
		||||
 | 
			
		||||
def pull_top_videos(config, db)
 | 
			
		||||
  loop do
 | 
			
		||||
    begin
 | 
			
		||||
      top = rank_videos(db, 40, filter, YT_URL)
 | 
			
		||||
      top = rank_videos(db, 40)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
@ -185,11 +204,11 @@ end
 | 
			
		||||
 | 
			
		||||
def pull_popular_videos(db)
 | 
			
		||||
  loop do
 | 
			
		||||
    subscriptions = PG_DB.query_all("SELECT channel FROM \
 | 
			
		||||
    subscriptions = db.query_all("SELECT channel FROM \
 | 
			
		||||
      (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
 | 
			
		||||
    GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String)
 | 
			
		||||
 | 
			
		||||
    videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM \
 | 
			
		||||
    videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM \
 | 
			
		||||
      channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \
 | 
			
		||||
    ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -43,8 +43,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
 | 
			
		||||
  mix_title = playlist["title"].as_s
 | 
			
		||||
 | 
			
		||||
  contents = playlist["contents"].as_a
 | 
			
		||||
  until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id
 | 
			
		||||
    contents.shift
 | 
			
		||||
  if contents.map { |video| video["playlistPanelVideoRenderer"]["videoId"] }.includes? video_id
 | 
			
		||||
    until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id
 | 
			
		||||
      contents.shift
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  videos = [] of MixVideo
 | 
			
		||||
@ -52,7 +54,10 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
 | 
			
		||||
    item = item["playlistPanelVideoRenderer"]
 | 
			
		||||
 | 
			
		||||
    id = item["videoId"].as_s
 | 
			
		||||
    title = item["title"]["simpleText"].as_s
 | 
			
		||||
    title = item["title"]?.try &.["simpleText"].as_s
 | 
			
		||||
    if !title
 | 
			
		||||
      next
 | 
			
		||||
    end
 | 
			
		||||
    author = item["longBylineText"]["runs"][0]["text"].as_s
 | 
			
		||||
    ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
 | 
			
		||||
    length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
 | 
			
		||||
@ -94,7 +99,10 @@ def template_mix(mix)
 | 
			
		||||
    html += <<-END_HTML
 | 
			
		||||
      <li class="pure-menu-item">
 | 
			
		||||
        <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>
 | 
			
		||||
              <b style="width: 100%">#{video["author"]}</b>
 | 
			
		||||
 | 
			
		||||
@ -161,117 +161,6 @@ def produce_playlist_url(id, index)
 | 
			
		||||
  return url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def produce_channel_playlists_url(ucid, cursor, sort = "newest")
 | 
			
		||||
  cursor = Base64.urlsafe_encode(cursor, false)
 | 
			
		||||
 | 
			
		||||
  meta = IO::Memory.new
 | 
			
		||||
  meta.write(Bytes[0x12, 0x09])
 | 
			
		||||
  meta.print("playlists")
 | 
			
		||||
 | 
			
		||||
  # TODO: Look at 0x01, 0x00
 | 
			
		||||
  case sort
 | 
			
		||||
  when "oldest", "oldest_created"
 | 
			
		||||
    meta.write(Bytes[0x18, 0x02])
 | 
			
		||||
  when "newest", "newest_created"
 | 
			
		||||
    meta.write(Bytes[0x18, 0x03])
 | 
			
		||||
  when "last", "last_added"
 | 
			
		||||
    meta.write(Bytes[0x18, 0x04])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0x20, 0x01])
 | 
			
		||||
  meta.write(Bytes[0x30, 0x02])
 | 
			
		||||
  meta.write(Bytes[0x38, 0x01])
 | 
			
		||||
  meta.write(Bytes[0x60, 0x01])
 | 
			
		||||
  meta.write(Bytes[0x6a, 0x00])
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0x7a, cursor.size])
 | 
			
		||||
  meta.print(cursor)
 | 
			
		||||
 | 
			
		||||
  meta.write(Bytes[0xb8, 0x01, 0x00])
 | 
			
		||||
 | 
			
		||||
  meta.rewind
 | 
			
		||||
  meta = Base64.urlsafe_encode(meta.to_slice)
 | 
			
		||||
  meta = URI.escape(meta)
 | 
			
		||||
 | 
			
		||||
  continuation = IO::Memory.new
 | 
			
		||||
  continuation.write(Bytes[0x12, ucid.size])
 | 
			
		||||
  continuation.print(ucid)
 | 
			
		||||
 | 
			
		||||
  continuation.write(Bytes[0x1a])
 | 
			
		||||
  continuation.write(write_var_int(meta.size))
 | 
			
		||||
  continuation.print(meta)
 | 
			
		||||
 | 
			
		||||
  continuation.rewind
 | 
			
		||||
  continuation = continuation.gets_to_end
 | 
			
		||||
 | 
			
		||||
  wrapper = IO::Memory.new
 | 
			
		||||
  wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
 | 
			
		||||
  wrapper.write(write_var_int(continuation.size))
 | 
			
		||||
  wrapper.print(continuation)
 | 
			
		||||
  wrapper.rewind
 | 
			
		||||
 | 
			
		||||
  wrapper = Base64.urlsafe_encode(wrapper.to_slice)
 | 
			
		||||
  wrapper = URI.escape(wrapper)
 | 
			
		||||
 | 
			
		||||
  url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
 | 
			
		||||
 | 
			
		||||
  return url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def extract_channel_playlists_cursor(url)
 | 
			
		||||
  wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
 | 
			
		||||
 | 
			
		||||
  wrapper = URI.unescape(wrapper)
 | 
			
		||||
  wrapper = Base64.decode(wrapper)
 | 
			
		||||
 | 
			
		||||
  # 0xe2 0xa9 0x85 0xb2 0x02
 | 
			
		||||
  wrapper += 5
 | 
			
		||||
 | 
			
		||||
  continuation_size = read_var_int(wrapper[0, 4])
 | 
			
		||||
  wrapper += write_var_int(continuation_size).size
 | 
			
		||||
  continuation = wrapper[0, continuation_size]
 | 
			
		||||
 | 
			
		||||
  # 0x12
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  ucid_size = continuation[0]
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  ucid = continuation[0, ucid_size]
 | 
			
		||||
  continuation += ucid_size
 | 
			
		||||
 | 
			
		||||
  # 0x1a
 | 
			
		||||
  continuation += 1
 | 
			
		||||
  meta_size = read_var_int(continuation[0, 4])
 | 
			
		||||
  continuation += write_var_int(meta_size).size
 | 
			
		||||
  meta = continuation[0, meta_size]
 | 
			
		||||
  continuation += meta_size
 | 
			
		||||
 | 
			
		||||
  meta = String.new(meta)
 | 
			
		||||
  meta = URI.unescape(meta)
 | 
			
		||||
  meta = Base64.decode(meta)
 | 
			
		||||
 | 
			
		||||
  # 0x12 0x09 playlists
 | 
			
		||||
  meta += 11
 | 
			
		||||
 | 
			
		||||
  until meta[0] == 0x7a
 | 
			
		||||
    tag = read_var_int(meta[0, 4])
 | 
			
		||||
    meta += write_var_int(tag).size
 | 
			
		||||
    value = meta[0]
 | 
			
		||||
    meta += 1
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # 0x7a
 | 
			
		||||
  meta += 1
 | 
			
		||||
  cursor_size = meta[0]
 | 
			
		||||
  meta += 1
 | 
			
		||||
  cursor = meta[0, cursor_size]
 | 
			
		||||
 | 
			
		||||
  cursor = String.new(cursor)
 | 
			
		||||
  cursor = URI.unescape(cursor)
 | 
			
		||||
  cursor = Base64.decode_string(cursor)
 | 
			
		||||
 | 
			
		||||
  return cursor
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def fetch_playlist(plid, locale)
 | 
			
		||||
  client = make_client(YT_URL)
 | 
			
		||||
 | 
			
		||||
@ -345,7 +234,10 @@ def template_playlist(playlist)
 | 
			
		||||
    html += <<-END_HTML
 | 
			
		||||
      <li class="pure-menu-item">
 | 
			
		||||
        <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>
 | 
			
		||||
              <b style="width: 100%">#{video["author"]}</b>
 | 
			
		||||
 | 
			
		||||
@ -188,7 +188,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
 | 
			
		||||
            end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if body.size > 0
 | 
			
		||||
  if !body.empty?
 | 
			
		||||
    token = head + "\x12" + body.size.unsafe_chr + body
 | 
			
		||||
  else
 | 
			
		||||
    token = head
 | 
			
		||||
 | 
			
		||||
@ -39,7 +39,12 @@ def fetch_decrypt_function(id = "CvFH_6DNRCY")
 | 
			
		||||
  return decrypt_function
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def decrypt_signature(a, code)
 | 
			
		||||
def decrypt_signature(fmt, code)
 | 
			
		||||
  if !fmt["s"]?
 | 
			
		||||
    return ""
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  a = fmt["s"]
 | 
			
		||||
  a = a.split("")
 | 
			
		||||
 | 
			
		||||
  code.each do |item|
 | 
			
		||||
@ -53,7 +58,8 @@ def decrypt_signature(a, code)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return a.join("")
 | 
			
		||||
  signature = a.join("")
 | 
			
		||||
  return "&#{fmt["sp"]?}=#{signature}"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def splice(a, b)
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,6 @@ class User
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  add_mapping({
 | 
			
		||||
    id:            Array(String),
 | 
			
		||||
    updated:       Time,
 | 
			
		||||
    notifications: Array(String),
 | 
			
		||||
    subscriptions: Array(String),
 | 
			
		||||
@ -126,49 +125,55 @@ class Preferences
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_user(sid, headers, db, refresh = true)
 | 
			
		||||
  if db.query_one?("SELECT EXISTS (SELECT true FROM users WHERE $1 = ANY(id))", sid, as: Bool)
 | 
			
		||||
    user = db.query_one("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
 | 
			
		||||
  if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
 | 
			
		||||
    user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
 | 
			
		||||
 | 
			
		||||
    if refresh && Time.now - user.updated > 1.minute
 | 
			
		||||
      user = fetch_user(sid, headers, db)
 | 
			
		||||
      user, sid = fetch_user(sid, headers, db)
 | 
			
		||||
      user_array = user.to_a
 | 
			
		||||
 | 
			
		||||
      user_array[5] = user_array[5].to_json
 | 
			
		||||
      user_array[4] = user_array[4].to_json
 | 
			
		||||
      args = arg_array(user_array)
 | 
			
		||||
 | 
			
		||||
      db.exec("INSERT INTO users VALUES (#{args}) \
 | 
			
		||||
      ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
 | 
			
		||||
      ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
 | 
			
		||||
 | 
			
		||||
      db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
 | 
			
		||||
      ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        view_name = "subscriptions_#{sha256(user.email)[0..7]}"
 | 
			
		||||
        PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
 | 
			
		||||
        db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
 | 
			
		||||
        SELECT * FROM channel_videos WHERE \
 | 
			
		||||
        ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \
 | 
			
		||||
        ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
 | 
			
		||||
        ORDER BY published DESC;")
 | 
			
		||||
      rescue ex
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  else
 | 
			
		||||
    user = fetch_user(sid, headers, db)
 | 
			
		||||
    user, sid = fetch_user(sid, headers, db)
 | 
			
		||||
    user_array = user.to_a
 | 
			
		||||
 | 
			
		||||
    user_array[5] = user_array[5].to_json
 | 
			
		||||
    user_array[4] = user_array[4].to_json
 | 
			
		||||
    args = arg_array(user.to_a)
 | 
			
		||||
 | 
			
		||||
    db.exec("INSERT INTO users VALUES (#{args}) \
 | 
			
		||||
    ON CONFLICT (email) DO UPDATE SET id = users.id || $1, updated = $2, subscriptions = $4", user_array)
 | 
			
		||||
    ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
 | 
			
		||||
 | 
			
		||||
    db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
 | 
			
		||||
    ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      view_name = "subscriptions_#{sha256(user.email)[0..7]}"
 | 
			
		||||
      PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
 | 
			
		||||
      db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
 | 
			
		||||
      SELECT * FROM channel_videos WHERE \
 | 
			
		||||
      ucid = ANY ((SELECT subscriptions FROM users WHERE email = '#{user.email}')::text[]) \
 | 
			
		||||
      ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
 | 
			
		||||
      ORDER BY published DESC;")
 | 
			
		||||
    rescue ex
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return user
 | 
			
		||||
  return user, sid
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def fetch_user(sid, headers, db)
 | 
			
		||||
@ -196,17 +201,17 @@ def fetch_user(sid, headers, db)
 | 
			
		||||
 | 
			
		||||
  token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
 | 
			
		||||
 | 
			
		||||
  user = User.new([sid], Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
 | 
			
		||||
  return user
 | 
			
		||||
  user = User.new(Time.now, [] of String, channels, email, DEFAULT_USER_PREFERENCES, nil, token, [] of String)
 | 
			
		||||
  return user, sid
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def create_user(sid, email, password)
 | 
			
		||||
  password = Crypto::Bcrypt::Password.create(password, cost: 10)
 | 
			
		||||
  token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
 | 
			
		||||
 | 
			
		||||
  user = User.new([sid], Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
 | 
			
		||||
  user = User.new(Time.now, [] of String, [] of String, email, DEFAULT_USER_PREFERENCES, password.to_s, token, [] of String)
 | 
			
		||||
 | 
			
		||||
  return user
 | 
			
		||||
  return user, sid
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def create_response(user_id, operation, key, db, expire = 6.hours)
 | 
			
		||||
@ -242,7 +247,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
 | 
			
		||||
    raise translate(locale, "Invalid challenge")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge)
 | 
			
		||||
  challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
 | 
			
		||||
  challenge = Base64.urlsafe_encode(challenge)
 | 
			
		||||
 | 
			
		||||
  if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)
 | 
			
		||||
 | 
			
		||||
@ -263,7 +263,7 @@ class Video
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def keywords
 | 
			
		||||
    keywords = self.player_response["videoDetails"]["keywords"]?.try &.as_a
 | 
			
		||||
    keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
 | 
			
		||||
    keywords ||= [] of String
 | 
			
		||||
 | 
			
		||||
    return keywords
 | 
			
		||||
@ -271,9 +271,51 @@ class Video
 | 
			
		||||
 | 
			
		||||
  def fmt_stream(decrypt_function)
 | 
			
		||||
    streams = [] of HTTP::Params
 | 
			
		||||
    self.info["url_encoded_fmt_stream_map"].split(",") do |string|
 | 
			
		||||
      if !string.empty?
 | 
			
		||||
        streams << HTTP::Params.parse(string)
 | 
			
		||||
 | 
			
		||||
    if fmt_streams = self.player_response["streamingData"]?.try &.["formats"]?
 | 
			
		||||
      fmt_streams.as_a.each do |fmt_stream|
 | 
			
		||||
        if !fmt_stream.as_h?
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        fmt = {} of String => String
 | 
			
		||||
 | 
			
		||||
        fmt["lmt"] = fmt_stream["lastModified"]?.try &.as_s || "0"
 | 
			
		||||
        fmt["projection_type"] = "1"
 | 
			
		||||
        fmt["type"] = fmt_stream["mimeType"].as_s
 | 
			
		||||
        fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0"
 | 
			
		||||
        fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
 | 
			
		||||
        fmt["itag"] = fmt_stream["itag"].as_i.to_s
 | 
			
		||||
        fmt["url"] = fmt_stream["url"].as_s
 | 
			
		||||
        fmt["quality"] = fmt_stream["quality"].as_s
 | 
			
		||||
 | 
			
		||||
        if fmt_stream["width"]?
 | 
			
		||||
          fmt["size"] = "#{fmt_stream["width"]}x#{fmt_stream["height"]}"
 | 
			
		||||
          fmt["height"] = fmt_stream["height"].as_i.to_s
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if fmt_stream["fps"]?
 | 
			
		||||
          fmt["fps"] = fmt_stream["fps"].as_i.to_s
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if fmt_stream["qualityLabel"]?
 | 
			
		||||
          fmt["quality_label"] = fmt_stream["qualityLabel"].as_s
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        params = HTTP::Params.new
 | 
			
		||||
        fmt.each do |key, value|
 | 
			
		||||
          params[key] = value
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        streams << params
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      streams.sort_by! { |stream| stream["height"].to_i }.reverse!
 | 
			
		||||
    elsif fmt_stream = self.info["url_encoded_fmt_stream_map"]?
 | 
			
		||||
      fmt_stream.split(",").each do |string|
 | 
			
		||||
        if !string.empty?
 | 
			
		||||
          streams << HTTP::Params.parse(string)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
@ -286,10 +328,8 @@ class Video
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if streams[0]? && streams[0]["s"]?
 | 
			
		||||
      streams.each do |fmt|
 | 
			
		||||
        fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
 | 
			
		||||
      end
 | 
			
		||||
    streams.each do |fmt|
 | 
			
		||||
      fmt["url"] += decrypt_signature(fmt, decrypt_function)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    return streams
 | 
			
		||||
@ -298,80 +338,54 @@ class Video
 | 
			
		||||
  def adaptive_fmts(decrypt_function)
 | 
			
		||||
    adaptive_fmts = [] of HTTP::Params
 | 
			
		||||
 | 
			
		||||
    if self.info.has_key?("adaptive_fmts")
 | 
			
		||||
      self.info["adaptive_fmts"].split(",") do |string|
 | 
			
		||||
        adaptive_fmts << HTTP::Params.parse(string)
 | 
			
		||||
      end
 | 
			
		||||
    elsif self.info.has_key?("dashmpd")
 | 
			
		||||
      client = make_client(YT_URL)
 | 
			
		||||
      response = client.get(self.info["dashmpd"])
 | 
			
		||||
      document = XML.parse_html(response.body)
 | 
			
		||||
 | 
			
		||||
      document.xpath_nodes(%q(//adaptationset)).each do |adaptation_set|
 | 
			
		||||
        mime_type = adaptation_set["mimetype"]
 | 
			
		||||
 | 
			
		||||
        document.xpath_nodes(%q(.//representation)).each do |representation|
 | 
			
		||||
          codecs = representation["codecs"]
 | 
			
		||||
          itag = representation["id"]
 | 
			
		||||
          bandwidth = representation["bandwidth"]
 | 
			
		||||
          url = representation.xpath_node(%q(.//baseurl)).not_nil!.content
 | 
			
		||||
 | 
			
		||||
          clen = url.match(/clen\/(?<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)
 | 
			
		||||
    if fmts = self.player_response["streamingData"]?.try &.["adaptiveFormats"]?
 | 
			
		||||
      fmts.as_a.each do |adaptive_fmt|
 | 
			
		||||
        if !adaptive_fmt.as_h?
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        fmt = {} of String => String
 | 
			
		||||
 | 
			
		||||
        if init = adaptive_fmt["initRange"]?
 | 
			
		||||
          fmt["init"] = "#{init["start"]}-#{init["end"]}"
 | 
			
		||||
        end
 | 
			
		||||
        fmt["init"] ||= "0-0"
 | 
			
		||||
 | 
			
		||||
        fmt["lmt"] = adaptive_fmt["lastModified"]?.try &.as_s || "0"
 | 
			
		||||
        fmt["projection_type"] = "1"
 | 
			
		||||
        fmt["type"] = adaptive_fmt["mimeType"].as_s
 | 
			
		||||
        fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
 | 
			
		||||
        fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
 | 
			
		||||
        fmt["itag"] = adaptive_fmt["itag"].as_i.to_s
 | 
			
		||||
        fmt["url"] = adaptive_fmt["url"].as_s
 | 
			
		||||
 | 
			
		||||
        if index = adaptive_fmt["indexRange"]?
 | 
			
		||||
          fmt["index"] = "#{index["start"]}-#{index["end"]}"
 | 
			
		||||
        end
 | 
			
		||||
        fmt["index"] ||= "0-0"
 | 
			
		||||
 | 
			
		||||
        if adaptive_fmt["width"]?
 | 
			
		||||
          fmt["size"] = "#{adaptive_fmt["width"]}x#{adaptive_fmt["height"]}"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if adaptive_fmt["fps"]?
 | 
			
		||||
          fmt["fps"] = adaptive_fmt["fps"].as_i.to_s
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if adaptive_fmt["qualityLabel"]?
 | 
			
		||||
          fmt["quality_label"] = adaptive_fmt["qualityLabel"].as_s
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        params = HTTP::Params.new
 | 
			
		||||
        fmt.each do |key, value|
 | 
			
		||||
          params[key] = value
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        adaptive_fmts << params
 | 
			
		||||
      end
 | 
			
		||||
    elsif fmts = self.info["adaptive_fmts"]?
 | 
			
		||||
      fmts.split(",") do |string|
 | 
			
		||||
        adaptive_fmts << HTTP::Params.parse(string)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
@ -381,23 +395,21 @@ class Video
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
 | 
			
		||||
      adaptive_fmts.each do |fmt|
 | 
			
		||||
        fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
 | 
			
		||||
      end
 | 
			
		||||
    adaptive_fmts.each do |fmt|
 | 
			
		||||
      fmt["url"] += decrypt_signature(fmt, decrypt_function)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    return adaptive_fmts
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def video_streams(adaptive_fmts)
 | 
			
		||||
    video_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("video") ? s : nil }
 | 
			
		||||
    video_streams = adaptive_fmts.select { |s| s["type"].starts_with? "video" }
 | 
			
		||||
 | 
			
		||||
    return video_streams
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def audio_streams(adaptive_fmts)
 | 
			
		||||
    audio_streams = adaptive_fmts.compact_map { |s| s["type"].starts_with?("audio") ? s : nil }
 | 
			
		||||
    audio_streams = adaptive_fmts.select { |s| s["type"].starts_with? "audio" }
 | 
			
		||||
    audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
 | 
			
		||||
    audio_streams.each do |stream|
 | 
			
		||||
      stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s
 | 
			
		||||
@ -624,7 +636,10 @@ def fetch_video(id, proxies, region)
 | 
			
		||||
 | 
			
		||||
  # Try to pull streams from embed URL
 | 
			
		||||
  if info["reason"]?
 | 
			
		||||
    embed_info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&ps=default&eurl=&gl=US&hl=en&disable_polymer=1").body)
 | 
			
		||||
    embed_page = client.get("/embed/#{id}").body
 | 
			
		||||
    sts = embed_page.match(/"sts"\s*:\s*(?<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"]?
 | 
			
		||||
      embed_info.each do |key, value|
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user