Merge branch 'master' into api-only

This commit is contained in:
Omar Roth 2019-03-25 09:28:21 -05:00
commit 705d307b60
25 changed files with 1085 additions and 787 deletions

View File

@ -1,3 +1,65 @@
# 0.15.0 (2019-03-06)
## Version 0.15.0: Preferences and Channel Playlists
The project has seen quite a bit of activity this past month. Large focus has been on fixing bugs, but there's still quite a few new features I'm happy to announce. There have been [133 commits](https://github.com/omarroth/invidious/compare/0.14.0...0.15.0) from 15 contributors this past month.
As a couple miscellaneous changes, a couple [nice screenshots](https://github.com/omarroth/invidious#screenshots) have been added to the README, so folks can see more of what the site has to offer without creating an account.
The footer has also been cleaned up quite a bit, and now displays the current version, so it's easier to know what features are available from the current instance.
## For Administrators
This past month there has been a minor release - `0.14.1` - which fixes a breaking change made by YouTube for their polymer redesign.
There have been several new features that unfortunately require a database migration. There are migration scripts provided in `config/migrate-scripts`, and the [wiki](https://github.com/omarroth/invidious/wiki/Updating) has instructions for automatically applying them. I'll do my best to keep those changes to a minimum, and expect to see a corresponding script to automatically apply any new changes.
Administrator preferences have been added with [#312](https://github.com/omarroth/invidious/issues/312), which allows administrators to customize their instance. Administrators can change the order of feed menus, change the default homepage, disable open registration, and several other options. There's a short 'how-to' [here](https://github.com/omarroth/invidious/issues/312#issuecomment-468831842), and the new options are documented [here](https://github.com/omarroth/invidious/wiki/Configuration).
An `/api/v1/stats` endpoint has been added with [#356](https://github.com/omarroth/invidious/issues/356), which reports the instance version and number of active users. Statistics are disabled by default, and can be enabled in administator preferences. Statistics for the official instance are available [here](https://invidio.us/api/v1/stats?pretty=1).
## For Developers
`/api/v1/channels/:ucid` now provides an `autoGenerated` tag, which returns true for [topic channels](https://www.youtube.com/channel/UCE80FOXpJydkkMo-BYoJdEg), and larger [genre channels](https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) generated by YouTube. These channels don't have any videos of their own, so `latestVideos` will be empty. It is recommended instead to display a list of playlists generated by YouTube.
You can now pull a list of playlists from a channel with `/api/v1/channels/playlists/:ucid`. Supported options are documented in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelsplaylistsucid-apiv1channelsucidplaylists). Pagination is handled with a `continuation` token, which is generated on each call. Of note is that auto-generated channels currently have one page of results, and subsequent calls will be empty.
For quickly pulling the latest 30 videos from a channel, there is now `/api/v1/channels/latest/:ucid`. It is much faster than a call to `/api/v1/channels/:ucid`. It will not convert an author name to a valid ucid automatically, and will not return any extra data about a channel.
## Preferences
In addition to administrator preferences mentioned above, you can now change your preferences without an account (see [#42](https://github.com/omarroth/invidious/pull/42)). I think this is quite an improvement to the usability of the site, and is much friendlier to privacy-conscious folks that don't want to make an account. Preferences will be automatically imported to a newly created account.
Several issues with sorting subscriptions have been fixed, and `/manage_subscriptions` has been sped up significantly. The subscription feed has also seen a bump in performance. Delayed notifications have unfortunately started becoming a problem now that there are more users on the site. Some new changes are currently being tested which should mostly resolve the issue, so expect to see more in the next release.
## Channel Playlists
You can now view available playlists from a channel, and [auto-generated channels](https://invidio.us/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) are no longer empty. You can sort as you would on YouTube, and all the same functionality should be available. I'm quite pleased to finally have it implemented, since it's currently the only data available from the above mentioned auto-generated channels, and makes it much easier to consume music on the site.
There's also more discussion on improving Invidious for streaming music in [#304](https://github.com/omarroth/invidious/issues/304), and adding support for music.youtube.com. I would appreciate any thoughts on how to improve that experience, since it's a very large and useful part of YouTube.
## Finances
### Donations
[Patreon](https://www.patreon.com/omarroth) : \$42.42
[Liberapay](https://liberapay.com/omarroth) : \$30.97
Crypto : ~\$0.00 (converted from BCH, BTC)
Total : \$73.39
### Expenses
invidious-load1 (nyc1) : \$10.00 (load balancer)
invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
Total : \$75.00
It's been very humbling to see how fast the project has grown, and I look forward to making the site even better. Thank you everyone.
# 0.14.0 (2019-02-06) # 0.14.0 (2019-02-06)
## Version 0.14.0: Community ## Version 0.14.0: Community

View File

@ -3,7 +3,7 @@
## Invidious is an alternative front-end to YouTube ## Invidious is an alternative front-end to YouTube
- Audio-only mode (and no need to keep window open on mobile) - Audio-only mode (and no need to keep window open on mobile)
- [Open-source](https://github.com/omarroth/invidious) (AGPLv3 licensed) - [Free software](https://github.com/omarroth/invidious) (AGPLv3 licensed)
- No ads - No ads
- No need to create a Google account to save subscriptions - No need to create a Google account to save subscriptions
- Lightweight (homepage is ~4 KB compressed) - Lightweight (homepage is ~4 KB compressed)

View File

@ -1,5 +1,3 @@
video_threads: 0
crawl_threads: 0
channel_threads: 1 channel_threads: 1
feed_threads: 1 feed_threads: 1
db: db:

View File

@ -10,8 +10,9 @@
"newest": "الأجدد", "newest": "الأجدد",
"oldest": "الأقدم", "oldest": "الأقدم",
"popular": "الاكثر شعبية", "popular": "الاكثر شعبية",
"Preview page": "معاينة الصفحة", "last": "",
"Next page": "الصفحة الثانية", "Next page": "الصفحة الثانية",
"Previous page": "الصفحة السابقة",
"Clear watch history?": "مسح السجل ؟", "Clear watch history?": "مسح السجل ؟",
"Yes": "نعم", "Yes": "نعم",
"No": "لا", "No": "لا",
@ -28,7 +29,6 @@
"Export data as JSON": "استخراج البيانات كـ JSON", "Export data as JSON": "استخراج البيانات كـ JSON",
"Delete account?": "حذف الحساب ؟", "Delete account?": "حذف الحساب ؟",
"History": "السجل", "History": "السجل",
"Previous page": "الصفحة السابقة",
"An alternative front-end to YouTube": "البديل الكامل لموقع يوتيوب", "An alternative front-end to YouTube": "البديل الكامل لموقع يوتيوب",
"JavaScript license information": "معلومات ترخيص JavaScript", "JavaScript license information": "معلومات ترخيص JavaScript",
"source": "المصدر", "source": "المصدر",
@ -50,6 +50,7 @@
"Autoplay: ": "تشغيل تلقائى: ", "Autoplay: ": "تشغيل تلقائى: ",
"Autoplay next video: ": "شغل الفيديو التالى تلقائى: ", "Autoplay next video: ": "شغل الفيديو التالى تلقائى: ",
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ", "Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
"Proxy videos? ": "",
"Default speed: ": "السرعة الإفتراضية: ", "Default speed: ": "السرعة الإفتراضية: ",
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ", "Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
"Player volume: ": "صوت المشغل: ", "Player volume: ": "صوت المشغل: ",
@ -101,11 +102,8 @@
"Sign out": "تسجيل الخروج", "Sign out": "تسجيل الخروج",
"Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.",
"Source available here.": "الأكواد متوفرة هنا.", "Source available here.": "الأكواد متوفرة هنا.",
"Liberapay: ": "ليبرباى: ",
"Patreon: ": "باتريون: ",
"BTC: ": "بيتكوين: ",
"BCH: ": "بيتكوين كاش: ",
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
"View privacy policy.": "",
"Trending": "الشائع", "Trending": "الشائع",
"Watch video on Youtube": "مشاهدة الفيديو على اليوتيوب", "Watch video on Youtube": "مشاهدة الفيديو على اليوتيوب",
"Genre: ": "النوع: ", "Genre: ": "النوع: ",
@ -286,9 +284,12 @@
"Download as: ": "تحميل كـ", "Download as: ": "تحميل كـ",
"Download": "تحميل", "Download": "تحميل",
"%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' اعجب بهذا",
"Audio mode": "", "Audio mode": "الوضع الصوتى",
"Video mode": "" "Video mode": "وضع الفيديو",
"Videos": "الفيديوهات",
"Playlists": "قوائم التشغيل",
"Current version: ": "الإصدار الحالى"
} }

View File

@ -10,8 +10,9 @@
"newest": "neueste", "newest": "neueste",
"oldest": "älteste", "oldest": "älteste",
"popular": "beliebt", "popular": "beliebt",
"Preview page": "Vorschau Seite", "last": "",
"Next page": "Nächste Seite", "Next page": "Nächste Seite",
"Previous page": "Vorherige Seite",
"Clear watch history?": "Verlauf löschen?", "Clear watch history?": "Verlauf löschen?",
"Yes": "Ja", "Yes": "Ja",
"No": "Nein", "No": "Nein",
@ -28,7 +29,6 @@
"Export data as JSON": "Daten als JSON exportieren", "Export data as JSON": "Daten als JSON exportieren",
"Delete account?": "Account löschen?", "Delete account?": "Account löschen?",
"History": "Verlauf", "History": "Verlauf",
"Previous page": "Vorherige Seite",
"An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube", "An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube",
"JavaScript license information": "JavaScript Lizenzinformationen", "JavaScript license information": "JavaScript Lizenzinformationen",
"source": "Quelle", "source": "Quelle",
@ -50,6 +50,7 @@
"Autoplay: ": "Automatisch abspielen: ", "Autoplay: ": "Automatisch abspielen: ",
"Autoplay next video: ": "nächstes Video automatisch abspielen: ", "Autoplay next video: ": "nächstes Video automatisch abspielen: ",
"Listen by default: ": "Nur Ton als Standard: ", "Listen by default: ": "Nur Ton als Standard: ",
"Proxy videos? ": "",
"Default speed: ": "Standardgeschwindigkeit: ", "Default speed: ": "Standardgeschwindigkeit: ",
"Preferred video quality: ": "Bevorzugte Videoqualität: ", "Preferred video quality: ": "Bevorzugte Videoqualität: ",
"Player volume: ": "Playerlautstärke: ", "Player volume: ": "Playerlautstärke: ",
@ -101,11 +102,8 @@
"Sign out": "Abmelden", "Sign out": "Abmelden",
"Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.",
"Source available here.": "Quellcode verfügbar hier.", "Source available here.": "Quellcode verfügbar hier.",
"Liberapay: ": "Liberapay: ",
"Patreon: ": "Patreon: ",
"BTC: ": "BTC: ",
"BCH: ": "BCH: ",
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
"View privacy policy.": "",
"Trending": "Trending", "Trending": "Trending",
"Watch video on Youtube": "Video auf YouTube ansehen", "Watch video on Youtube": "Video auf YouTube ansehen",
"Genre: ": "Genre: ", "Genre: ": "Genre: ",
@ -290,5 +288,8 @@
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "",
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "",
"Audio mode": "", "Audio mode": "",
"Video mode": "" "Video mode": "",
"Videos": "",
"Playlists": "",
"Current version: ": ""
} }

View File

@ -10,8 +10,9 @@
"newest": "newest", "newest": "newest",
"oldest": "oldest", "oldest": "oldest",
"popular": "popular", "popular": "popular",
"Preview page": "Preview page", "last": "last",
"Next page": "Next page", "Next page": "Next page",
"Previous page": "Previous page",
"Clear watch history?": "Clear watch history?", "Clear watch history?": "Clear watch history?",
"Yes": "Yes", "Yes": "Yes",
"No": "No", "No": "No",
@ -28,7 +29,6 @@
"Export data as JSON": "Export data as JSON", "Export data as JSON": "Export data as JSON",
"Delete account?": "Delete account?", "Delete account?": "Delete account?",
"History": "History", "History": "History",
"Previous page": "Previous page",
"An alternative front-end to YouTube": "An alternative front-end to YouTube", "An alternative front-end to YouTube": "An alternative front-end to YouTube",
"JavaScript license information": "JavaScript license information", "JavaScript license information": "JavaScript license information",
"source": "source", "source": "source",
@ -50,6 +50,7 @@
"Autoplay: ": "Autoplay: ", "Autoplay: ": "Autoplay: ",
"Autoplay next video: ": "Autoplay next video: ", "Autoplay next video: ": "Autoplay next video: ",
"Listen by default: ": "Listen by default: ", "Listen by default: ": "Listen by default: ",
"Proxy videos? ": "Proxy videos? ",
"Default speed: ": "Default speed: ", "Default speed: ": "Default speed: ",
"Preferred video quality: ": "Preferred video quality: ", "Preferred video quality: ": "Preferred video quality: ",
"Player volume: ": "Player volume: ", "Player volume: ": "Player volume: ",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.",
"Source available here.": "Source available here.", "Source available here.": "Source available here.",
"View JavaScript license information.": "View JavaScript license information.", "View JavaScript license information.": "View JavaScript license information.",
"View privacy policy.": "View privacy policy.",
"Trending": "Trending", "Trending": "Trending",
"Watch video on Youtube": "Watch video on Youtube", "Watch video on Youtube": "Watch video on Youtube",
"Genre: ": "Genre: ", "Genre: ": "Genre: ",
@ -284,5 +286,8 @@
"Youtube permalink of the comment": "Youtube permalink of the comment", "Youtube permalink of the comment": "Youtube permalink of the comment",
"`x` marked it with a ❤": "`x` marked it with a ❤", "`x` marked it with a ❤": "`x` marked it with a ❤",
"Audio mode": "Audio mode", "Audio mode": "Audio mode",
"Video mode": "Video mode" "Video mode": "Video mode",
"Videos": "Videos",
"Playlists": "Playlists",
"Current version: ": "Current version: "
} }

View File

@ -10,8 +10,9 @@
"newest": "berrienak", "newest": "berrienak",
"oldest": "zaharrenak", "oldest": "zaharrenak",
"popular": "ospetsuenak", "popular": "ospetsuenak",
"Preview page": "Aurrebista orria", "last": "",
"Next page": "Hurrengo orria", "Next page": "Hurrengo orria",
"Previous page": "Aurreko orria",
"Clear watch history?": "Garbitu ikusitakoen historia?", "Clear watch history?": "Garbitu ikusitakoen historia?",
"Yes": "Bai", "Yes": "Bai",
"No": "Ez", "No": "Ez",
@ -28,7 +29,6 @@
"Export data as JSON": "Datuak JSON bezala esportatu", "Export data as JSON": "Datuak JSON bezala esportatu",
"Delete account?": "Kontua ezabatu?", "Delete account?": "Kontua ezabatu?",
"History": "Historia", "History": "Historia",
"Previous page": "Aurreko orria",
"An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat", "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
"JavaScript license information": "JavaScript lizentzia informazioa", "JavaScript license information": "JavaScript lizentzia informazioa",
"source": "iturburua", "source": "iturburua",
@ -50,6 +50,7 @@
"Autoplay: ": "", "Autoplay: ": "",
"Autoplay next video: ": "", "Autoplay next video: ": "",
"Listen by default: ": "", "Listen by default: ": "",
"Proxy videos? ": "",
"Default speed: ": "", "Default speed: ": "",
"Preferred video quality: ": "", "Preferred video quality: ": "",
"Player volume: ": "", "Player volume: ": "",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "", "Released under the AGPLv3 by Omar Roth.": "",
"Source available here.": "", "Source available here.": "",
"View JavaScript license information.": "", "View JavaScript license information.": "",
"View privacy policy.": "",
"Trending": "", "Trending": "",
"Watch video on Youtube": "", "Watch video on Youtube": "",
"Genre: ": "", "Genre: ": "",
@ -284,5 +286,8 @@
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "",
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "",
"Audio mode": "", "Audio mode": "",
"Video mode": "" "Video mode": "",
"Videos": "",
"Playlists": "",
"Current version: ": ""
} }

View File

@ -10,7 +10,9 @@
"newest": "Date d'ajout (la plus récente)", "newest": "Date d'ajout (la plus récente)",
"oldest": "Date d'ajout (la plus ancienne)", "oldest": "Date d'ajout (la plus ancienne)",
"popular": "Les plus populaires", "popular": "Les plus populaires",
"last": "Dernières",
"Next page": "Page suivante", "Next page": "Page suivante",
"Previous page": "Page précédente",
"Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?", "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
"Yes": "Oui", "Yes": "Oui",
"No": "Non", "No": "Non",
@ -25,23 +27,22 @@
"Export subscriptions as OPML": "Exporter les abonnements en OPML", "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 subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON", "Export data as JSON": "Exporter les données au format JSON",
"Delete account?": "Supprimer votre compte ?", "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
"History": "Historique", "History": "Historique",
"Previous page": "Page précédente",
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube", "An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
"JavaScript license information": "Informations sur les licences JavaScript", "JavaScript license information": "Informations sur les licences JavaScript",
"source": "source", "source": "source",
"Login": "Connexion", "Login": "Connexion",
"Login/Register": "Connexion/S'inscrire", "Login/Register": "Connexion/S'inscrire",
"Login to Google": "Se connecter à Google", "Login to Google": "Se connecter à Google",
"User ID:": "ID utilisateur :", "User ID:": "Identifiant utilisateur :",
"Password:": "Mot de passe :", "Password:": "Mot de passe :",
"Time (h:mm:ss):": "Heure (h:mm:ss) :", "Time (h:mm:ss):": "Heure (h:mm:ss) :",
"Text CAPTCHA": "CAPTCHA Texte", "Text CAPTCHA": "CAPTCHA Texte",
"Image CAPTCHA": "CAPTCHA Image", "Image CAPTCHA": "CAPTCHA Image",
"Sign In": "S'identifier", "Sign In": "S'identifier",
"Register": "S'inscrire", "Register": "S'inscrire",
"Email:": "Email :", "Email:": "E-mail :",
"Google verification code:": "Code de vérification Google :", "Google verification code:": "Code de vérification Google :",
"Preferences": "Préférences", "Preferences": "Préférences",
"Player preferences": "Préférences du Lecteur", "Player preferences": "Préférences du Lecteur",
@ -49,6 +50,7 @@
"Autoplay: ": "Lire Automatiquement : ", "Autoplay: ": "Lire Automatiquement : ",
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ", "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
"Listen by default: ": "Audio Uniquement par défaut : ", "Listen by default: ": "Audio Uniquement par défaut : ",
"Proxy videos? ": "Souhaitez vous charger les vidéos à travers un proxy ?",
"Default speed: ": "Vitesse par défaut : ", "Default speed: ": "Vitesse par défaut : ",
"Preferred video quality: ": "Qualité vidéo souhaitée : ", "Preferred video quality: ": "Qualité vidéo souhaitée : ",
"Player volume: ": "Volume du lecteur : ", "Player volume: ": "Volume du lecteur : ",
@ -56,7 +58,7 @@
"Default captions: ": "Sous-titres principal : ", "Default captions: ": "Sous-titres principal : ",
"Fallback captions: ": "Sous-titres secondaire : ", "Fallback captions: ": "Sous-titres secondaire : ",
"Show related videos? ": "Voir les vidéos liées à ce sujet ? ", "Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
"Visual preferences": "Préférences visuelles", "Visual preferences": "Préférences du site",
"Dark mode: ": "Mode Sombre : ", "Dark mode: ": "Mode Sombre : ",
"Thin mode: ": "Mode Simplifié : ", "Thin mode: ": "Mode Simplifié : ",
"Subscription preferences": "Préférences de la page d'abonnements", "Subscription preferences": "Préférences de la page d'abonnements",
@ -79,14 +81,14 @@
"Manage subscriptions": "Gérer les abonnements", "Manage subscriptions": "Gérer les abonnements",
"Watch history": "Historique de visionnage", "Watch history": "Historique de visionnage",
"Delete account": "Supprimer votre compte", "Delete account": "Supprimer votre compte",
"Administrator preferences": "", "Administrator preferences": "Préferences d'Administrateur",
"Default homepage: ": "", "Default homepage: ": "Page d'accueil par defaut :",
"Feed menu: ": "", "Feed menu: ": "Menu des Flux :",
"Top enabled? ": "", "Top enabled? ": "Top activé ?",
"CAPTCHA enabled? ": "", "CAPTCHA enabled? ": "CAPTCHA activé ?",
"Login enabled? ": "", "Login enabled? ": "Connexion activé ?",
"Registration enabled? ": "", "Registration enabled? ": "Inscription activé ?",
"Report statistics? ": "", "Report statistics? ": "Telemetrie activé ?",
"Save preferences": "Enregistrer les préférences", "Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement", "Subscription manager": "Gestionnaire d'abonnement",
"`x` subscriptions": "`x` abonnements", "`x` subscriptions": "`x` abonnements",
@ -99,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
"Source available here.": "Code Source.", "Source available here.": "Code Source.",
"View JavaScript license information.": "Voir les informations des licences JavaScript.", "View JavaScript license information.": "Voir les informations des licences JavaScript.",
"View privacy policy.": "Politique de confidentialité",
"Trending": "Tendances", "Trending": "Tendances",
"Watch video on Youtube": "Voir la vidéo sur Youtube", "Watch video on Youtube": "Voir la vidéo sur Youtube",
"Genre: ": "Genre : ", "Genre: ": "Genre : ",
@ -283,5 +286,8 @@
"Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire", "Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
"Audio mode": "Mode Audio", "Audio mode": "Mode Audio",
"Video mode": "Mode Vidéo" "Video mode": "Mode Vidéo",
"Videos": "Vidéos",
"Playlists": "Liste de lecture",
"Current version: ": "Version actuelle :"
} }

View File

@ -10,7 +10,9 @@
"newest": "Data di aggiunta (più recente)", "newest": "Data di aggiunta (più recente)",
"oldest": "Data di aggiunta (più vecchia)", "oldest": "Data di aggiunta (più vecchia)",
"popular": "Tendenze", "popular": "Tendenze",
"last": "",
"Next page": "Pagina successiva", "Next page": "Pagina successiva",
"Previous page": "Pagina precedente",
"Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?", "Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?",
"Yes": "Si", "Yes": "Si",
"No": "No", "No": "No",
@ -27,7 +29,6 @@
"Export data as JSON": "Esporta i dati in formato JSON", "Export data as JSON": "Esporta i dati in formato JSON",
"Delete account?": "Sei sicuro di voler cancellare l'account?", "Delete account?": "Sei sicuro di voler cancellare l'account?",
"History": "Cronologia", "History": "Cronologia",
"Previous page": "Pagina precedente",
"An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube", "An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube",
"JavaScript license information": "Info licenze JavaScript", "JavaScript license information": "Info licenze JavaScript",
"source": "sorgente", "source": "sorgente",
@ -49,6 +50,7 @@
"Autoplay: ": "Riproduzione automatica: ", "Autoplay: ": "Riproduzione automatica: ",
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ", "Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
"Listen by default: ": "Modalità solo audio come predefinita: ", "Listen by default: ": "Modalità solo audio come predefinita: ",
"Proxy videos? ": "",
"Default speed: ": "Velocità di riproduzione predefinita: ", "Default speed: ": "Velocità di riproduzione predefinita: ",
"Preferred video quality: ": "Preferenza sulla qualità video: ", "Preferred video quality: ": "Preferenza sulla qualità video: ",
"Player volume: ": "Volume di riproduzione: ", "Player volume: ": "Volume di riproduzione: ",
@ -99,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.",
"Source available here.": "Codice sorgente.", "Source available here.": "Codice sorgente.",
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
"View privacy policy.": "",
"Trending": "Tendenze", "Trending": "Tendenze",
"Watch video on Youtube": "Guarda il video su YouTube", "Watch video on Youtube": "Guarda il video su YouTube",
"Genre: ": "Genere: ", "Genre: ": "Genere: ",
@ -283,5 +286,8 @@
"Youtube permalink of the comment": "Link permanente al commento di YouTube", "Youtube permalink of the comment": "Link permanente al commento di YouTube",
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio", "Audio mode": "Modalità audio",
"Video mode": "Modalità video" "Video mode": "Modalità video",
"Videos": "",
"Playlists": "",
"Current version: ": ""
} }

View File

@ -10,8 +10,9 @@
"newest": "nyeste", "newest": "nyeste",
"oldest": "eldste", "oldest": "eldste",
"popular": "populært", "popular": "populært",
"Preview page": "Forhåndsvis side", "last": "siste",
"Next page": "Neste side", "Next page": "Neste side",
"Previous page": "Forrige side",
"Clear watch history?": "Tøm visningshistorikk?", "Clear watch history?": "Tøm visningshistorikk?",
"Yes": "Ja", "Yes": "Ja",
"No": "Nei", "No": "Nei",
@ -28,7 +29,6 @@
"Export data as JSON": "Eksporter data som JSON", "Export data as JSON": "Eksporter data som JSON",
"Delete account?": "Slett konto?", "Delete account?": "Slett konto?",
"History": "Historikk", "History": "Historikk",
"Previous page": "Forrige side",
"An alternative front-end to YouTube": "En alternativ grenseflate for YouTube", "An alternative front-end to YouTube": "En alternativ grenseflate for YouTube",
"JavaScript license information": "JavaScript-lisensinformasjon", "JavaScript license information": "JavaScript-lisensinformasjon",
"source": "kilde", "source": "kilde",
@ -50,6 +50,7 @@
"Autoplay: ": "Autoavspilling: ", "Autoplay: ": "Autoavspilling: ",
"Autoplay next video: ": "Autospill neste video: ", "Autoplay next video: ": "Autospill neste video: ",
"Listen by default: ": "Lytt som forvalg: ", "Listen by default: ": "Lytt som forvalg: ",
"Proxy videos? ": "",
"Default speed: ": "Forvalgt hastighet: ", "Default speed: ": "Forvalgt hastighet: ",
"Preferred video quality: ": "Foretrukket videokvalitet: ", "Preferred video quality: ": "Foretrukket videokvalitet: ",
"Player volume: ": "Avspillerlydstyrke: ", "Player volume: ": "Avspillerlydstyrke: ",
@ -83,11 +84,11 @@
"Administrator preferences": "Administratorinnstillinger", "Administrator preferences": "Administratorinnstillinger",
"Default homepage: ": "Forvalgt hjemmeside: ", "Default homepage: ": "Forvalgt hjemmeside: ",
"Feed menu: ": "Flyt-meny: ", "Feed menu: ": "Flyt-meny: ",
"Top enabled? ": "", "Top enabled? ": "Topp påskrudd? ",
"CAPTCHA enabled? ": "CAPTCHA påskrudd? ", "CAPTCHA enabled? ": "CAPTCHA påskrudd? ",
"Login enabled? ": "Innlogging påskrudd? ", "Login enabled? ": "Innlogging påskrudd? ",
"Registration enabled? ": "Registrering påskrudd? ", "Registration enabled? ": "Registrering påskrudd? ",
"Report statistics? ": "", "Report statistics? ": "Innrapporter statistikk? ",
"Save preferences": "Lagre innstillinger", "Save preferences": "Lagre innstillinger",
"Subscription manager": "Abonnementsbehandler", "Subscription manager": "Abonnementsbehandler",
"`x` subscriptions": "`x` abonnementer", "`x` subscriptions": "`x` abonnementer",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.",
"Source available here.": "Kildekode tilgjengelig her.", "Source available here.": "Kildekode tilgjengelig her.",
"View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.",
"View privacy policy.": "",
"Trending": "Trendsettende", "Trending": "Trendsettende",
"Watch video on Youtube": "Vis video på YouTube", "Watch video on Youtube": "Vis video på YouTube",
"Genre: ": "Sjanger: ", "Genre: ": "Sjanger: ",
@ -284,5 +286,8 @@
"Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet", "Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
"`x` marked it with a ❤": "`x` levnet et ❤", "`x` marked it with a ❤": "`x` levnet et ❤",
"Audio mode": "Lydmodus", "Audio mode": "Lydmodus",
"Video mode": "Video-modus" "Video mode": "Video-modus",
"Videos": "Videoer",
"Playlists": "Spillelister",
"Current version: ": "Nåværende versjon: "
} }

View File

@ -10,8 +10,9 @@
"newest": "nieuwste", "newest": "nieuwste",
"oldest": "oudste", "oldest": "oudste",
"popular": "populair", "popular": "populair",
"Preview page": "Pagina voorvertonen", "last": "",
"Next page": "Volgende pagina", "Next page": "Volgende pagina",
"Previous page": "Vorige pagina",
"Clear watch history?": "Kijk geschiedenis wissen?", "Clear watch history?": "Kijk geschiedenis wissen?",
"Yes": "Ja", "Yes": "Ja",
"No": "Nee", "No": "Nee",
@ -28,7 +29,6 @@
"Export data as JSON": "Exporteer gegevens als JSON", "Export data as JSON": "Exporteer gegevens als JSON",
"Delete account?": "Verwijder account?", "Delete account?": "Verwijder account?",
"History": "Geschiedenis", "History": "Geschiedenis",
"Previous page": "Vorige pagina",
"An alternative front-end to YouTube": "Een alternatieve front-end voor YouTube", "An alternative front-end to YouTube": "Een alternatieve front-end voor YouTube",
"JavaScript license information": "JavaScript licentie informatie", "JavaScript license information": "JavaScript licentie informatie",
"source": "bron", "source": "bron",
@ -50,6 +50,7 @@
"Autoplay: ": "Automatisch afspelen: ", "Autoplay: ": "Automatisch afspelen: ",
"Autoplay next video: ": "Automatisch volgende video afspelen: ", "Autoplay next video: ": "Automatisch volgende video afspelen: ",
"Listen by default: ": "Standaard luisteren: ", "Listen by default: ": "Standaard luisteren: ",
"Proxy videos? ": "",
"Default speed: ": "Standaard snelheid: ", "Default speed: ": "Standaard snelheid: ",
"Preferred video quality: ": "Video kwaliteit voorkeur: ", "Preferred video quality: ": "Video kwaliteit voorkeur: ",
"Player volume: ": "Afspeler volume: ", "Player volume: ": "Afspeler volume: ",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder AGPLv3 door Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder AGPLv3 door Omar Roth.",
"Source available here.": "Bron beschikbaar hier.", "Source available here.": "Bron beschikbaar hier.",
"View JavaScript license information.": "Bekijk JavaScript licentie informatie.", "View JavaScript license information.": "Bekijk JavaScript licentie informatie.",
"View privacy policy.": "",
"Trending": "Trending", "Trending": "Trending",
"Watch video on Youtube": "Bekijk video op Youtube", "Watch video on Youtube": "Bekijk video op Youtube",
"Genre: ": "Genre: ", "Genre: ": "Genre: ",
@ -284,5 +286,8 @@
"Youtube permalink of the comment": "", "Youtube permalink of the comment": "",
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "",
"Audio mode": "", "Audio mode": "",
"Video mode": "" "Video mode": "",
"Videos": "",
"Playlists": "",
"Current version: ": ""
} }

View File

@ -10,8 +10,9 @@
"newest": "najnowsze", "newest": "najnowsze",
"oldest": "najstarsze", "oldest": "najstarsze",
"popular": "popularne", "popular": "popularne",
"Preview page": "Podgląd strony", "last": "",
"Next page": "Następna strona", "Next page": "Następna strona",
"Previous page": "Poprzednia strona",
"Clear watch history?": "Wyczyścić historię?", "Clear watch history?": "Wyczyścić historię?",
"Yes": "Tak", "Yes": "Tak",
"No": "Nie", "No": "Nie",
@ -28,7 +29,6 @@
"Export data as JSON": "Eksportuj dane jako JSON", "Export data as JSON": "Eksportuj dane jako JSON",
"Delete account?": "Usunąć konto?", "Delete account?": "Usunąć konto?",
"History": "Historia", "History": "Historia",
"Previous page": "Poprzednia strona",
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube", "An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
"JavaScript license information": "Informacja o licencji JavaScript", "JavaScript license information": "Informacja o licencji JavaScript",
"source": "źródło", "source": "źródło",
@ -50,12 +50,13 @@
"Autoplay: ": "Autoodtwarzanie: ", "Autoplay: ": "Autoodtwarzanie: ",
"Autoplay next video: ": "Odtwórz następny film: ", "Autoplay next video: ": "Odtwórz następny film: ",
"Listen by default: ": "Tryb dźwiękowy: ", "Listen by default: ": "Tryb dźwiękowy: ",
"Proxy videos? ": "",
"Default speed: ": "Domyślna prędkość: ", "Default speed: ": "Domyślna prędkość: ",
"Preferred video quality: ": "Preferowana jakość filmów: ", "Preferred video quality: ": "Preferowana jakość filmów: ",
"Player volume: ": "Głośność odtwarzacza: ", "Player volume: ": "Głośność odtwarzacza: ",
"Default comments: ": "Domyślne komentarze: ", "Default comments: ": "Domyślne komentarze: ",
"Default captions: ": "Domyślne napisy: ", "Default captions: ": "Domyślne napisy: ",
"Fallback captions: ": "Rezerwowe napisy: ", "Fallback captions: ": "Zastępcze napisy: ",
"Show related videos? ": "Pokaż powiązane filmy? ", "Show related videos? ": "Pokaż powiązane filmy? ",
"Visual preferences": "Preferencje Wizualne", "Visual preferences": "Preferencje Wizualne",
"Dark mode: ": "Ciemny motyw: ", "Dark mode: ": "Ciemny motyw: ",
@ -63,13 +64,13 @@
"Subscription preferences": "Preferencje subskrybcji", "Subscription preferences": "Preferencje subskrybcji",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
"Sort videos by: ": "Sortuj filmy po: ", "Sort videos by: ": "Sortuj filmy: ",
"published": "czasie publikacji", "published": "po czasie publikacji",
"published - reverse": "czasie publikacji od najstarszych", "published - reverse": "po czasie publikacji od najstarszych",
"alphabetically": "alfabetycznie", "alphabetically": "alfabetycznie",
"alphabetically - reverse": "alfabetycznie od tyłu", "alphabetically - reverse": "alfabetycznie od tyłu",
"channel name": "nazwie kanału", "channel name": "po nazwie kanału",
"channel name - reverse": "nazwie kanału od tyłu", "channel name - reverse": "po nazwie kanału od tyłu",
"Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ", "Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ",
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ", "Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ", "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
@ -80,14 +81,14 @@
"Manage subscriptions": "Organizuj subskrybcje", "Manage subscriptions": "Organizuj subskrybcje",
"Watch history": "Historia", "Watch history": "Historia",
"Delete account": "Usuń konto", "Delete account": "Usuń konto",
"Administrator preferences": "", "Administrator preferences": "Preferencje administratora",
"Default homepage: ": "", "Default homepage: ": "Domyślna strona główna: ",
"Feed menu: ": "", "Feed menu: ": "",
"Top enabled? ": "", "Top enabled? ": "",
"CAPTCHA enabled? ": "", "CAPTCHA enabled? ": "CAPTCHA aktywna? ",
"Login enabled? ": "", "Login enabled? ": "Logowanie włączone? ",
"Registration enabled? ": "", "Registration enabled? ": "Rejestracja włączona? ",
"Report statistics? ": "", "Report statistics? ": "Raportować statystyki? ",
"Save preferences": "Zapisz preferencje", "Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji", "Subscription manager": "Manager subskrybcji",
"`x` subscriptions": "`x` subskrybcji", "`x` subscriptions": "`x` subskrybcji",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.",
"Source available here.": "Kod źródłowy dostępny tutaj.", "Source available here.": "Kod źródłowy dostępny tutaj.",
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "",
"Trending": "Na czasie", "Trending": "Na czasie",
"Watch video on Youtube": "Zobacz film na YouTube", "Watch video on Youtube": "Zobacz film na YouTube",
"Genre: ": "Gatunek: ", "Genre: ": "Gatunek: ",
@ -155,20 +157,20 @@
"Token is expired, please try again": "Token wygasł, spróbuj ponownie", "Token is expired, please try again": "Token wygasł, spróbuj ponownie",
"English": "angielski", "English": "angielski",
"English (auto-generated)": "angielski (automatycznie generowane)", "English (auto-generated)": "angielski (automatycznie generowane)",
"Afrikaans": "", "Afrikaans": "afrykanerski",
"Albanian": "albański", "Albanian": "albański",
"Amharic": "", "Amharic": "amharski",
"Arabic": "arabski", "Arabic": "arabski",
"Armenian": "", "Armenian": "armeński",
"Azerbaijani": "", "Azerbaijani": "azerski",
"Bangla": "", "Bangla": "bengalski",
"Basque": "", "Basque": "baskijski",
"Belarusian": "białoruski", "Belarusian": "białoruski",
"Bosnian": "bośniacki", "Bosnian": "bośniacki",
"Bulgarian": "bułgarski", "Bulgarian": "bułgarski",
"Burmese": "birmański", "Burmese": "birmański",
"Catalan": "kataloński", "Catalan": "kataloński",
"Cebuano": "", "Cebuano": "cebuański",
"Chinese (Simplified)": "chiński (uproszczony)", "Chinese (Simplified)": "chiński (uproszczony)",
"Chinese (Traditional)": "chiński (tradycyjny)", "Chinese (Traditional)": "chiński (tradycyjny)",
"Corsican": "korsykański", "Corsican": "korsykański",
@ -185,28 +187,28 @@
"Georgian": "gruziński", "Georgian": "gruziński",
"German": "niemiecki", "German": "niemiecki",
"Greek": "grecki", "Greek": "grecki",
"Gujarati": "", "Gujarati": "gudźarati",
"Haitian Creole": "", "Haitian Creole": "kreolski haitański",
"Hausa": "", "Hausa": "hausa",
"Hawaiian": "hawajski", "Hawaiian": "hawajski",
"Hebrew": "hebrajski", "Hebrew": "hebrajski",
"Hindi": "hindi", "Hindi": "hindi",
"Hmong": "", "Hmong": "hmong",
"Hungarian": "węgierski", "Hungarian": "węgierski",
"Icelandic": "islandzki", "Icelandic": "islandzki",
"Igbo": "", "Igbo": "ibo",
"Indonesian": "indonezyjski", "Indonesian": "indonezyjski",
"Irish": "irlandzki", "Irish": "irlandzki",
"Italian": "włoski", "Italian": "włoski",
"Japanese": "japoński", "Japanese": "japoński",
"Javanese": "jawajski", "Javanese": "jawajski",
"Kannada": "", "Kannada": "kannada",
"Kazakh": "kazachski", "Kazakh": "kazachski",
"Khmer": "", "Khmer": "khmerski",
"Korean": "koreański", "Korean": "koreański",
"Kurdish": "kurdyjski", "Kurdish": "kurdyjski",
"Kyrgyz": "kirgiski", "Kyrgyz": "kirgiski",
"Lao": "", "Lao": "laotański",
"Latin": "łaciński", "Latin": "łaciński",
"Latvian": "łotewski", "Latvian": "łotewski",
"Lithuanian": "litewski", "Lithuanian": "litewski",
@ -214,51 +216,51 @@
"Macedonian": "macedoński", "Macedonian": "macedoński",
"Malagasy": "malgaski", "Malagasy": "malgaski",
"Malay": "malajski", "Malay": "malajski",
"Malayalam": "", "Malayalam": "malajalam",
"Maltese": "maltański", "Maltese": "maltański",
"Maori": "", "Maori": "maoryski",
"Marathi": "", "Marathi": "marathi",
"Mongolian": "mongolski", "Mongolian": "mongolski",
"Nepali": "nepalski", "Nepali": "nepalski",
"Norwegian": "norweski", "Norwegian": "norweski",
"Nyanja": "", "Nyanja": "njandża",
"Pashto": "", "Pashto": "paszto",
"Persian": "perski", "Persian": "perski",
"Polish": "polski", "Polish": "polski",
"Portuguese": "portugalski", "Portuguese": "portugalski",
"Punjabi": "", "Punjabi": "pendżabski",
"Romanian": "rumuński", "Romanian": "rumuński",
"Russian": "rosyjski", "Russian": "rosyjski",
"Samoan": "", "Samoan": "samoański",
"Scottish Gaelic": "", "Scottish Gaelic": "gaelicki szkocki",
"Serbian": "serbski", "Serbian": "serbski",
"Shona": "", "Shona": "shona",
"Sindhi": "", "Sindhi": "sindhi",
"Sinhala": "", "Sinhala": "syngaleski",
"Slovak": "słowacki", "Slovak": "słowacki",
"Slovenian": "słoweński", "Slovenian": "słoweński",
"Somali": "somalijski", "Somali": "somalijski",
"Southern Sotho": "", "Southern Sotho": "sotho południowy",
"Spanish": "hiszpański", "Spanish": "hiszpański",
"Spanish (Latin America)": "hiszpański (ameryka łacińska)", "Spanish (Latin America)": "hiszpański (ameryka łacińska)",
"Sundanese": "", "Sundanese": "sundajski",
"Swahili": "", "Swahili": "suahili",
"Swedish": "szwedzki", "Swedish": "szwedzki",
"Tajik": "", "Tajik": "tadżycki",
"Tamil": "", "Tamil": "tamilski",
"Telugu": "", "Telugu": "telugu",
"Thai": "tajski", "Thai": "tajski",
"Turkish": "turecki", "Turkish": "turecki",
"Ukrainian": "ukraiński", "Ukrainian": "ukraiński",
"Urdu": "", "Urdu": "urdu",
"Uzbek": "uzbecki", "Uzbek": "uzbecki",
"Vietnamese": "wietnamski", "Vietnamese": "wietnamski",
"Welsh": "walijski", "Welsh": "walijski",
"Western Frisian": "", "Western Frisian": "zachodniofryzyjski",
"Xhosa": "", "Xhosa": "xhosa",
"Yiddish": "", "Yiddish": "jidysz",
"Yoruba": "", "Yoruba": "joruba",
"Zulu": "", "Zulu": "zuluski",
"`x` years": "`x` lat", "`x` years": "`x` lat",
"`x` months": "`x` miesięcy", "`x` months": "`x` miesięcy",
"`x` weeks": "`x` tygodni", "`x` weeks": "`x` tygodni",
@ -272,7 +274,7 @@
"About": "Informacje", "About": "Informacje",
"Rating: ": "Ocena: ", "Rating: ": "Ocena: ",
"Language: ": "Język: ", "Language: ": "Język: ",
"Default": "", "Default": "Domyślnie",
"Music": "Muzyka", "Music": "Muzyka",
"Gaming": "Gry", "Gaming": "Gry",
"News": "Wiadomości", "News": "Wiadomości",
@ -282,7 +284,10 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "(edytowany)", "(edited)": "(edytowany)",
"Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube", "Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube",
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "'x' oznaczonych ❤",
"Audio mode": "Tryb audio", "Audio mode": "Tryb audio",
"Video mode": "Tryb wideo" "Video mode": "Tryb wideo",
"Videos": "Filmy",
"Playlists": "Playlisty",
"Current version: ": "Aktualna wersja: "
} }

View File

@ -1,294 +1,295 @@
{ {
"`x` subscribers": "`x` подписчиков", "`x` subscribers": "`x` подписчиков",
"`x` videos": "`x` видео", "`x` videos": "`x` видео",
"LIVE": "ПРЯМОЙ ЭФИР", "LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад", "Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться", "Unsubscribe": "Отписаться",
"Subscribe": "Подписаться", "Subscribe": "Подписаться",
"Login to subscribe to `x`": "Войти, чтобы подписаться на `x`", "Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
"View channel on YouTube": "Канал на YouTube", "View channel on YouTube": "Канал на YouTube",
"newest": "новые", "newest": "новые",
"oldest": "старые", "oldest": "старые",
"popular": "популярные", "popular": "популярные",
"Preview page": "Предварительный просмотр", "last": "недавно обновленные",
"Next page": "Следующая страница", "Next page": "Следующая страница",
"Clear watch history?": "Очистить историю просмотров?", "Previous page": "Предыдущая страница",
"Yes": "Да", "Clear watch history?": "Очистить историю просмотров?",
"No": "Нет", "Yes": "Да",
"Import and Export Data": "Импорт и экспорт данных", "No": "Нет",
"Import": "Импорт", "Import and Export Data": "Импорт и экспорт данных",
"Import Invidious data": "Импортировать данные Invidious", "Import": "Импорт",
"Import YouTube subscriptions": "Импортировать YouTube подписки", "Import Invidious data": "Импортировать данные Invidious",
"Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)", "Import YouTube subscriptions": "Импортировать YouTube подписки",
"Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)", "Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
"Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)", "Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
"Export": "Экспорт", "Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
"Export subscriptions as OPML": "Экспортировать подписки в OPML", "Export": "Экспорт",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)", "Export subscriptions as OPML": "Экспортировать подписки в OPML",
"Export data as JSON": "Экспортировать данные в JSON", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
"Delete account?": "Удалить аккаунт?", "Export data as JSON": "Экспортировать данные в JSON",
"History": "История", "Delete account?": "Удалить аккаунт?",
"Previous page": "Предыдущая страница", "History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube", "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
"JavaScript license information": "Лицензии JavaScript", "JavaScript license information": "Лицензии JavaScript",
"source": "источник", "source": "источник",
"Login": "Войти", "Login": "Войти",
"Login/Register": "Войти/Регистрация", "Login/Register": "Войти/Регистрация",
"Login to Google": "Войти через Google", "Login to Google": "Войти через Google",
"User ID:": "ID пользователя:", "User ID:": "ID пользователя:",
"Password:": "Пароль:", "Password:": "Пароль:",
"Time (h:mm:ss):": "Время (ч:мм:сс):", "Time (h:mm:ss):": "Время (ч:мм:сс):",
"Text CAPTCHA": "Текст капчи", "Text CAPTCHA": "Текст капчи",
"Image CAPTCHA": "Изображение капчи", "Image CAPTCHA": "Изображение капчи",
"Sign In": "Войти", "Sign In": "Войти",
"Register": "Регистрация", "Register": "Регистрация",
"Email:": "Эл. почта:", "Email:": "Эл. почта:",
"Google verification code:": "Код подтверждения Google:", "Google verification code:": "Код подтверждения Google:",
"Preferences": "Настройки", "Preferences": "Настройки",
"Player preferences": "Настройки проигрывателя", "Player preferences": "Настройки проигрывателя",
"Always loop: ": "Всегда повторять: ", "Always loop: ": "Всегда повторять: ",
"Autoplay: ": "Автовоспроизведение: ", "Autoplay: ": "Автовоспроизведение: ",
"Autoplay next video: ": "Автовоспроизведение следующего видео: ", "Autoplay next video: ": "Автовоспроизведение следующего видео: ",
"Listen by default: ": "Режим \"только аудио\" по-умолчанию: ", "Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
"Default speed: ": "Скорость по-умолчанию: ", "Proxy videos? ": "Проксировать видео? ",
"Preferred video quality: ": "Предпочтительное качество видео: ", "Default speed: ": "Скорость по-умолчанию: ",
"Player volume: ": "Громкость воспроизведения: ", "Preferred video quality: ": "Предпочтительное качество видео: ",
"Default comments: ": "Источник комментариев: ", "Player volume: ": "Громкость воспроизведения: ",
"youtube": "YouTube", "Default comments: ": "Источник комментариев: ",
"reddit": "Reddit", "youtube": "YouTube",
"Default captions: ": "Субтитры по-умолчанию: ", "reddit": "Reddit",
"Fallback captions: ": "Резервные субтитры: ", "Default captions: ": "Субтитры по-умолчанию: ",
"Show related videos? ": "Показывать похожие видео? ", "Fallback captions: ": "Резервные субтитры: ",
"Visual preferences": "Визуальные настройки", "Show related videos? ": "Показывать похожие видео? ",
"Dark mode: ": "Темная тема: ", "Visual preferences": "Визуальные настройки",
"Thin mode: ": "Облегченный режим: ", "Dark mode: ": "Темная тема: ",
"Subscription preferences": "Настройки подписок", "Thin mode: ": "Облегченный режим: ",
"Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ", "Subscription preferences": "Настройки подписок",
"Number of videos shown in feed: ": "Число видео в ленте: ", "Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
"Sort videos by: ": "Сортировать видео по: ", "Number of videos shown in feed: ": "Число видео в ленте: ",
"published": "дате публикации", "Sort videos by: ": "Сортировать видео по: ",
"published - reverse": "дате - обратный порядок", "published": "дате публикации",
"alphabetically": "алфавиту", "published - reverse": "дате - обратный порядок",
"alphabetically - reverse": "алфавиту - обратный порядок", "alphabetically": "алфавиту",
"channel name": "имени канала", "alphabetically - reverse": "алфавиту - обратный порядок",
"channel name - reverse": "имени канала - обратный порядок", "channel name": "имени канала",
"Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ", "channel name - reverse": "имени канала - обратный порядок",
"Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ", "Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
"Only show unwatched: ": "Отображать только непросмотренные видео: ", "Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
"Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ", "Only show unwatched: ": "Отображать только непросмотренные видео: ",
"Data preferences": "Настройки данных", "Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
"Clear watch history": "Очистить историю просмотра", "Data preferences": "Настройки данных",
"Import/Export data": "Импорт/Экспорт данных", "Clear watch history": "Очистить историю просмотра",
"Manage subscriptions": "Управление подписками", "Import/Export data": "Импорт/Экспорт данных",
"Watch history": "История просмотров", "Manage subscriptions": "Управление подписками",
"Delete account": "Удалить аккаунт", "Watch history": "История просмотров",
"Administrator preferences": "", "Delete account": "Удалить аккаунт",
"Default homepage: ": "", "Administrator preferences": "Настройки администратора",
"Feed menu: ": "", "Default homepage: ": "Главная страница по умолчанию: ",
"Top enabled? ": "", "Feed menu: ": "Меню ленты: ",
"CAPTCHA enabled? ": "", "Top enabled? ": "Включить ТОП? ",
"Login enabled? ": "", "CAPTCHA enabled? ": "Включить капчу? ",
"Registration enabled? ": "", "Login enabled? ": "Включить логин? ",
"Report statistics? ": "", "Registration enabled? ": "Включить регистрацию? ",
"Save preferences": "Сохранить настройки", "Report statistics? ": "Отображать статистику? ",
"Subscription manager": "Менеджер подписок", "Save preferences": "Сохранить настройки",
"`x` subscriptions": "`x` подписок", "Subscription manager": "Менеджер подписок",
"Import/Export": "Импорт/Экспорт", "`x` subscriptions": "`x` подписок",
"unsubscribe": "отписаться", "Import/Export": "Импорт/Экспорт",
"Subscriptions": "Подписки", "unsubscribe": "отписаться",
"`x` unseen notifications": "`x` новых оповещений", "Subscriptions": "Подписки",
"search": "поиск", "`x` unseen notifications": "`x` новых оповещений",
"Sign out": "Выйти", "search": "поиск",
"Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.", "Sign out": "Выйти",
"Source available here.": "Исходный код доступен здесь.", "Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
"Liberapay: ": "Liberapay: ", "Source available here.": "Исходный код доступен здесь.",
"Patreon: ": "Patreon: ", "View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
"BTC: ": "BTC: ", "View privacy policy.": "См. политику конфиденциальности.",
"BCH: ": "BCH: ", "Trending": "В тренде",
"View JavaScript license information.": "Посмотреть лицензии JavaScript кода.", "Watch video on Youtube": "Смотреть на YouTube",
"Trending": "В тренде", "Genre: ": "Жанр: ",
"Watch video on Youtube": "Смотреть на YouTube", "License: ": "Лицензия: ",
"Genre: ": "Жанр: ", "Family friendly? ": "Семейный просмотр: ",
"License: ": "Лицензия: ", "Wilson score: ": "Рейтинг Вильсона: ",
"Family friendly? ": "Семейный просмотр: ", "Engagement: ": "Вовлеченность: ",
"Wilson score: ": "Рейтинг Вильсона: ", "Whitelisted regions: ": "Доступно для: ",
"Engagement: ": "Вовлеченность: ", "Blacklisted regions: ": "Недоступно для: ",
"Whitelisted regions: ": "Доступно для: ", "Shared `x`": "Опубликовано `x`",
"Blacklisted regions: ": "Недоступно для: ", "Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).",
"Shared `x`": "Опубликовано `x`", "View YouTube comments": "Смотреть комментарии с YouTube",
"Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).", "View more comments on Reddit": "Больше комментариев на Reddit",
"View YouTube comments": "Смотреть комментарии с YouTube", "View `x` comments": "Показать `x` комментариев",
"View more comments on Reddit": "Больше комментариев на Reddit", "View Reddit comments": "Смотреть комментарии с Reddit",
"View `x` comments": "Показать `x` комментариев", "Hide replies": "Скрыть ответы",
"View Reddit comments": "Смотреть комментарии с Reddit", "Show replies": "Показать ответы",
"Hide replies": "Скрыть ответы", "Incorrect password": "Неправильный пароль",
"Show replies": "Показать ответы", "Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
"Incorrect password": "Неправильный пароль", "Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
"Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов", "Invalid TFA code": "Неправильный TFA код",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.", "Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
"Invalid TFA code": "Неправильный TFA код", "Invalid answer": "Неверный ответ",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.", "Invalid CAPTCHA": "Неверная капча",
"Invalid answer": "Неверный ответ", "CAPTCHA is a required field": "Необходимо ввести капчу",
"Invalid CAPTCHA": "Неверная капча", "User ID is a required field": "Необходимо ввести идентификатор пользователя",
"CAPTCHA is a required field": "Необходимо ввести капчу", "Password is a required field": "Необходимо ввести пароль",
"User ID is a required field": "Необходимо ввести идентификатор пользователя", "Invalid username or password": "Недопустимый пароль или имя пользователя",
"Password is a required field": "Необходимо ввести пароль", "Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
"Invalid username or password": "Недопустимый пароль или имя пользователя", "Password cannot be empty": "Пароль не может быть пустым",
"Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google", "Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
"Password cannot be empty": "Пароль не может быть пустым", "Please sign in": "Пожалуйста, войдите",
"Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов", "Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
"Please sign in": "Пожалуйста, войдите", "channel:`x`": "канал: `x`",
"Invidious Private Feed for `x`": "Приватная лента Invidious для `x`", "Deleted or invalid channel": "Канал удален или не найден",
"channel:`x`": "канал: `x`", "This channel does not exist.": "Такой канал не существует.",
"Deleted or invalid channel": "Канал удален или не найден", "Could not get channel info.": "Невозможно получить информацию о канале.",
"This channel does not exist.": "Такой канал не существует.", "Could not fetch comments": "Невозможно получить комментарии",
"Could not get channel info.": "Невозможно получить информацию о канале.", "View `x` replies": "Показать `x` ответов",
"Could not fetch comments": "Невозможно получить комментарии", "`x` ago": "`x` назад",
"View `x` replies": "Показать `x` ответов", "Load more": "Загрузить больше",
"`x` ago": "`x` назад", "`x` points": "`x` очков",
"Load more": "Загрузить больше", "Could not create mix.": "Невозможно создать \"микс\".",
"`x` points": "`x` очков", "Playlist is empty": "Плейлист пуст",
"Could not create mix.": "Невозможно создать \"микс\".", "Invalid playlist.": "Некорректный плейлист.",
"Playlist is empty": "Плейлист пуст", "Playlist does not exist.": "Плейлист не существует.",
"Invalid playlist.": "Некорректный плейлист.", "Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
"Playlist does not exist.": "Плейлист не существует.", "Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
"Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".", "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
"Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"", "Invalid challenge": "Неправильный ответ в \"challenge\"",
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"", "Invalid token": "Неправильный токен",
"Invalid challenge": "Неправильный ответ в \"challenge\"", "Invalid user": "Недопустимое имя пользователя",
"Invalid token": "Неправильный токен", "Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
"Invalid user": "Недопустимое имя пользователя", "English": "Английский",
"Token is expired, please try again": "Срок действия токена истек, попробуйте позже", "English (auto-generated)": "Английский (созданы автоматически)",
"English": "Английский", "Afrikaans": "Африкаанс",
"English (auto-generated)": "Английский (созданы автоматически)", "Albanian": "Албанский",
"Afrikaans": "Африкаанс", "Amharic": "Амхарский",
"Albanian": "Албанский", "Arabic": "Арабский",
"Amharic": "Амхарский", "Armenian": "Армянский",
"Arabic": "Арабский", "Azerbaijani": "Азербайджанский",
"Armenian": "Армянский", "Bangla": "Бенгальский",
"Azerbaijani": "Азербайджанский", "Basque": "Баскский",
"Bangla": "Бенгальский", "Belarusian": "Белорусский",
"Basque": "Баскский", "Bosnian": "Боснийский",
"Belarusian": "Белорусский", "Bulgarian": "Болгарский",
"Bosnian": "Боснийский", "Burmese": "Бирманский",
"Bulgarian": "Болгарский", "Catalan": "Каталонский",
"Burmese": "Бирманский", "Cebuano": "Себуанский",
"Catalan": "Каталонский", "Chinese (Simplified)": "Китайский (упрощенный)",
"Cebuano": "Себуанский", "Chinese (Traditional)": "Китайский (традиционный)",
"Chinese (Simplified)": "Китайский (упрощенный)", "Corsican": "Корсиканский",
"Chinese (Traditional)": "Китайский (традиционный)", "Croatian": "Хорватский",
"Corsican": "Корсиканский", "Czech": "Чешский",
"Croatian": "Хорватский", "Danish": "Датский",
"Czech": "Чешский", "Dutch": "Нидерландский",
"Danish": "Датский", "Esperanto": "Эсперанто",
"Dutch": "Нидерландский", "Estonian": "Эстонский",
"Esperanto": "Эсперанто", "Filipino": "Филиппинский",
"Estonian": "Эстонский", "Finnish": "Финский",
"Filipino": "Филиппинский", "French": "Французский",
"Finnish": "Финский", "Galician": "Галисийский",
"French": "Французский", "Georgian": "Грузинский",
"Galician": "Галисийский", "German": "Немецкий",
"Georgian": "Грузинский", "Greek": "Греческий",
"German": "Немецкий", "Gujarati": "Гуджаратский",
"Greek": "Греческий", "Haitian Creole": "Гаит. креольский",
"Gujarati": "Гуджаратский", "Hausa": "Хауса",
"Haitian Creole": "Гаит. креольский", "Hawaiian": "Гавайский",
"Hausa": "Хауса", "Hebrew": "Иврит",
"Hawaiian": "Гавайский", "Hindi": "Хинди",
"Hebrew": "Иврит", "Hmong": "Хмонг (мяо)",
"Hindi": "Хинди", "Hungarian": "Венгерский",
"Hmong": "Хмонг (мяо)", "Icelandic": "Исландский",
"Hungarian": "Венгерский", "Igbo": "Игбо",
"Icelandic": "Исландский", "Indonesian": "Индонезийский",
"Igbo": "Игбо", "Irish": "Ирландский",
"Indonesian": "Индонезийский", "Italian": "Итальянский",
"Irish": "Ирландский", "Japanese": "Японский",
"Italian": "Итальянский", "Javanese": "Яванский",
"Japanese": "Японский", "Kannada": "Каннада",
"Javanese": "Яванский", "Kazakh": "Казахский",
"Kannada": "Каннада", "Khmer": "Кхмерский",
"Kazakh": "Казахский", "Korean": "Корейский",
"Khmer": "Кхмерский", "Kurdish": "Курдский",
"Korean": "Корейский", "Kyrgyz": "Киргизский",
"Kurdish": "Курдский", "Lao": "Лаосский",
"Kyrgyz": "Киргизский", "Latin": "Латинский",
"Lao": "Лаосский", "Latvian": "Латышский",
"Latin": "Латинский", "Lithuanian": "Литовский",
"Latvian": "Латышский", "Luxembourgish": "Люксембургский",
"Lithuanian": "Литовский", "Macedonian": "Македонский",
"Luxembourgish": "Люксембургский", "Malagasy": "Малагасийский",
"Macedonian": "Македонский", "Malay": "Малайский",
"Malagasy": "Малагасийский", "Malayalam": "Малаялам",
"Malay": "Малайский", "Maltese": "Мальтийский",
"Malayalam": "Малаялам", "Maori": "Маори",
"Maltese": "Мальтийский", "Marathi": "Маратхи",
"Maori": "Маори", "Mongolian": "Монгольская",
"Marathi": "Маратхи", "Nepali": "Непальский",
"Mongolian": "Монгольская", "Norwegian": "Норвежский",
"Nepali": "Непальский", "Nyanja": "Ньянджа",
"Norwegian": "Норвежский", "Pashto": "Пушту",
"Nyanja": "Ньянджа", "Persian": "Персидский",
"Pashto": "Пушту", "Polish": "Польский",
"Persian": "Персидский", "Portuguese": "Португальский",
"Polish": "Польский", "Punjabi": "Панджаби",
"Portuguese": "Португальский", "Romanian": "Румынский",
"Punjabi": "Панджаби", "Russian": "Русский",
"Romanian": "Румынский", "Samoan": "Самоанский",
"Russian": "Русский", "Scottish Gaelic": "Шотландский (гэльский)",
"Samoan": "Самоанский", "Serbian": "Сербский",
"Scottish Gaelic": "Шотландский (гэльский)", "Shona": "Шона",
"Serbian": "Сербский", "Sindhi": "Синдхи",
"Shona": "Шона", "Sinhala": "Сингальский",
"Sindhi": "Синдхи", "Slovak": "Словацкий",
"Sinhala": "Сингальский", "Slovenian": "Словенский",
"Slovak": "Словацкий", "Somali": "Сомалийский",
"Slovenian": "Словенский", "Southern Sotho": "Сесото (южный сото)",
"Somali": "Сомалийский", "Spanish": "Испанский",
"Southern Sotho": "Сесото (южный сото)", "Spanish (Latin America)": "Испанский (Латинская Америка)",
"Spanish": "Испанский", "Sundanese": "Сунданский",
"Spanish (Latin America)": "Испанский (Латинская Америка)", "Swahili": "Суахили",
"Sundanese": "Сунданский", "Swedish": "Шведский",
"Swahili": "Суахили", "Tajik": "Таджикский",
"Swedish": "Шведский", "Tamil": "Тамильский",
"Tajik": "Таджикский", "Telugu": "Телугу",
"Tamil": "Тамильский", "Thai": "Тайский",
"Telugu": "Телугу", "Turkish": "Турецкий",
"Thai": "Тайский", "Ukrainian": "Украинский",
"Turkish": "Турецкий", "Urdu": "Урду",
"Ukrainian": "Украинский", "Uzbek": "Узбекский",
"Urdu": "Урду", "Vietnamese": "Вьетнамский",
"Uzbek": "Узбекский", "Welsh": "Валлийский",
"Vietnamese": "Вьетнамский", "Western Frisian": "Западнофризский",
"Welsh": "Валлийский", "Xhosa": "Коса",
"Western Frisian": "Западнофризский", "Yiddish": "Идиш",
"Xhosa": "Коса", "Yoruba": "Йоруба",
"Yiddish": "Идиш", "Zulu": "Зулусский",
"Yoruba": "Йоруба", "`x` years": "`x` лет",
"Zulu": "Зулусский", "`x` months": "`x` месяцев",
"`x` years": "`x` лет", "`x` weeks": "`x` недель",
"`x` months": "`x` месяцев", "`x` days": "`x` дней",
"`x` weeks": "`x` недель", "`x` hours": "`x` часов",
"`x` days": "`x` дней", "`x` minutes": "`x` минут",
"`x` hours": "`x` часов", "`x` seconds": "`x` секунд",
"`x` minutes": "`x` минут", "Fallback comments: ": "Резервные комментарии: ",
"`x` seconds": "`x` секунд", "Popular": "Популярное",
"Fallback comments: ": "Резервные комментарии: ", "Top": "Топ",
"Popular": "Популярное", "About": "О сайте",
"Top": "Топ", "Rating: ": "Рейтинг: ",
"About": "О сайте", "Language: ": "Язык: ",
"Rating: ": "Рейтинг: ", "Default": "По-умолчанию",
"Language: ": "Язык: ", "Music": "Музыка",
"Default": "По-умолчанию", "Gaming": "Игры",
"Music": "Музыка", "News": "Новости",
"Gaming": "Игры", "Movies": "Фильмы",
"News": "Новости", "Download": "Скачать",
"Movies": "Фильмы", "Download as: ": "Скачать как: ",
"Download": "Скачать", "%A %B %-d, %Y": "%-d %B %Y, %A",
"Download as: ": "Скачать как: ", "(edited)": "(изменено)",
"%A %B %-d, %Y": "%-d %B %Y, %A", "Youtube permalink of the comment": "Прямая ссылка на YouTube",
"(edited)": "(изменено)", "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Youtube permalink of the comment": "Прямая ссылка на YouTube", "Audio mode": "Аудио режим",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "Video mode": "Видео режим",
"Audio mode": "Аудио режим", "Videos": "Видео",
"Video mode": "Видео режим" "Playlists": "Плейлисты",
"Current version: ": "Текущая версия: "
} }

View File

@ -1,8 +1,8 @@
name: invidious name: invidious
version: 0.14.1 version: 0.15.0
authors: authors:
- Omar Roth <omarroth@hotmail.com> - Omar Roth <omarroth@protonmail.com>
targets: targets:
invidious: invidious:

View File

@ -1,5 +1,5 @@
# "Invidious" (which is an alternative front-end to YouTube) # "Invidious" (which is an alternative front-end to YouTube)
# Copyright (C) 2018 Omar Roth # Copyright (C) 2019 Omar Roth
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published # it under the terms of the GNU Affero General Public License as published
@ -17,6 +17,7 @@
require "digest/md5" require "digest/md5"
require "file_utils" require "file_utils"
require "kemal" require "kemal"
require "markdown"
require "openssl/hmac" require "openssl/hmac"
require "option_parser" require "option_parser"
require "pg" require "pg"
@ -35,14 +36,6 @@ logger = Invidious::LogHandler.new
Kemal.config.extra_options do |parser| Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]" parser.banner = "Usage: invidious [arguments]"
parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{config.crawl_threads})") do |number|
begin
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: #{config.channel_threads})") do |number| parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number|
begin begin
config.channel_threads = number.to_i config.channel_threads = number.to_i
@ -59,14 +52,6 @@ Kemal.config.extra_options do |parser|
exit exit
end end
end end
parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{config.video_threads})") do |number|
begin
config.video_threads = number.to_i
rescue ex
puts "THREADS must be integer"
exit
end
end
parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output| parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output|
FileUtils.mkdir_p(File.dirname(output)) FileUtils.mkdir_p(File.dirname(output))
logger = Invidious::LogHandler.new(File.open(output, mode: "a")) logger = Invidious::LogHandler.new(File.open(output, mode: "a"))
@ -79,10 +64,10 @@ YT_URL = URI.parse("https://www.youtube.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
LOGIN_URL = URI.parse("https://accounts.google.com") LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@hotmail.com.json") TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@protonmail.com.json")
CURRENT_COMMIT = `git rev-list HEAD --max-count=1 --abbrev-commit`.strip CURRENT_BRANCH = {{ "#{`git branch | sed -n '/\* /s///p'`.strip}" }}
CURRENT_VERSION = `git describe --tags $(git rev-list --tags --max-count=1)`.strip CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
CURRENT_BRANCH = `git status | head -1`.strip CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }}
LOCALES = { LOCALES = {
"ar" => load_locale("ar"), "ar" => load_locale("ar"),
@ -110,8 +95,6 @@ spawn do
end end
end end
proxies = PROXY_LIST
before_all do |env| before_all do |env|
env.response.headers["X-XSS-Protection"] = "1; mode=block;" env.response.headers["X-XSS-Protection"] = "1; mode=block;"
env.response.headers["X-Content-Type-Options"] = "nosniff" env.response.headers["X-Content-Type-Options"] = "nosniff"
@ -126,24 +109,22 @@ end
get "/api/v1/stats" do |env| get "/api/v1/stats" do |env|
env.response.content_type = "application/json" env.response.content_type = "application/json"
if statistics["error"]?
halt env, status_code: 500, response: statistics.to_json
end
if !config.statistics_enabled if !config.statistics_enabled
error_message = {"error" => "Statistics are not enabled."}.to_json error_message = {"error" => "Statistics are not enabled."}.to_json
halt env, status_code: 400, response: error_message env.response.status_code = 400
next error_message
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" if statistics["error"]?
statistics.to_pretty_json env.response.status_code = 500
else next statistics.to_json
statistics.to_json
end end
statistics.to_json
end end
get "/api/v1/captions/:id" do |env| get "/api/v1/captions/:id" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -156,7 +137,8 @@ get "/api/v1/captions/:id" do |env|
rescue ex : VideoRedirect rescue ex : VideoRedirect
next env.redirect "/api/v1/captions/#{ex.message}" next env.redirect "/api/v1/captions/#{ex.message}"
rescue ex rescue ex
halt env, status_code: 500 env.response.status_code = 500
next
end end
captions = video.captions captions = video.captions
@ -182,11 +164,7 @@ get "/api/v1/captions/:id" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" next response
next JSON.parse(response).to_pretty_json
else
next response
end
end end
env.response.content_type = "text/vtt" env.response.content_type = "text/vtt"
@ -198,7 +176,8 @@ get "/api/v1/captions/:id" do |env|
end end
if caption.empty? if caption.empty?
halt env, status_code: 404 env.response.status_code = 404
next
else else
caption = caption[0] caption = caption[0]
end end
@ -248,7 +227,7 @@ get "/api/v1/captions/:id" do |env|
end end
get "/api/v1/comments/:id" do |env| get "/api/v1/comments/:id" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
region = env.params.query["region"]? region = env.params.query["region"]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -268,7 +247,8 @@ get "/api/v1/comments/:id" do |env|
comments = fetch_youtube_comments(id, continuation, proxies, format, locale, region) comments = fetch_youtube_comments(id, continuation, proxies, format, locale, region)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
next comments next comments
@ -286,18 +266,15 @@ get "/api/v1/comments/:id" do |env|
end end
if !reddit_thread || !comments if !reddit_thread || !comments
halt env, status_code: 404 env.response.status_code = 404
next
end end
if format == "json" if format == "json"
reddit_thread = JSON.parse(reddit_thread.to_json).as_h reddit_thread = JSON.parse(reddit_thread.to_json).as_h
reddit_thread["comments"] = JSON.parse(comments.to_json) reddit_thread["comments"] = JSON.parse(comments.to_json)
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" next reddit_thread.to_json
next reddit_thread.to_pretty_json
else
next reddit_thread.to_json
end
else else
response = { response = {
"title" => reddit_thread.title, "title" => reddit_thread.title,
@ -305,23 +282,20 @@ get "/api/v1/comments/:id" do |env|
"contentHtml" => content_html, "contentHtml" => content_html,
} }
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" next response.to_json
next response.to_pretty_json
else
next response.to_json
end
end end
end end
end end
get "/api/v1/insights/:id" do |env| get "/api/v1/insights/:id" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
id = env.params.url["id"] id = env.params.url["id"]
env.response.content_type = "application/json" env.response.content_type = "application/json"
error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json
halt env, status_code: 410, response: error_message env.response.status_code = 410
next error_message
client = make_client(YT_URL) client = make_client(YT_URL)
headers = HTTP::Headers.new headers = HTTP::Headers.new
@ -398,15 +372,11 @@ get "/api/v1/insights/:id" do |env|
"graphData" => graph_data, "graphData" => graph_data,
} }
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" next response.to_json
next response.to_pretty_json
else
next response.to_json
end
end end
get "/api/v1/videos/:id" do |env| get "/api/v1/videos/:id" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -419,7 +389,8 @@ get "/api/v1/videos/:id" do |env|
next env.redirect "/api/v1/videos/#{ex.message}" next env.redirect "/api/v1/videos/#{ex.message}"
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
fmt_stream = video.fmt_stream(decrypt_function) fmt_stream = video.fmt_stream(decrypt_function)
@ -432,7 +403,7 @@ get "/api/v1/videos/:id" do |env|
json.field "title", video.title json.field "title", video.title
json.field "videoId", video.id json.field "videoId", video.id
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
video.description, description = html_to_content(video.description) video.description, description = html_to_content(video.description)
@ -475,19 +446,18 @@ get "/api/v1/videos/:id" do |env|
json.field "subCountText", video.sub_count_text json.field "subCountText", video.sub_count_text
json.field "lengthSeconds", video.info["length_seconds"].to_i json.field "lengthSeconds", video.info["length_seconds"].to_i
if video.info["allow_ratings"]? json.field "allowRatings", video.allow_ratings
json.field "allowRatings", video.info["allow_ratings"] == "1"
else
json.field "allowRatings", false
end
json.field "rating", video.info["avg_rating"].to_f32 json.field "rating", video.info["avg_rating"].to_f32
json.field "isListed", video.is_listed
json.field "liveNow", video.live_now
json.field "isUpcoming", video.is_upcoming
if video.info["is_listed"]? if video.premiere_timestamp
json.field "isListed", video.info["is_listed"] == "1" json.field "premiereTimestamp", video.premiere_timestamp.not_nil!.to_unix
end end
if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]? if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) host_url = make_host_url(config, Kemal.config)
host_params = env.request.query_params host_params = env.request.query_params
host_params.delete_all("v") host_params.delete_all("v")
@ -595,7 +565,7 @@ get "/api/v1/videos/:id" do |env|
json.field "videoId", rv["id"] json.field "videoId", rv["id"]
json.field "title", rv["title"] json.field "title", rv["title"]
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, rv["id"]) generate_thumbnails(json, rv["id"], config, Kemal.config)
end end
json.field "author", rv["author"] json.field "author", rv["author"]
json.field "lengthSeconds", rv["length_seconds"].to_i json.field "lengthSeconds", rv["length_seconds"].to_i
@ -608,15 +578,11 @@ get "/api/v1/videos/:id" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" video_info
JSON.parse(video_info).to_pretty_json
else
video_info
end
end end
get "/api/v1/trending" do |env| get "/api/v1/trending" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -627,7 +593,8 @@ get "/api/v1/trending" do |env|
trending = fetch_trending(trending_type, proxies, region, locale) trending = fetch_trending(trending_type, proxies, region, locale)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
videos = JSON.build do |json| videos = JSON.build do |json|
@ -637,7 +604,7 @@ get "/api/v1/trending" do |env|
json.field "title", video.title json.field "title", video.title
json.field "videoId", video.id json.field "videoId", video.id
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
json.field "lengthSeconds", video.length_seconds json.field "lengthSeconds", video.length_seconds
@ -659,15 +626,11 @@ get "/api/v1/trending" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" videos
JSON.parse(videos).to_pretty_json
else
videos
end
end end
get "/api/v1/channels/:ucid" do |env| get "/api/v1/channels/:ucid" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -679,7 +642,8 @@ get "/api/v1/channels/:ucid" do |env|
author, ucid, auto_generated = get_about_info(ucid, locale) author, ucid, auto_generated = get_about_info(ucid, locale)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
page = 1 page = 1
@ -691,7 +655,8 @@ get "/api/v1/channels/:ucid" do |env|
videos, count = get_60_videos(ucid, page, auto_generated, sort_by) videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
end end
@ -820,7 +785,7 @@ get "/api/v1/channels/:ucid" do |env|
end end
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
json.field "description", video.description json.field "description", video.description
@ -866,16 +831,12 @@ get "/api/v1/channels/:ucid" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" channel_info
JSON.parse(channel_info).to_pretty_json
else
channel_info
end
end end
["/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"].each do |route| ["/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"].each do |route|
get route do |env| get route do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -890,14 +851,16 @@ end
author, ucid, auto_generated = get_about_info(ucid, locale) author, ucid, auto_generated = get_about_info(ucid, locale)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
begin begin
videos, count = get_60_videos(ucid, page, auto_generated, sort_by) videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
result = JSON.build do |json| result = JSON.build do |json|
@ -918,7 +881,7 @@ end
end end
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
json.field "description", video.description json.field "description", video.description
@ -936,17 +899,13 @@ end
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" result
JSON.parse(result).to_pretty_json
else
result
end
end end
end end
["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route| ["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route|
get route do |env| get route do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -956,7 +915,8 @@ end
videos = get_latest_videos(ucid) videos = get_latest_videos(ucid)
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
response = JSON.build do |json| response = JSON.build do |json|
@ -970,7 +930,7 @@ end
json.field "authorUrl", "/channel/#{ucid}" json.field "authorUrl", "/channel/#{ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
json.field "description", video.description json.field "description", video.description
@ -988,17 +948,13 @@ end
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
end end
["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route| ["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route|
get route do |env| get route do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1011,8 +967,9 @@ end
begin begin
author, ucid, auto_generated = get_about_info(ucid, locale) author, ucid, auto_generated = get_about_info(ucid, locale)
rescue ex rescue ex
error_message = ex.message error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
@ -1041,7 +998,7 @@ end
json.field "lengthSeconds", video.length_seconds json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
end end
end end
@ -1057,16 +1014,12 @@ end
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
end end
get "/api/v1/channels/search/:ucid" do |env| get "/api/v1/channels/search/:ucid" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1094,7 +1047,7 @@ get "/api/v1/channels/search/:ucid" do |env|
json.field "authorUrl", "/channel/#{item.ucid}" json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, item.id) generate_thumbnails(json, item.id, config, Kemal.config)
end end
json.field "description", item.description json.field "description", item.description
@ -1126,7 +1079,7 @@ get "/api/v1/channels/search/:ucid" do |env|
json.field "lengthSeconds", video.length_seconds json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
end end
end end
@ -1162,15 +1115,11 @@ get "/api/v1/channels/search/:ucid" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
get "/api/v1/search" do |env| get "/api/v1/search" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
region = env.params.query["region"]? region = env.params.query["region"]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1223,7 +1172,7 @@ get "/api/v1/search" do |env|
json.field "authorUrl", "/channel/#{item.ucid}" json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, item.id) generate_thumbnails(json, item.id, config, Kemal.config)
end end
json.field "description", item.description json.field "description", item.description
@ -1255,7 +1204,7 @@ get "/api/v1/search" do |env|
json.field "lengthSeconds", video.length_seconds json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
end end
end end
@ -1291,15 +1240,11 @@ get "/api/v1/search" do |env|
end end
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
get "/api/v1/playlists/:plid" do |env| get "/api/v1/playlists/:plid" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
plid = env.params.url["plid"] plid = env.params.url["plid"]
@ -1320,7 +1265,8 @@ get "/api/v1/playlists/:plid" do |env|
playlist = fetch_playlist(plid, locale) playlist = fetch_playlist(plid, locale)
rescue ex rescue ex
error_message = {"error" => "Playlist is empty"}.to_json error_message = {"error" => "Playlist is empty"}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
begin begin
@ -1371,7 +1317,7 @@ get "/api/v1/playlists/:plid" do |env|
json.field "authorUrl", "/channel/#{video.ucid}" json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do json.field "videoThumbnails" do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
json.field "index", video.index json.field "index", video.index
@ -1394,15 +1340,11 @@ get "/api/v1/playlists/:plid" do |env|
}.to_json }.to_json
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
get "/api/v1/mixes/:rdid" do |env| get "/api/v1/mixes/:rdid" do |env|
locale = LOCALES[env.get("locale").as(String)]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json" env.response.content_type = "application/json"
@ -1426,7 +1368,8 @@ get "/api/v1/mixes/:rdid" do |env|
mix.videos = mix.videos[index..-1] mix.videos = mix.videos[index..-1]
rescue ex rescue ex
error_message = {"error" => ex.message}.to_json error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message env.response.status_code = 500
next error_message
end end
response = JSON.build do |json| response = JSON.build do |json|
@ -1447,7 +1390,7 @@ get "/api/v1/mixes/:rdid" do |env|
json.field "videoThumbnails" do json.field "videoThumbnails" do
json.array do json.array do
generate_thumbnails(json, video.id) generate_thumbnails(json, video.id, config, Kemal.config)
end end
end end
@ -1472,11 +1415,7 @@ get "/api/v1/mixes/:rdid" do |env|
}.to_json }.to_json
end end
if env.params.query["pretty"]? && env.params.query["pretty"] == "1" response
JSON.parse(response).to_pretty_json
else
response
end
end end
get "/api/manifest/dash/id/videoplayback" do |env| get "/api/manifest/dash/id/videoplayback" do |env|
@ -1508,10 +1447,11 @@ get "/api/manifest/dash/id/:id" do |env|
next env.redirect url next env.redirect url
rescue ex rescue ex
halt env, status_code: 403 env.response.status_code = 403
next
end end
if dashmpd = video.player_response["streamingData"]["dashManifestUrl"]?.try &.as_s if dashmpd = video.player_response["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s
manifest = client.get(dashmpd).body manifest = client.get(dashmpd).body
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
@ -1595,13 +1535,14 @@ get "/api/manifest/hls_variant/*" do |env|
manifest = client.get(env.request.path) manifest = client.get(env.request.path)
if manifest.status_code != 200 if manifest.status_code != 200
halt env, status_code: manifest.status_code env.response.status_code = manifest.status_code
next
end end
env.response.content_type = "application/x-mpegURL" env.response.content_type = "application/x-mpegURL"
env.response.headers.add("Access-Control-Allow-Origin", "*") env.response.headers.add("Access-Control-Allow-Origin", "*")
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) host_url = make_host_url(config, Kemal.config)
manifest = manifest.body manifest = manifest.body
manifest.gsub("https://www.youtube.com", host_url) manifest.gsub("https://www.youtube.com", host_url)
@ -1612,10 +1553,11 @@ get "/api/manifest/hls_playlist/*" do |env|
manifest = client.get(env.request.path) manifest = client.get(env.request.path)
if manifest.status_code != 200 if manifest.status_code != 200
halt env, status_code: manifest.status_code env.response.status_code = manifest.status_code
next
end end
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) host_url = make_host_url(config, Kemal.config)
manifest = manifest.body.gsub("https://www.youtube.com", host_url) manifest = manifest.body.gsub("https://www.youtube.com", host_url)
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url) manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)
@ -1648,7 +1590,8 @@ get "/latest_version" do |env|
local = local == "true" local = local == "true"
if !id || !itag if !id || !itag
halt env, status_code: 400 env.response.status_code = 400
next
end end
video = fetch_video(id, proxies, region: region) video = fetch_video(id, proxies, region: region)
@ -1658,9 +1601,11 @@ get "/latest_version" do |env|
urls = (fmt_stream + adaptive_fmts).select { |fmt| fmt["itag"] == itag } urls = (fmt_stream + adaptive_fmts).select { |fmt| fmt["itag"] == itag }
if urls.empty? if urls.empty?
halt env, status_code: 404 env.response.status_code = 404
next
elsif urls.size > 1 elsif urls.size > 1
halt env, status_code: 409 env.response.status_code = 409
next
end end
url = urls[0]["url"] url = urls[0]["url"]
@ -1734,24 +1679,39 @@ get "/videoplayback" do |env|
query_params = env.params.query query_params = env.params.query
fvip = query_params["fvip"]? || "3" fvip = query_params["fvip"]? || "3"
mn = query_params["mn"].split(",")[-1] mns = query_params["mn"].split(",")
host = "https://r#{fvip}---#{mn}.googlevideo.com"
if query_params["host"]? && !query_params["host"].empty?
host = "https://#{query_params["host"]}"
query_params.delete("host")
else
host = "https://r#{fvip}---#{mns.pop}.googlevideo.com"
end
url = "/videoplayback?#{query_params.to_s}" url = "/videoplayback?#{query_params.to_s}"
headers = env.request.headers headers = HTTP::Headers.new
headers.delete("Host") {"Accept", "Accept-Encoding", "Connection", "Range"}.each do |header|
headers.delete("Cookie") if env.request.headers[header]?
headers.delete("User-Agent") headers[header] = env.request.headers[header]
headers.delete("Referer") end
end
region = query_params["region"]? region = query_params["region"]?
response = HTTP::Client::Response.new(403) response = HTTP::Client::Response.new(403)
loop do 5.times do
begin begin
client = make_client(URI.parse(host), proxies, region) client = make_client(URI.parse(host), proxies, region)
response = client.head(url, headers) response = client.head(url, headers)
break break
rescue Socket::Addrinfo::Error
if !mns.empty?
mn = mns.pop
end
fvip = "3"
host = "https://r#{fvip}---#{mn}.googlevideo.com"
rescue ex rescue ex
end end
end end
@ -1769,7 +1729,8 @@ get "/videoplayback" do |env|
end end
if response.status_code >= 400 if response.status_code >= 400
halt env, status_code: response.status_code env.response.status_code = response.status_code
next
end end
client = make_client(URI.parse(host), proxies, region) client = make_client(URI.parse(host), proxies, region)
@ -1801,6 +1762,7 @@ get "/videoplayback" do |env|
end end
end end
# We need this so the below route works as expected
get "/ggpht*" do |env| get "/ggpht*" do |env|
end end
@ -1809,11 +1771,12 @@ get "/ggpht/*" do |env|
client = make_client(URI.parse(host)) client = make_client(URI.parse(host))
url = env.request.path.lchop("/ggpht") url = env.request.path.lchop("/ggpht")
headers = env.request.headers headers = HTTP::Headers.new
headers.delete("Host") {"Range", "Accept", "Accept-Encoding"}.each do |header|
headers.delete("Cookie") if env.request.headers[header]?
headers.delete("User-Agent") headers[header] = env.request.headers[header]
headers.delete("Referer") end
end
client.get(url, headers) do |response| client.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code
@ -1858,7 +1821,7 @@ get "/vi/:id/:name" do |env|
client = make_client(URI.parse(host)) client = make_client(URI.parse(host))
if name == "maxres.jpg" if name == "maxres.jpg"
VIDEO_THUMBNAILS.each do |thumb| build_thumbnails(id, config, Kemal.config).each do |thumb|
if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200 if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200
name = thumb[:url] + ".jpg" name = thumb[:url] + ".jpg"
break break
@ -1867,11 +1830,12 @@ get "/vi/:id/:name" do |env|
end end
url = "/vi/#{id}/#{name}" url = "/vi/#{id}/#{name}"
headers = env.request.headers headers = HTTP::Headers.new
headers.delete("Host") {"Range", "Accept", "Accept-Encoding"}.each do |header|
headers.delete("Cookie") if env.request.headers[header]?
headers.delete("User-Agent") headers[header] = env.request.headers[header]
headers.delete("Referer") end
end
client.get(url, headers) do |response| client.get(url, headers) do |response|
env.response.status_code = response.status_code env.response.status_code = response.status_code

View File

@ -10,13 +10,15 @@ end
class ChannelVideo class ChannelVideo
add_mapping({ add_mapping({
id: String, id: String,
title: String, title: String,
published: Time, published: Time,
updated: Time, updated: Time,
ucid: String, ucid: String,
author: String, author: String,
length_seconds: {type: Int32, default: 0}, length_seconds: {type: Int32, default: 0},
live_now: {type: Bool, default: false},
premiere_timestamp: {type: Time?, default: nil},
}) })
end end
@ -112,15 +114,32 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
rss.xpath_nodes("//feed/entry").each do |entry| rss.xpath_nodes("//feed/entry").each do |entry|
video_id = entry.xpath_node("videoid").not_nil!.content video_id = entry.xpath_node("videoid").not_nil!.content
title = entry.xpath_node("title").not_nil!.content title = entry.xpath_node("title").not_nil!.content
published = Time.parse(entry.xpath_node("published").not_nil!.content, "%FT%X%z", Time::Location.local) published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
updated = Time.parse(entry.xpath_node("updated").not_nil!.content, "%FT%X%z", Time::Location.local) updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
author = entry.xpath_node("author/name").not_nil!.content author = entry.xpath_node("author/name").not_nil!.content
ucid = entry.xpath_node("channelid").not_nil!.content ucid = entry.xpath_node("channelid").not_nil!.content
length_seconds = videos.select { |video| video.id == video_id }[0]?.try &.length_seconds channel_video = videos.select { |video| video.id == video_id }[0]?
length_seconds = channel_video.try &.length_seconds
length_seconds ||= 0 length_seconds ||= 0
video = ChannelVideo.new(video_id, title, published, Time.now, ucid, author, length_seconds) live_now = channel_video.try &.live_now
live_now ||= false
premiere_timestamp = channel_video.try &.premiere_timestamp
video = ChannelVideo.new(
video_id,
title,
published,
Time.now,
ucid,
author,
length_seconds,
live_now,
premiere_timestamp
)
db.exec("UPDATE users SET notifications = notifications || $1 \ db.exec("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid) WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid)
@ -128,9 +147,12 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
video_array = video.to_a video_array = video.to_a
args = arg_array(video_array) args = arg_array(video_array)
# We don't include the 'premire_timestamp' here because channel pages don't include them,
# meaning the above timestamp is always null
db.exec("INSERT INTO channel_videos VALUES (#{args}) \ db.exec("INSERT INTO channel_videos VALUES (#{args}) \
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
updated = $4, ucid = $5, author = $6, length_seconds = $7", video_array) updated = $4, ucid = $5, author = $6, length_seconds = $7, \
live_now = $8", video_array)
end end
else else
page = 1 page = 1
@ -157,7 +179,17 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
end end
count = nodeset.size count = nodeset.size
videos = videos.map { |video| ChannelVideo.new(video.id, video.title, video.published, Time.now, video.ucid, video.author, video.length_seconds) } videos = videos.map { |video| ChannelVideo.new(
video.id,
video.title,
video.published,
Time.now,
video.ucid,
video.author,
video.length_seconds,
video.live_now,
video.premiere_timestamp
) }
videos.each do |video| videos.each do |video|
ids << video.id ids << video.id
@ -170,8 +202,12 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
video_array = video.to_a video_array = video.to_a
args = arg_array(video_array) args = arg_array(video_array)
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \ # We don't include the 'premire_timestamp' here because channel pages don't include them,
published = $3, updated = $4, ucid = $5, author = $6, length_seconds = $7", video_array) # meaning the above timestamp is always null
db.exec("INSERT INTO channel_videos VALUES (#{args}) \
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
live_now = $8", video_array)
end end
end end
@ -194,12 +230,14 @@ end
def subscribe_pubsub(ucid, key, config) def subscribe_pubsub(ucid, key, config)
client = make_client(PUBSUB_URL) client = make_client(PUBSUB_URL)
time = Time.now.to_unix.to_s time = Time.now.to_unix.to_s
nonce = Random::Secure.hex(4)
signature = "#{time}:#{nonce}"
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain) host_url = make_host_url(config, Kemal.config)
body = { body = {
"hub.callback" => "#{host_url}/feed/webhook/#{time}:#{OpenSSL::HMAC.hexdigest(:sha1, key, time)}", "hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
"hub.topic" => "https://www.youtube.com/feeds/videos.xml?channel_id=#{ucid}", "hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?channel_id=#{ucid}",
"hub.verify" => "async", "hub.verify" => "async",
"hub.mode" => "subscribe", "hub.mode" => "subscribe",
"hub.lease_seconds" => "432000", "hub.lease_seconds" => "432000",

View File

@ -308,13 +308,13 @@ def template_youtube_comments(comments, locale)
<p> <p>
<b> <b>
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a> <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b> </b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p> <p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span> <span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
| |
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a> <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a>
| |
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])} <i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML END_HTML
if child["creatorHeart"]? if child["creatorHeart"]?
@ -372,8 +372,8 @@ def template_reddit_comments(root, locale)
content = <<-END_HTML content = <<-END_HTML
<p> <p>
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a> <a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b> <b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
#{translate(locale, "`x` points", number_with_separator(score))} #{translate(locale, "`x` points", number_with_separator(score))}
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
</p> </p>

View File

@ -0,0 +1,133 @@
module HTTP::Handler
@@exclude_routes_tree = Radix::Tree(String).new
macro exclude(paths, method = "GET")
class_name = {{@type.name}}
method_downcase = {{method.downcase}}
class_name_method = "#{class_name}/#{method_downcase}"
({{paths}}).each do |path|
@@exclude_routes_tree.add class_name_method + path, '/' + method_downcase + path
end
end
def exclude_match?(env : HTTP::Server::Context)
@@exclude_routes_tree.find(radix_path(env.request.method, env.request.path)).found?
end
private def radix_path(method : String, path : String)
"#{self.class}/#{method.downcase}#{path}"
end
end
class Kemal::RouteHandler
exclude ["/api/v1/*"]
# Processes the route if it's a match. Otherwise renders 404.
private def process_request(context)
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
content = context.route.handler.call(context)
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
raise Kemal::Exceptions::CustomException.new(context)
end
context.response.print(content)
context
end
end
class Kemal::ExceptionHandler
exclude ["/api/v1/*"]
private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
return if context.response.closed?
return if exclude_match? context
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
context.response.status_code = status_code
context.response.print Kemal.config.error_handlers[status_code].call(context, exception)
context
end
end
end
class FilteredCompressHandler < Kemal::Handler
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/ggpht/*"]
def call(env)
return call_next env if exclude_match? env
{% if flag?(:without_zlib) %}
call_next env
{% else %}
request_headers = env.request.headers
if request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip"
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate"
env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
end
call_next env
{% end %}
end
end
class APIHandler < Kemal::Handler
only ["/api/v1/*"]
def call(env)
return call_next env unless only_match? env
env.response.headers["Access-Control-Allow-Origin"] = "*"
# Here we swap out the socket IO so we can modify the response as needed
output = env.response.output
env.response.output = IO::Memory.new
begin
call_next env
env.response.output.rewind
response = env.response.output.gets_to_end
if env.response.headers["Content-Type"]?.try &.== "application/json"
response = JSON.parse(response)
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
response = response.to_pretty_json
else
response = response.to_json
end
end
rescue
ensure
env.response.output = output
env.response.puts response
env.response.flush
end
end
end
class DenyFrame < Kemal::Handler
exclude ["/embed/*"]
def call(env)
return call_next env if exclude_match? env
env.response.headers["X-Frame-Options"] = "sameorigin"
call_next env
end
end
# Temp fix for https://github.com/crystal-lang/crystal/issues/7383
class HTTP::Client
private def handle_response(response)
# close unless response.keep_alive?
response
end
end

View File

@ -1,7 +1,5 @@
class Config class Config
YAML.mapping({ YAML.mapping({
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions) channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
feed_threads: Int32, # Number of threads to use for updating feeds feed_threads: Int32, # Number of threads to use for updating feeds
db: NamedTuple( # Database configuration db: NamedTuple( # Database configuration
@ -17,63 +15,17 @@ user: String,
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
default_home: {type: String, default: "Top"}, default_home: {type: String, default: "Top"},
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending"]}, feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
top_enabled: {type: Bool, default: true}, top_enabled: {type: Bool, default: true},
captcha_enabled: {type: Bool, default: true}, captcha_enabled: {type: Bool, default: true},
login_enabled: {type: Bool, default: true}, login_enabled: {type: Bool, default: true},
registration_enabled: {type: Bool, default: true}, registration_enabled: {type: Bool, default: true},
statistics_enabled: {type: Bool, default: false}, statistics_enabled: {type: Bool, default: false},
admins: {type: Array(String), default: [] of String}, admins: {type: Array(String), default: [] of String},
external_port: {type: Int32 | Nil, default: nil},
}) })
end end
class FilteredCompressHandler < Kemal::Handler
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/api/*", "/ggpht/*"]
def call(env)
return call_next env if exclude_match? env
{% if flag?(:without_zlib) %}
call_next env
{% else %}
request_headers = env.request.headers
if request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip"
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate"
env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
end
call_next env
{% end %}
end
end
class APIHandler < Kemal::Handler
only ["/api/v1/*"]
def call(env)
return call_next env unless only_match? env
env.response.headers["Access-Control-Allow-Origin"] = "*"
call_next env
end
end
class DenyFrame < Kemal::Handler
exclude ["/embed/*"]
def call(env)
return call_next env if exclude_match? env
env.response.headers["X-Frame-Options"] = "sameorigin"
call_next env
end
end
def rank_videos(db, n) def rank_videos(db, n)
top = [] of {Float64, String} top = [] of {Float64, String}
@ -223,13 +175,22 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
) )
end end
playlist_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]?
playlist_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"]
if !playlist_thumbnail || playlist_thumbnail.empty?
thumbnail_id = videos[0]?.try &.id
else
thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
end
items << SearchPlaylist.new( items << SearchPlaylist.new(
title, title,
plid, plid,
author, author,
author_id, author_id,
video_count, video_count,
videos videos,
thumbnail_id
) )
when .includes? "yt-lockup-channel" when .includes? "yt-lockup-channel"
author = title.strip author = title.strip
@ -307,6 +268,11 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
paid = true paid = true
end end
premiere_timestamp = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/span[@class="localized-date"])).try &.["data-timestamp"]?.try &.to_i64
if premiere_timestamp
premiere_timestamp = Time.unix(premiere_timestamp)
end
items << SearchVideo.new( items << SearchVideo.new(
title: title, title: title,
id: id, id: id,
@ -319,7 +285,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
length_seconds: length_seconds, length_seconds: length_seconds,
live_now: live_now, live_now: live_now,
paid: paid, paid: paid,
premium: premium premium: premium,
premiere_timestamp: premiere_timestamp
) )
end end
end end
@ -390,13 +357,28 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
playlist_title ||= "" playlist_title ||= ""
plid ||= "" plid ||= ""
playlist_thumbnail = child_node.xpath_node(%q(.//span/img)).try &.["data-thumb"]?
playlist_thumbnail ||= child_node.xpath_node(%q(.//span/img)).try &.["src"]
if !playlist_thumbnail || playlist_thumbnail.empty?
thumbnail_id = videos[0]?.try &.id
else
thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
end
video_count_label = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
if video_count_label
video_count = video_count_label.content.strip.match(/^\d+/).try &.[0].to_i?
end
video_count ||= 50
items << SearchPlaylist.new( items << SearchPlaylist.new(
playlist_title, playlist_title,
plid, plid,
author_name, author_name,
ucid, ucid,
50, video_count,
Array(SearchPlaylistVideo).new Array(SearchPlaylistVideo).new,
thumbnail_id
) )
end end
end end
@ -410,7 +392,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
author_name, author_name,
ucid, ucid,
videos.size, videos.size,
videos videos,
videos[0].try &.id
) )
end end
end end

View File

@ -162,6 +162,23 @@ def number_with_separator(number)
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
end end
def short_text_to_number(short_text)
case short_text
when .ends_with? "M"
number = short_text.rstrip(" mM").to_f
number *= 1000000
when .ends_with? "K"
number = short_text.rstrip(" kK").to_f
number *= 1000
else
number = short_text.rstrip(" ")
end
number = number.to_i
return number
end
def number_to_short_text(number) def number_to_short_text(number)
seperated = number_with_separator(number).gsub(",", ".").split("") seperated = number_with_separator(number).gsub(",", ".").split("")
text = seperated.first(2).join text = seperated.first(2).join
@ -193,19 +210,30 @@ def arg_array(array, start = 1)
return args return args
end end
def make_host_url(ssl, host) def make_host_url(config, kemal_config)
ssl = config.https_only || kemal_config.ssl
port = config.external_port || kemal_config.port
if ssl if ssl
scheme = "https://" scheme = "https://"
else else
scheme = "http://" scheme = "http://"
end end
if host # Add if non-standard port
host = host.lchop(".") if port != 80 && port != 443
return "#{scheme}#{host}" port = ":#{kemal_config.port}"
else else
port = ""
end
if !config.domain
return "" return ""
end end
host = config.domain.not_nil!.lchop(".")
return "#{scheme}#{host}#{port}"
end end
def get_referer(env, fallback = "/") def get_referer(env, fallback = "/")

View File

@ -1,51 +1,3 @@
def crawl_videos(db, logger)
ids = Deque(String).new
random = Random.new
search(random.base64(3)).as(Tuple)[1].each do |video|
if video.is_a?(SearchVideo)
ids << video.id
end
end
loop do
if ids.empty?
search(random.base64(3)).as(Tuple)[1].each do |video|
if video.is_a?(SearchVideo)
ids << video.id
end
end
end
begin
id = ids[0]
video = get_video(id, db)
rescue ex
logger.write("#{id} : #{ex.message}\n")
next
ensure
ids.delete(id)
end
rvs = [] of Hash(String, String)
video.info["rvs"]?.try &.split(",").each do |rv|
rvs << HTTP::Params.parse(rv).to_h
end
rvs.each do |rv|
if rv.has_key?("id") && !db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", rv["id"], as: Bool)
ids.delete(id)
ids << rv["id"]
if ids.size == 150
ids.shift
end
end
end
Fiber.yield
end
end
def refresh_channels(db, logger, max_threads = 1, full_refresh = false) def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
max_channel = Channel(Int32).new max_channel = Channel(Int32).new
@ -82,30 +34,14 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
end end
end end
end end
sleep 1.minute
end end
end end
max_channel.send(max_threads) max_channel.send(max_threads)
end end
def refresh_videos(db, logger)
loop do
db.query("SELECT id FROM videos ORDER BY updated") do |rs|
rs.each do
begin
id = rs.read(String)
video = get_video(id, db)
rescue ex
logger.write("#{id} : #{ex.message}\n")
next
end
end
end
Fiber.yield
end
end
def refresh_feeds(db, logger, max_threads = 1) def refresh_feeds(db, logger, max_threads = 1)
max_channel = Channel(Int32).new max_channel = Channel(Int32).new
@ -129,15 +65,26 @@ def refresh_feeds(db, logger, max_threads = 1)
active_threads += 1 active_threads += 1
spawn do spawn do
begin begin
db.query("SELECT * FROM #{view_name} LIMIT 1") do |rs|
# View doesn't contain same number of rows as ChannelVideo
if ChannelVideo.from_rs(rs)[0]?.try &.to_a.size.try &.!= rs.column_count
db.exec("DROP MATERIALIZED VIEW #{view_name}")
raise "valid schema does not exist"
end
end
db.exec("REFRESH MATERIALIZED VIEW #{view_name}") db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
rescue ex rescue ex
# Create view if it doesn't exist # Create view if it doesn't exist
if ex.message.try &.ends_with? "does not exist" if ex.message.try &.ends_with?("does not exist")
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ # While iterating through, we may have an email stored from a deleted account
SELECT * FROM channel_videos WHERE \ if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
ORDER BY published DESC;") SELECT * FROM channel_videos WHERE \
logger.write("CREATE #{view_name}") ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
logger.write("CREATE #{view_name}\n")
end
else else
logger.write("REFRESH #{email} : #{ex.message}\n") logger.write("REFRESH #{email} : #{ex.message}\n")
end end
@ -147,6 +94,8 @@ def refresh_feeds(db, logger, max_threads = 1)
end end
end end
end end
sleep 1.minute
end end
end end
@ -158,16 +107,17 @@ def subscribe_to_feeds(db, logger, key, config)
spawn do spawn do
loop do loop do
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs| db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs|
ucid = rs.read(String) rs.each do
response = subscribe_pubsub(ucid, key, config) ucid = rs.read(String)
response = subscribe_pubsub(ucid, key, config)
if response.status_code >= 400 if response.status_code >= 400
logger.write("#{ucid} : #{response.body}\n") logger.write("#{ucid} : #{response.body}\n")
end
end end
end end
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
end end
@ -198,7 +148,7 @@ def pull_top_videos(config, db)
end end
yield videos yield videos
Fiber.yield sleep 1.minute
end end
end end
@ -213,7 +163,7 @@ def pull_popular_videos(db)
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
yield videos yield videos
Fiber.yield sleep 1.minute
end end
end end
@ -226,6 +176,7 @@ def update_decrypt_function
end end
yield decrypt_function yield decrypt_function
sleep 1.minute
end end
end end
@ -237,7 +188,8 @@ def find_working_proxies(regions)
# proxies = filter_proxies(proxies) # proxies = filter_proxies(proxies)
yield region, proxies yield region, proxies
Fiber.yield
end end
sleep 1.minute
end end
end end

View File

@ -8,6 +8,7 @@ class PlaylistVideo
published: Time, published: Time,
playlists: Array(String), playlists: Array(String),
index: Int32, index: Int32,
live_now: Bool,
}) })
end end
@ -101,8 +102,10 @@ def extract_playlist(plid, nodeset, index)
anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1])) anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1]))
if anchor && !anchor.content.empty? if anchor && !anchor.content.empty?
length_seconds = decode_length_seconds(anchor.content) length_seconds = decode_length_seconds(anchor.content)
live_now = false
else else
length_seconds = 0 length_seconds = 0
live_now = true
end end
videos << PlaylistVideo.new( videos << PlaylistVideo.new(
@ -114,6 +117,7 @@ def extract_playlist(plid, nodeset, index)
published: Time.now, published: Time.now,
playlists: [plid], playlists: [plid],
index: index + offset, index: index + offset,
live_now: live_now
) )
end end

View File

@ -1,17 +1,18 @@
class SearchVideo class SearchVideo
add_mapping({ add_mapping({
title: String, title: String,
id: String, id: String,
author: String, author: String,
ucid: String, ucid: String,
published: Time, published: Time,
views: Int64, views: Int64,
description: String, description: String,
description_html: String, description_html: String,
length_seconds: Int32, length_seconds: Int32,
live_now: Bool, live_now: Bool,
paid: Bool, paid: Bool,
premium: Bool, premium: Bool,
premiere_timestamp: Time?,
}) })
end end
@ -25,12 +26,13 @@ end
class SearchPlaylist class SearchPlaylist
add_mapping({ add_mapping({
title: String, title: String,
id: String, id: String,
author: String, author: String,
ucid: String, ucid: String,
video_count: Int32, video_count: Int32,
videos: Array(SearchPlaylistVideo), videos: Array(SearchPlaylistVideo),
thumbnail_id: String?,
}) })
end end

View File

@ -31,6 +31,7 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
"video_loop" => false, "video_loop" => false,
"autoplay" => false, "autoplay" => false,
"continue" => false, "continue" => false,
"local" => false,
"listen" => false, "listen" => false,
"speed" => 1.0, "speed" => 1.0,
"quality" => "hd720", "quality" => "hd720",
@ -80,6 +81,10 @@ class Preferences
type: Bool, type: Bool,
default: DEFAULT_USER_PREFERENCES.continue, default: DEFAULT_USER_PREFERENCES.continue,
}, },
local: {
type: Bool,
default: DEFAULT_USER_PREFERENCES.local,
},
listen: { listen: {
type: Bool, type: Bool,
default: DEFAULT_USER_PREFERENCES.listen, default: DEFAULT_USER_PREFERENCES.listen,
@ -250,8 +255,12 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
challenge = OpenSSL::HMAC.digest(:sha256, key, challenge) challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
challenge = Base64.urlsafe_encode(challenge) challenge = Base64.urlsafe_encode(challenge)
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool) if nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", nonce, as: {String, Time})
db.exec("DELETE FROM nonces * WHERE nonce = $1", nonce) if nonce[1] > Time.now
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
else
raise translate(locale, "Invalid token")
end
else else
raise translate(locale, "Invalid token") raise translate(locale, "Invalid token")
end end
@ -265,7 +274,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
end end
if challenge_user_id != user_id if challenge_user_id != user_id
raise translate(locale, "Invalid user") raise translate(locale, "Invalid token")
end end
if expire < Time.now.to_unix if expire < Time.now.to_unix
@ -291,7 +300,7 @@ def generate_captcha(key, db)
clock_svg = <<-END_SVG clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px"> <svg viewBox="0 0 100 100" width="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle> <circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text> <text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text> <text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text> <text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
@ -323,7 +332,22 @@ def generate_captcha(key, db)
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
challenge, token = create_response(answer, "sign_in", key, db) return {
question: image,
return {image: image, challenge: challenge, token: token} tokens: [create_response(answer, "sign_in", key, db)],
}
end
def generate_text_captcha(key, db)
response = HTTP::Client.get(TEXTCAPTCHA_URL).body
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
create_response(answer.as_s, "sign_in", key, db)
end
return {
question: response["q"].as_s,
tokens: tokens,
}
end end

View File

@ -136,18 +136,6 @@ BYPASS_REGIONS = {
"TR", "TR",
} }
VIDEO_THUMBNAILS = {
{name: "maxres", host: "#{CONFIG.domain}", url: "maxres", height: 720, width: 1280},
{name: "maxresdefault", host: "i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", host: "i.ytimg.com", url: "sddefault", height: 480, width: 640},
{name: "high", host: "i.ytimg.com", url: "hqdefault", height: 360, width: 480},
{name: "medium", host: "i.ytimg.com", url: "mqdefault", height: 180, width: 320},
{name: "default", host: "i.ytimg.com", url: "default", height: 90, width: 120},
{name: "start", host: "i.ytimg.com", url: "1", height: 90, width: 120},
{name: "middle", host: "i.ytimg.com", url: "2", height: 90, width: 120},
{name: "end", host: "i.ytimg.com", url: "3", height: 90, width: 120},
}
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
VIDEO_FORMATS = { VIDEO_FORMATS = {
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
@ -262,6 +250,63 @@ class Video
end end
end end
def allow_ratings
allow_ratings = player_response["videoDetails"].try &.["allowRatings"]?.try &.as_bool
if allow_ratings.nil?
return true
end
return allow_ratings
end
def live_now
live_now = self.player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool
if live_now.nil?
return false
end
return live_now
end
def is_listed
is_listed = player_response["videoDetails"].try &.["isCrawlable"]?.try &.as_bool
if is_listed.nil?
return true
end
return is_listed
end
def is_upcoming
is_upcoming = player_response["videoDetails"].try &.["isUpcoming"]?.try &.as_bool
if is_upcoming.nil?
return false
end
return is_upcoming
end
def premiere_timestamp
if self.is_upcoming
premiere_timestamp = player_response["playabilityStatus"]?
.try &.["liveStreamability"]?
.try &.["liveStreamabilityRenderer"]?
.try &.["offlineSlate"]?
.try &.["liveStreamOfflineSlateRenderer"]?
.try &.["scheduledStartTime"].as_s.to_i64
end
if premiere_timestamp
premiere_timestamp = Time.unix(premiere_timestamp)
end
return premiere_timestamp
end
def keywords def keywords
keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
keywords ||= [] of String keywords ||= [] of String
@ -329,6 +374,7 @@ class Video
end end
streams.each do |fmt| streams.each do |fmt|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function) fmt["url"] += decrypt_signature(fmt, decrypt_function)
end end
@ -396,6 +442,7 @@ class Video
end end
adaptive_fmts.each do |fmt| adaptive_fmts.each do |fmt|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function) fmt["url"] += decrypt_signature(fmt, decrypt_function)
end end
@ -654,6 +701,10 @@ def fetch_video(id, proxies, region)
raise "Video unavailable." raise "Video unavailable."
end end
if !info["title"]?
raise "Video unavailable."
end
title = info["title"] title = info["title"]
author = info["author"] author = info["author"]
ucid = info["ucid"] ucid = info["ucid"]
@ -743,11 +794,12 @@ end
def process_video_params(query, preferences) def process_video_params(query, preferences)
autoplay = query["autoplay"]?.try &.to_i? autoplay = query["autoplay"]?.try &.to_i?
continue = query["continue"]?.try &.to_i? continue = query["continue"]?.try &.to_i?
related_videos = query["related_videos"]?
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
local = query["local"]? && (query["local"] == "true").to_unsafe
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase } preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]? quality = query["quality"]?
region = query["region"]? region = query["region"]?
related_videos = query["related_videos"]?
speed = query["speed"]?.try &.to_f? speed = query["speed"]?.try &.to_f?
video_loop = query["loop"]?.try &.to_i? video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i? volume = query["volume"]?.try &.to_i?
@ -756,10 +808,11 @@ def process_video_params(query, preferences)
# region ||= preferences.region # region ||= preferences.region
autoplay ||= preferences.autoplay.to_unsafe autoplay ||= preferences.autoplay.to_unsafe
continue ||= preferences.continue.to_unsafe continue ||= preferences.continue.to_unsafe
related_videos ||= preferences.related_videos.to_unsafe
listen ||= preferences.listen.to_unsafe listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe
preferred_captions ||= preferences.captions preferred_captions ||= preferences.captions
quality ||= preferences.quality quality ||= preferences.quality
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe video_loop ||= preferences.video_loop.to_unsafe
volume ||= preferences.volume volume ||= preferences.volume
@ -767,18 +820,20 @@ def process_video_params(query, preferences)
autoplay ||= DEFAULT_USER_PREFERENCES.autoplay.to_unsafe autoplay ||= DEFAULT_USER_PREFERENCES.autoplay.to_unsafe
continue ||= DEFAULT_USER_PREFERENCES.continue.to_unsafe continue ||= DEFAULT_USER_PREFERENCES.continue.to_unsafe
related_videos ||= DEFAULT_USER_PREFERENCES.related_videos.to_unsafe
listen ||= DEFAULT_USER_PREFERENCES.listen.to_unsafe listen ||= DEFAULT_USER_PREFERENCES.listen.to_unsafe
local ||= DEFAULT_USER_PREFERENCES.local.to_unsafe
preferred_captions ||= DEFAULT_USER_PREFERENCES.captions preferred_captions ||= DEFAULT_USER_PREFERENCES.captions
quality ||= DEFAULT_USER_PREFERENCES.quality quality ||= DEFAULT_USER_PREFERENCES.quality
related_videos ||= DEFAULT_USER_PREFERENCES.related_videos.to_unsafe
speed ||= DEFAULT_USER_PREFERENCES.speed speed ||= DEFAULT_USER_PREFERENCES.speed
video_loop ||= DEFAULT_USER_PREFERENCES.video_loop.to_unsafe video_loop ||= DEFAULT_USER_PREFERENCES.video_loop.to_unsafe
volume ||= DEFAULT_USER_PREFERENCES.volume volume ||= DEFAULT_USER_PREFERENCES.volume
autoplay = autoplay == 1 autoplay = autoplay == 1
continue = continue == 1 continue = continue == 1
related_videos = related_videos == 1
listen = listen == 1 listen = listen == 1
local = local == 1
related_videos = related_videos == 1
video_loop = video_loop == 1 video_loop = video_loop == 1
if query["t"]? if query["t"]?
@ -811,6 +866,7 @@ def process_video_params(query, preferences)
continue: continue, continue: continue,
controls: controls, controls: controls,
listen: listen, listen: listen,
local: local,
preferred_captions: preferred_captions, preferred_captions: preferred_captions,
quality: quality, quality: quality,
raw: raw, raw: raw,
@ -826,12 +882,26 @@ def process_video_params(query, preferences)
return params return params
end end
def generate_thumbnails(json, id) def build_thumbnails(id, config, kemal_config)
return {
{name: "maxres", host: "#{make_host_url(config, kemal_config)}", url: "maxres", height: 720, width: 1280},
{name: "maxresdefault", host: "https://i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", host: "https://i.ytimg.com", url: "sddefault", height: 480, width: 640},
{name: "high", host: "https://i.ytimg.com", url: "hqdefault", height: 360, width: 480},
{name: "medium", host: "https://i.ytimg.com", url: "mqdefault", height: 180, width: 320},
{name: "default", host: "https://i.ytimg.com", url: "default", height: 90, width: 120},
{name: "start", host: "https://i.ytimg.com", url: "1", height: 90, width: 120},
{name: "middle", host: "https://i.ytimg.com", url: "2", height: 90, width: 120},
{name: "end", host: "https://i.ytimg.com", url: "3", height: 90, width: 120},
}
end
def generate_thumbnails(json, id, config, kemal_config)
json.array do json.array do
VIDEO_THUMBNAILS.each do |thumbnail| build_thumbnails(id, config, kemal_config).each do |thumbnail|
json.object do json.object do
json.field "quality", thumbnail[:name] json.field "quality", thumbnail[:name]
json.field "url", "https://#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "width", thumbnail[:width] json.field "width", thumbnail[:width]
json.field "height", thumbnail[:height] json.field "height", thumbnail[:height]
end end