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)
## Version 0.14.0: Community

View File

@ -3,7 +3,7 @@
## Invidious is an alternative front-end to YouTube
- 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 need to create a Google account to save subscriptions
- Lightweight (homepage is ~4 KB compressed)

View File

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

View File

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

View File

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

View File

@ -10,8 +10,9 @@
"newest": "newest",
"oldest": "oldest",
"popular": "popular",
"Preview page": "Preview page",
"last": "last",
"Next page": "Next page",
"Previous page": "Previous page",
"Clear watch history?": "Clear watch history?",
"Yes": "Yes",
"No": "No",
@ -28,7 +29,6 @@
"Export data as JSON": "Export data as JSON",
"Delete account?": "Delete account?",
"History": "History",
"Previous page": "Previous page",
"An alternative front-end to YouTube": "An alternative front-end to YouTube",
"JavaScript license information": "JavaScript license information",
"source": "source",
@ -50,6 +50,7 @@
"Autoplay: ": "Autoplay: ",
"Autoplay next video: ": "Autoplay next video: ",
"Listen by default: ": "Listen by default: ",
"Proxy videos? ": "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.": "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"
"Video mode": "Video mode",
"Videos": "Videos",
"Playlists": "Playlists",
"Current version: ": "Current version: "
}

View File

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

View File

@ -10,7 +10,9 @@
"newest": "Date d'ajout (la plus récente)",
"oldest": "Date d'ajout (la plus ancienne)",
"popular": "Les plus populaires",
"last": "Dernières",
"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 ?",
"Yes": "Oui",
"No": "Non",
@ -25,23 +27,22 @@
"Export subscriptions as OPML": "Exporter les abonnements en OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
"Export data as JSON": "Exporter les données au format JSON",
"Delete account?": "Supprimer votre compte ?",
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
"History": "Historique",
"Previous page": "Page précédente",
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
"JavaScript license information": "Informations sur les licences JavaScript",
"source": "source",
"Login": "Connexion",
"Login/Register": "Connexion/S'inscrire",
"Login to Google": "Se connecter à Google",
"User ID:": "ID utilisateur :",
"User ID:": "Identifiant utilisateur :",
"Password:": "Mot de passe :",
"Time (h:mm:ss):": "Heure (h:mm:ss) :",
"Text CAPTCHA": "CAPTCHA Texte",
"Image CAPTCHA": "CAPTCHA Image",
"Sign In": "S'identifier",
"Register": "S'inscrire",
"Email:": "Email :",
"Email:": "E-mail :",
"Google verification code:": "Code de vérification Google :",
"Preferences": "Préférences",
"Player preferences": "Préférences du Lecteur",
@ -49,6 +50,7 @@
"Autoplay: ": "Lire Automatiquement : ",
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
"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 : ",
"Preferred video quality: ": "Qualité vidéo souhaitée : ",
"Player volume: ": "Volume du lecteur : ",
@ -56,7 +58,7 @@
"Default captions: ": "Sous-titres principal : ",
"Fallback captions: ": "Sous-titres secondaire : ",
"Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
"Visual preferences": "Préférences visuelles",
"Visual preferences": "Préférences du site",
"Dark mode: ": "Mode Sombre : ",
"Thin mode: ": "Mode Simplifié : ",
"Subscription preferences": "Préférences de la page d'abonnements",
@ -79,14 +81,14 @@
"Manage subscriptions": "Gérer les abonnements",
"Watch history": "Historique de visionnage",
"Delete account": "Supprimer votre compte",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Administrator preferences": "Préferences d'Administrateur",
"Default homepage: ": "Page d'accueil par defaut :",
"Feed menu: ": "Menu des Flux :",
"Top enabled? ": "Top activé ?",
"CAPTCHA enabled? ": "CAPTCHA activé ?",
"Login enabled? ": "Connexion activé ?",
"Registration enabled? ": "Inscription activé ?",
"Report statistics? ": "Telemetrie activé ?",
"Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement",
"`x` subscriptions": "`x` abonnements",
@ -99,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
"Source available here.": "Code Source.",
"View JavaScript license information.": "Voir les informations des licences JavaScript.",
"View privacy policy.": "Politique de confidentialité",
"Trending": "Tendances",
"Watch video on Youtube": "Voir la vidéo sur Youtube",
"Genre: ": "Genre : ",
@ -283,5 +286,8 @@
"Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
"Audio mode": "Mode Audio",
"Video mode": "Mode Vidéo"
"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)",
"oldest": "Data di aggiunta (più vecchia)",
"popular": "Tendenze",
"last": "",
"Next page": "Pagina successiva",
"Previous page": "Pagina precedente",
"Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?",
"Yes": "Si",
"No": "No",
@ -27,7 +29,6 @@
"Export data as JSON": "Esporta i dati in formato JSON",
"Delete account?": "Sei sicuro di voler cancellare l'account?",
"History": "Cronologia",
"Previous page": "Pagina precedente",
"An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube",
"JavaScript license information": "Info licenze JavaScript",
"source": "sorgente",
@ -49,6 +50,7 @@
"Autoplay: ": "Riproduzione automatica: ",
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
"Listen by default: ": "Modalità solo audio come predefinita: ",
"Proxy videos? ": "",
"Default speed: ": "Velocità di riproduzione predefinita: ",
"Preferred video quality: ": "Preferenza sulla qualità video: ",
"Player volume: ": "Volume di riproduzione: ",
@ -99,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.",
"Source available here.": "Codice sorgente.",
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
"View privacy policy.": "",
"Trending": "Tendenze",
"Watch video on Youtube": "Guarda il video su YouTube",
"Genre: ": "Genere: ",
@ -283,5 +286,8 @@
"Youtube permalink of the comment": "Link permanente al commento di YouTube",
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio",
"Video mode": "Modalità video"
"Video mode": "Modalità video",
"Videos": "",
"Playlists": "",
"Current version: ": ""
}

View File

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

View File

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

View File

@ -10,8 +10,9 @@
"newest": "najnowsze",
"oldest": "najstarsze",
"popular": "popularne",
"Preview page": "Podgląd strony",
"last": "",
"Next page": "Następna strona",
"Previous page": "Poprzednia strona",
"Clear watch history?": "Wyczyścić historię?",
"Yes": "Tak",
"No": "Nie",
@ -28,7 +29,6 @@
"Export data as JSON": "Eksportuj dane jako JSON",
"Delete account?": "Usunąć konto?",
"History": "Historia",
"Previous page": "Poprzednia strona",
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
"JavaScript license information": "Informacja o licencji JavaScript",
"source": "źródło",
@ -50,12 +50,13 @@
"Autoplay: ": "Autoodtwarzanie: ",
"Autoplay next video: ": "Odtwórz następny film: ",
"Listen by default: ": "Tryb dźwiękowy: ",
"Proxy videos? ": "",
"Default speed: ": "Domyślna prędkość: ",
"Preferred video quality: ": "Preferowana jakość filmów: ",
"Player volume: ": "Głośność odtwarzacza: ",
"Default comments: ": "Domyślne komentarze: ",
"Default captions: ": "Domyślne napisy: ",
"Fallback captions: ": "Rezerwowe napisy: ",
"Fallback captions: ": "Zastępcze napisy: ",
"Show related videos? ": "Pokaż powiązane filmy? ",
"Visual preferences": "Preferencje Wizualne",
"Dark mode: ": "Ciemny motyw: ",
@ -63,13 +64,13 @@
"Subscription preferences": "Preferencje subskrybcji",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
"Sort videos by: ": "Sortuj filmy po: ",
"published": "czasie publikacji",
"published - reverse": "czasie publikacji od najstarszych",
"Sort videos by: ": "Sortuj filmy: ",
"published": "po czasie publikacji",
"published - reverse": "po czasie publikacji od najstarszych",
"alphabetically": "alfabetycznie",
"alphabetically - reverse": "alfabetycznie od tyłu",
"channel name": "nazwie kanału",
"channel name - reverse": "nazwie kanału od tyłu",
"channel name": "po nazwie kanał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 unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
@ -80,14 +81,14 @@
"Manage subscriptions": "Organizuj subskrybcje",
"Watch history": "Historia",
"Delete account": "Usuń konto",
"Administrator preferences": "",
"Default homepage: ": "",
"Administrator preferences": "Preferencje administratora",
"Default homepage: ": "Domyślna strona główna: ",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"CAPTCHA enabled? ": "CAPTCHA aktywna? ",
"Login enabled? ": "Logowanie włączone? ",
"Registration enabled? ": "Rejestracja włączona? ",
"Report statistics? ": "Raportować statystyki? ",
"Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji",
"`x` subscriptions": "`x` subskrybcji",
@ -100,6 +101,7 @@
"Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.",
"Source available here.": "Kod źródłowy dostępny tutaj.",
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
"View privacy policy.": "",
"Trending": "Na czasie",
"Watch video on Youtube": "Zobacz film na YouTube",
"Genre: ": "Gatunek: ",
@ -155,20 +157,20 @@
"Token is expired, please try again": "Token wygasł, spróbuj ponownie",
"English": "angielski",
"English (auto-generated)": "angielski (automatycznie generowane)",
"Afrikaans": "",
"Afrikaans": "afrykanerski",
"Albanian": "albański",
"Amharic": "",
"Amharic": "amharski",
"Arabic": "arabski",
"Armenian": "",
"Azerbaijani": "",
"Bangla": "",
"Basque": "",
"Armenian": "armeński",
"Azerbaijani": "azerski",
"Bangla": "bengalski",
"Basque": "baskijski",
"Belarusian": "białoruski",
"Bosnian": "bośniacki",
"Bulgarian": "bułgarski",
"Burmese": "birmański",
"Catalan": "kataloński",
"Cebuano": "",
"Cebuano": "cebuański",
"Chinese (Simplified)": "chiński (uproszczony)",
"Chinese (Traditional)": "chiński (tradycyjny)",
"Corsican": "korsykański",
@ -185,28 +187,28 @@
"Georgian": "gruziński",
"German": "niemiecki",
"Greek": "grecki",
"Gujarati": "",
"Haitian Creole": "",
"Hausa": "",
"Gujarati": "gudźarati",
"Haitian Creole": "kreolski haitański",
"Hausa": "hausa",
"Hawaiian": "hawajski",
"Hebrew": "hebrajski",
"Hindi": "hindi",
"Hmong": "",
"Hmong": "hmong",
"Hungarian": "węgierski",
"Icelandic": "islandzki",
"Igbo": "",
"Igbo": "ibo",
"Indonesian": "indonezyjski",
"Irish": "irlandzki",
"Italian": "włoski",
"Japanese": "japoński",
"Javanese": "jawajski",
"Kannada": "",
"Kannada": "kannada",
"Kazakh": "kazachski",
"Khmer": "",
"Khmer": "khmerski",
"Korean": "koreański",
"Kurdish": "kurdyjski",
"Kyrgyz": "kirgiski",
"Lao": "",
"Lao": "laotański",
"Latin": "łaciński",
"Latvian": "łotewski",
"Lithuanian": "litewski",
@ -214,51 +216,51 @@
"Macedonian": "macedoński",
"Malagasy": "malgaski",
"Malay": "malajski",
"Malayalam": "",
"Malayalam": "malajalam",
"Maltese": "maltański",
"Maori": "",
"Marathi": "",
"Maori": "maoryski",
"Marathi": "marathi",
"Mongolian": "mongolski",
"Nepali": "nepalski",
"Norwegian": "norweski",
"Nyanja": "",
"Pashto": "",
"Nyanja": "njandża",
"Pashto": "paszto",
"Persian": "perski",
"Polish": "polski",
"Portuguese": "portugalski",
"Punjabi": "",
"Punjabi": "pendżabski",
"Romanian": "rumuński",
"Russian": "rosyjski",
"Samoan": "",
"Scottish Gaelic": "",
"Samoan": "samoański",
"Scottish Gaelic": "gaelicki szkocki",
"Serbian": "serbski",
"Shona": "",
"Sindhi": "",
"Sinhala": "",
"Shona": "shona",
"Sindhi": "sindhi",
"Sinhala": "syngaleski",
"Slovak": "słowacki",
"Slovenian": "słoweński",
"Somali": "somalijski",
"Southern Sotho": "",
"Southern Sotho": "sotho południowy",
"Spanish": "hiszpański",
"Spanish (Latin America)": "hiszpański (ameryka łacińska)",
"Sundanese": "",
"Swahili": "",
"Sundanese": "sundajski",
"Swahili": "suahili",
"Swedish": "szwedzki",
"Tajik": "",
"Tamil": "",
"Telugu": "",
"Tajik": "tadżycki",
"Tamil": "tamilski",
"Telugu": "telugu",
"Thai": "tajski",
"Turkish": "turecki",
"Ukrainian": "ukraiński",
"Urdu": "",
"Urdu": "urdu",
"Uzbek": "uzbecki",
"Vietnamese": "wietnamski",
"Welsh": "walijski",
"Western Frisian": "",
"Xhosa": "",
"Yiddish": "",
"Yoruba": "",
"Zulu": "",
"Western Frisian": "zachodniofryzyjski",
"Xhosa": "xhosa",
"Yiddish": "jidysz",
"Yoruba": "joruba",
"Zulu": "zuluski",
"`x` years": "`x` lat",
"`x` months": "`x` miesięcy",
"`x` weeks": "`x` tygodni",
@ -272,7 +274,7 @@
"About": "Informacje",
"Rating: ": "Ocena: ",
"Language: ": "Język: ",
"Default": "",
"Default": "Domyślnie",
"Music": "Muzyka",
"Gaming": "Gry",
"News": "Wiadomości",
@ -282,7 +284,10 @@
"%A %B %-d, %Y": "",
"(edited)": "(edytowany)",
"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",
"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` videos": "`x` видео",
"LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться",
"Subscribe": "Подписаться",
"Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
"View channel on YouTube": "Канал на YouTube",
"newest": "новые",
"oldest": "старые",
"popular": "популярные",
"Preview page": "Предварительный просмотр",
"Next page": "Следующая страница",
"Clear watch history?": "Очистить историю просмотров?",
"Yes": "Да",
"No": "Нет",
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать данные Invidious",
"Import YouTube subscriptions": "Импортировать YouTube подписки",
"Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
"Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
"Export": "Экспорт",
"Export subscriptions as OPML": "Экспортировать подписки в OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
"Export data as JSON": "Экспортировать данные в JSON",
"Delete account?": "Удалить аккаунт?",
"History": "История",
"Previous page": "Предыдущая страница",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
"JavaScript license information": "Лицензии JavaScript",
"source": "источник",
"Login": "Войти",
"Login/Register": "Войти/Регистрация",
"Login to Google": "Войти через Google",
"User ID:": "ID пользователя:",
"Password:": "Пароль:",
"Time (h:mm:ss):": "Время (ч:мм:сс):",
"Text CAPTCHA": "Текст капчи",
"Image CAPTCHA": "Изображение капчи",
"Sign In": "Войти",
"Register": "Регистрация",
"Email:": "Эл. почта:",
"Google verification code:": "Код подтверждения Google:",
"Preferences": "Настройки",
"Player preferences": "Настройки проигрывателя",
"Always loop: ": "Всегда повторять: ",
"Autoplay: ": "Автовоспроизведение: ",
"Autoplay next video: ": "Автовоспроизведение следующего видео: ",
"Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
"Default speed: ": "Скорость по-умолчанию: ",
"Preferred video quality: ": "Предпочтительное качество видео: ",
"Player volume: ": "Громкость воспроизведения: ",
"Default comments: ": "Источник комментариев: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Default captions: ": "Субтитры по-умолчанию: ",
"Fallback captions: ": "Резервные субтитры: ",
"Show related videos? ": "Показывать похожие видео? ",
"Visual preferences": "Визуальные настройки",
"Dark mode: ": "Темная тема: ",
"Thin mode: ": "Облегченный режим: ",
"Subscription preferences": "Настройки подписок",
"Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
"Number of videos shown in feed: ": "Число видео в ленте: ",
"Sort videos by: ": "Сортировать видео по: ",
"published": "дате публикации",
"published - reverse": "дате - обратный порядок",
"alphabetically": "алфавиту",
"alphabetically - reverse": "алфавиту - обратный порядок",
"channel name": "имени канала",
"channel name - reverse": "имени канала - обратный порядок",
"Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
"Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
"Only show unwatched: ": "Отображать только непросмотренные видео: ",
"Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
"Data preferences": "Настройки данных",
"Clear watch history": "Очистить историю просмотра",
"Import/Export data": "Импорт/Экспорт данных",
"Manage subscriptions": "Управление подписками",
"Watch history": "История просмотров",
"Delete account": "Удалить аккаунт",
"Administrator preferences": "",
"Default homepage: ": "",
"Feed menu: ": "",
"Top enabled? ": "",
"CAPTCHA enabled? ": "",
"Login enabled? ": "",
"Registration enabled? ": "",
"Report statistics? ": "",
"Save preferences": "Сохранить настройки",
"Subscription manager": "Менеджер подписок",
"`x` subscriptions": "`x` подписок",
"Import/Export": "Импорт/Экспорт",
"unsubscribe": "отписаться",
"Subscriptions": "Подписки",
"`x` unseen notifications": "`x` новых оповещений",
"search": "поиск",
"Sign out": "Выйти",
"Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
"Source available here.": "Исходный код доступен здесь.",
"Liberapay: ": "Liberapay: ",
"Patreon: ": "Patreon: ",
"BTC: ": "BTC: ",
"BCH: ": "BCH: ",
"View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
"Trending": "В тренде",
"Watch video on Youtube": "Смотреть на YouTube",
"Genre: ": "Жанр: ",
"License: ": "Лицензия: ",
"Family friendly? ": "Семейный просмотр: ",
"Wilson score: ": "Рейтинг Вильсона: ",
"Engagement: ": "Вовлеченность: ",
"Whitelisted regions: ": "Доступно для: ",
"Blacklisted regions: ": "Недоступно для: ",
"Shared `x`": "Опубликовано `x`",
"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 YouTube comments": "Смотреть комментарии с YouTube",
"View more comments on Reddit": "Больше комментариев на Reddit",
"View `x` comments": "Показать `x` комментариев",
"View Reddit comments": "Смотреть комментарии с Reddit",
"Hide replies": "Скрыть ответы",
"Show replies": "Показать ответы",
"Incorrect password": "Неправильный пароль",
"Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
"Invalid TFA code": "Неправильный TFA код",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
"Invalid answer": "Неверный ответ",
"Invalid CAPTCHA": "Неверная капча",
"CAPTCHA is a required field": "Необходимо ввести капчу",
"User ID is a required field": "Необходимо ввести идентификатор пользователя",
"Password is a required field": "Необходимо ввести пароль",
"Invalid username or password": "Недопустимый пароль или имя пользователя",
"Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
"Password cannot be empty": "Пароль не может быть пустым",
"Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
"Please sign in": "Пожалуйста, войдите",
"Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
"channel:`x`": "канал: `x`",
"Deleted or invalid channel": "Канал удален или не найден",
"This channel does not exist.": "Такой канал не существует.",
"Could not get channel info.": "Невозможно получить информацию о канале.",
"Could not fetch comments": "Невозможно получить комментарии",
"View `x` replies": "Показать `x` ответов",
"`x` ago": "`x` назад",
"Load more": "Загрузить больше",
"`x` points": "`x` очков",
"Could not create mix.": "Невозможно создать \"микс\".",
"Playlist is empty": "Плейлист пуст",
"Invalid playlist.": "Некорректный плейлист.",
"Playlist does not exist.": "Плейлист не существует.",
"Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
"Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
"Invalid challenge": "Неправильный ответ в \"challenge\"",
"Invalid token": "Неправильный токен",
"Invalid user": "Недопустимое имя пользователя",
"Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
"English": "Английский",
"English (auto-generated)": "Английский (созданы автоматически)",
"Afrikaans": "Африкаанс",
"Albanian": "Албанский",
"Amharic": "Амхарский",
"Arabic": "Арабский",
"Armenian": "Армянский",
"Azerbaijani": "Азербайджанский",
"Bangla": "Бенгальский",
"Basque": "Баскский",
"Belarusian": "Белорусский",
"Bosnian": "Боснийский",
"Bulgarian": "Болгарский",
"Burmese": "Бирманский",
"Catalan": "Каталонский",
"Cebuano": "Себуанский",
"Chinese (Simplified)": "Китайский (упрощенный)",
"Chinese (Traditional)": "Китайский (традиционный)",
"Corsican": "Корсиканский",
"Croatian": "Хорватский",
"Czech": "Чешский",
"Danish": "Датский",
"Dutch": "Нидерландский",
"Esperanto": "Эсперанто",
"Estonian": "Эстонский",
"Filipino": "Филиппинский",
"Finnish": "Финский",
"French": "Французский",
"Galician": "Галисийский",
"Georgian": "Грузинский",
"German": "Немецкий",
"Greek": "Греческий",
"Gujarati": "Гуджаратский",
"Haitian Creole": "Гаит. креольский",
"Hausa": "Хауса",
"Hawaiian": "Гавайский",
"Hebrew": "Иврит",
"Hindi": "Хинди",
"Hmong": "Хмонг (мяо)",
"Hungarian": "Венгерский",
"Icelandic": "Исландский",
"Igbo": "Игбо",
"Indonesian": "Индонезийский",
"Irish": "Ирландский",
"Italian": "Итальянский",
"Japanese": "Японский",
"Javanese": "Яванский",
"Kannada": "Каннада",
"Kazakh": "Казахский",
"Khmer": "Кхмерский",
"Korean": "Корейский",
"Kurdish": "Курдский",
"Kyrgyz": "Киргизский",
"Lao": "Лаосский",
"Latin": "Латинский",
"Latvian": "Латышский",
"Lithuanian": "Литовский",
"Luxembourgish": "Люксембургский",
"Macedonian": "Македонский",
"Malagasy": "Малагасийский",
"Malay": "Малайский",
"Malayalam": "Малаялам",
"Maltese": "Мальтийский",
"Maori": "Маори",
"Marathi": "Маратхи",
"Mongolian": "Монгольская",
"Nepali": "Непальский",
"Norwegian": "Норвежский",
"Nyanja": "Ньянджа",
"Pashto": "Пушту",
"Persian": "Персидский",
"Polish": "Польский",
"Portuguese": "Португальский",
"Punjabi": "Панджаби",
"Romanian": "Румынский",
"Russian": "Русский",
"Samoan": "Самоанский",
"Scottish Gaelic": "Шотландский (гэльский)",
"Serbian": "Сербский",
"Shona": "Шона",
"Sindhi": "Синдхи",
"Sinhala": "Сингальский",
"Slovak": "Словацкий",
"Slovenian": "Словенский",
"Somali": "Сомалийский",
"Southern Sotho": "Сесото (южный сото)",
"Spanish": "Испанский",
"Spanish (Latin America)": "Испанский (Латинская Америка)",
"Sundanese": "Сунданский",
"Swahili": "Суахили",
"Swedish": "Шведский",
"Tajik": "Таджикский",
"Tamil": "Тамильский",
"Telugu": "Телугу",
"Thai": "Тайский",
"Turkish": "Турецкий",
"Ukrainian": "Украинский",
"Urdu": "Урду",
"Uzbek": "Узбекский",
"Vietnamese": "Вьетнамский",
"Welsh": "Валлийский",
"Western Frisian": "Западнофризский",
"Xhosa": "Коса",
"Yiddish": "Идиш",
"Yoruba": "Йоруба",
"Zulu": "Зулусский",
"`x` years": "`x` лет",
"`x` months": "`x` месяцев",
"`x` weeks": "`x` недель",
"`x` days": "`x` дней",
"`x` hours": "`x` часов",
"`x` minutes": "`x` минут",
"`x` seconds": "`x` секунд",
"Fallback comments: ": "Резервные комментарии: ",
"Popular": "Популярное",
"Top": "Топ",
"About": "О сайте",
"Rating: ": "Рейтинг: ",
"Language: ": "Язык: ",
"Default": "По-умолчанию",
"Music": "Музыка",
"Gaming": "Игры",
"News": "Новости",
"Movies": "Фильмы",
"Download": "Скачать",
"Download as: ": "Скачать как: ",
"%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(изменено)",
"Youtube permalink of the comment": "Прямая ссылка на YouTube",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим",
"Video mode": "Видео режим"
"`x` subscribers": "`x` подписчиков",
"`x` videos": "`x` видео",
"LIVE": "ПРЯМОЙ ЭФИР",
"Shared `x` ago": "Опубликовано `x` назад",
"Unsubscribe": "Отписаться",
"Subscribe": "Подписаться",
"Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
"View channel on YouTube": "Канал на YouTube",
"newest": "новые",
"oldest": "старые",
"popular": "популярные",
"last": "недавно обновленные",
"Next page": "Следующая страница",
"Previous page": "Предыдущая страница",
"Clear watch history?": "Очистить историю просмотров?",
"Yes": "Да",
"No": "Нет",
"Import and Export Data": "Импорт и экспорт данных",
"Import": "Импорт",
"Import Invidious data": "Импортировать данные Invidious",
"Import YouTube subscriptions": "Импортировать YouTube подписки",
"Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
"Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
"Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
"Export": "Экспорт",
"Export subscriptions as OPML": "Экспортировать подписки в OPML",
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
"Export data as JSON": "Экспортировать данные в JSON",
"Delete account?": "Удалить аккаунт?",
"History": "История",
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
"JavaScript license information": "Лицензии JavaScript",
"source": "источник",
"Login": "Войти",
"Login/Register": "Войти/Регистрация",
"Login to Google": "Войти через Google",
"User ID:": "ID пользователя:",
"Password:": "Пароль:",
"Time (h:mm:ss):": "Время (ч:мм:сс):",
"Text CAPTCHA": "Текст капчи",
"Image CAPTCHA": "Изображение капчи",
"Sign In": "Войти",
"Register": "Регистрация",
"Email:": "Эл. почта:",
"Google verification code:": "Код подтверждения Google:",
"Preferences": "Настройки",
"Player preferences": "Настройки проигрывателя",
"Always loop: ": "Всегда повторять: ",
"Autoplay: ": "Автовоспроизведение: ",
"Autoplay next video: ": "Автовоспроизведение следующего видео: ",
"Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
"Proxy videos? ": "Проксировать видео? ",
"Default speed: ": "Скорость по-умолчанию: ",
"Preferred video quality: ": "Предпочтительное качество видео: ",
"Player volume: ": "Громкость воспроизведения: ",
"Default comments: ": "Источник комментариев: ",
"youtube": "YouTube",
"reddit": "Reddit",
"Default captions: ": "Субтитры по-умолчанию: ",
"Fallback captions: ": "Резервные субтитры: ",
"Show related videos? ": "Показывать похожие видео? ",
"Visual preferences": "Визуальные настройки",
"Dark mode: ": "Темная тема: ",
"Thin mode: ": "Облегченный режим: ",
"Subscription preferences": "Настройки подписок",
"Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
"Number of videos shown in feed: ": "Число видео в ленте: ",
"Sort videos by: ": "Сортировать видео по: ",
"published": "дате публикации",
"published - reverse": "дате - обратный порядок",
"alphabetically": "алфавиту",
"alphabetically - reverse": "алфавиту - обратный порядок",
"channel name": "имени канала",
"channel name - reverse": "имени канала - обратный порядок",
"Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
"Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
"Only show unwatched: ": "Отображать только непросмотренные видео: ",
"Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
"Data preferences": "Настройки данных",
"Clear watch history": "Очистить историю просмотра",
"Import/Export data": "Импорт/Экспорт данных",
"Manage subscriptions": "Управление подписками",
"Watch history": "История просмотров",
"Delete account": "Удалить аккаунт",
"Administrator preferences": "Настройки администратора",
"Default homepage: ": "Главная страница по умолчанию: ",
"Feed menu: ": "Меню ленты: ",
"Top enabled? ": "Включить ТОП? ",
"CAPTCHA enabled? ": "Включить капчу? ",
"Login enabled? ": "Включить логин? ",
"Registration enabled? ": "Включить регистрацию? ",
"Report statistics? ": "Отображать статистику? ",
"Save preferences": "Сохранить настройки",
"Subscription manager": "Менеджер подписок",
"`x` subscriptions": "`x` подписок",
"Import/Export": "Импорт/Экспорт",
"unsubscribe": "отписаться",
"Subscriptions": "Подписки",
"`x` unseen notifications": "`x` новых оповещений",
"search": "поиск",
"Sign out": "Выйти",
"Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
"Source available here.": "Исходный код доступен здесь.",
"View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
"View privacy policy.": "См. политику конфиденциальности.",
"Trending": "В тренде",
"Watch video on Youtube": "Смотреть на YouTube",
"Genre: ": "Жанр: ",
"License: ": "Лицензия: ",
"Family friendly? ": "Семейный просмотр: ",
"Wilson score: ": "Рейтинг Вильсона: ",
"Engagement: ": "Вовлеченность: ",
"Whitelisted regions: ": "Доступно для: ",
"Blacklisted regions: ": "Недоступно для: ",
"Shared `x`": "Опубликовано `x`",
"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 YouTube comments": "Смотреть комментарии с YouTube",
"View more comments on Reddit": "Больше комментариев на Reddit",
"View `x` comments": "Показать `x` комментариев",
"View Reddit comments": "Смотреть комментарии с Reddit",
"Hide replies": "Скрыть ответы",
"Show replies": "Показать ответы",
"Incorrect password": "Неправильный пароль",
"Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
"Invalid TFA code": "Неправильный TFA код",
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
"Invalid answer": "Неверный ответ",
"Invalid CAPTCHA": "Неверная капча",
"CAPTCHA is a required field": "Необходимо ввести капчу",
"User ID is a required field": "Необходимо ввести идентификатор пользователя",
"Password is a required field": "Необходимо ввести пароль",
"Invalid username or password": "Недопустимый пароль или имя пользователя",
"Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
"Password cannot be empty": "Пароль не может быть пустым",
"Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
"Please sign in": "Пожалуйста, войдите",
"Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
"channel:`x`": "канал: `x`",
"Deleted or invalid channel": "Канал удален или не найден",
"This channel does not exist.": "Такой канал не существует.",
"Could not get channel info.": "Невозможно получить информацию о канале.",
"Could not fetch comments": "Невозможно получить комментарии",
"View `x` replies": "Показать `x` ответов",
"`x` ago": "`x` назад",
"Load more": "Загрузить больше",
"`x` points": "`x` очков",
"Could not create mix.": "Невозможно создать \"микс\".",
"Playlist is empty": "Плейлист пуст",
"Invalid playlist.": "Некорректный плейлист.",
"Playlist does not exist.": "Плейлист не существует.",
"Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
"Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
"Invalid challenge": "Неправильный ответ в \"challenge\"",
"Invalid token": "Неправильный токен",
"Invalid user": "Недопустимое имя пользователя",
"Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
"English": "Английский",
"English (auto-generated)": "Английский (созданы автоматически)",
"Afrikaans": "Африкаанс",
"Albanian": "Албанский",
"Amharic": "Амхарский",
"Arabic": "Арабский",
"Armenian": "Армянский",
"Azerbaijani": "Азербайджанский",
"Bangla": "Бенгальский",
"Basque": "Баскский",
"Belarusian": "Белорусский",
"Bosnian": "Боснийский",
"Bulgarian": "Болгарский",
"Burmese": "Бирманский",
"Catalan": "Каталонский",
"Cebuano": "Себуанский",
"Chinese (Simplified)": "Китайский (упрощенный)",
"Chinese (Traditional)": "Китайский (традиционный)",
"Corsican": "Корсиканский",
"Croatian": "Хорватский",
"Czech": "Чешский",
"Danish": "Датский",
"Dutch": "Нидерландский",
"Esperanto": "Эсперанто",
"Estonian": "Эстонский",
"Filipino": "Филиппинский",
"Finnish": "Финский",
"French": "Французский",
"Galician": "Галисийский",
"Georgian": "Грузинский",
"German": "Немецкий",
"Greek": "Греческий",
"Gujarati": "Гуджаратский",
"Haitian Creole": "Гаит. креольский",
"Hausa": "Хауса",
"Hawaiian": "Гавайский",
"Hebrew": "Иврит",
"Hindi": "Хинди",
"Hmong": "Хмонг (мяо)",
"Hungarian": "Венгерский",
"Icelandic": "Исландский",
"Igbo": "Игбо",
"Indonesian": "Индонезийский",
"Irish": "Ирландский",
"Italian": "Итальянский",
"Japanese": "Японский",
"Javanese": "Яванский",
"Kannada": "Каннада",
"Kazakh": "Казахский",
"Khmer": "Кхмерский",
"Korean": "Корейский",
"Kurdish": "Курдский",
"Kyrgyz": "Киргизский",
"Lao": "Лаосский",
"Latin": "Латинский",
"Latvian": "Латышский",
"Lithuanian": "Литовский",
"Luxembourgish": "Люксембургский",
"Macedonian": "Македонский",
"Malagasy": "Малагасийский",
"Malay": "Малайский",
"Malayalam": "Малаялам",
"Maltese": "Мальтийский",
"Maori": "Маори",
"Marathi": "Маратхи",
"Mongolian": "Монгольская",
"Nepali": "Непальский",
"Norwegian": "Норвежский",
"Nyanja": "Ньянджа",
"Pashto": "Пушту",
"Persian": "Персидский",
"Polish": "Польский",
"Portuguese": "Португальский",
"Punjabi": "Панджаби",
"Romanian": "Румынский",
"Russian": "Русский",
"Samoan": "Самоанский",
"Scottish Gaelic": "Шотландский (гэльский)",
"Serbian": "Сербский",
"Shona": "Шона",
"Sindhi": "Синдхи",
"Sinhala": "Сингальский",
"Slovak": "Словацкий",
"Slovenian": "Словенский",
"Somali": "Сомалийский",
"Southern Sotho": "Сесото (южный сото)",
"Spanish": "Испанский",
"Spanish (Latin America)": "Испанский (Латинская Америка)",
"Sundanese": "Сунданский",
"Swahili": "Суахили",
"Swedish": "Шведский",
"Tajik": "Таджикский",
"Tamil": "Тамильский",
"Telugu": "Телугу",
"Thai": "Тайский",
"Turkish": "Турецкий",
"Ukrainian": "Украинский",
"Urdu": "Урду",
"Uzbek": "Узбекский",
"Vietnamese": "Вьетнамский",
"Welsh": "Валлийский",
"Western Frisian": "Западнофризский",
"Xhosa": "Коса",
"Yiddish": "Идиш",
"Yoruba": "Йоруба",
"Zulu": "Зулусский",
"`x` years": "`x` лет",
"`x` months": "`x` месяцев",
"`x` weeks": "`x` недель",
"`x` days": "`x` дней",
"`x` hours": "`x` часов",
"`x` minutes": "`x` минут",
"`x` seconds": "`x` секунд",
"Fallback comments: ": "Резервные комментарии: ",
"Popular": "Популярное",
"Top": "Топ",
"About": "О сайте",
"Rating: ": "Рейтинг: ",
"Language: ": "Язык: ",
"Default": "По-умолчанию",
"Music": "Музыка",
"Gaming": "Игры",
"News": "Новости",
"Movies": "Фильмы",
"Download": "Скачать",
"Download as: ": "Скачать как: ",
"%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(изменено)",
"Youtube permalink of the comment": "Прямая ссылка на YouTube",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим",
"Video mode": "Видео режим",
"Videos": "Видео",
"Playlists": "Плейлисты",
"Current version: ": "Текущая версия: "
}

View File

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

View File

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

View File

@ -10,13 +10,15 @@ end
class ChannelVideo
add_mapping({
id: String,
title: String,
published: Time,
updated: Time,
ucid: String,
author: String,
length_seconds: {type: Int32, default: 0},
id: String,
title: String,
published: Time,
updated: Time,
ucid: String,
author: String,
length_seconds: {type: Int32, default: 0},
live_now: {type: Bool, default: false},
premiere_timestamp: {type: Time?, default: nil},
})
end
@ -112,15 +114,32 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
rss.xpath_nodes("//feed/entry").each do |entry|
video_id = entry.xpath_node("videoid").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)
updated = Time.parse(entry.xpath_node("updated").not_nil!.content, "%FT%X%z", Time::Location.local)
published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
author = entry.xpath_node("author/name").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
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 \
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
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}) \
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
else
page = 1
@ -157,7 +179,17 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
end
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|
ids << video.id
@ -170,8 +202,12 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
video_array = video.to_a
args = arg_array(video_array)
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", 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}) \
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
@ -194,12 +230,14 @@ end
def subscribe_pubsub(ucid, key, config)
client = make_client(PUBSUB_URL)
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 = {
"hub.callback" => "#{host_url}/feed/webhook/#{time}:#{OpenSSL::HMAC.hexdigest(:sha1, key, time)}",
"hub.topic" => "https://www.youtube.com/feeds/videos.xml?channel_id=#{ucid}",
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?channel_id=#{ucid}",
"hub.verify" => "async",
"hub.mode" => "subscribe",
"hub.lease_seconds" => "432000",

View File

@ -308,13 +308,13 @@ def template_youtube_comments(comments, locale)
<p>
<b>
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b>
</b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), 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>
|
<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
if child["creatorHeart"]?
@ -372,8 +372,8 @@ def template_reddit_comments(root, locale)
content = <<-END_HTML
<p>
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
#{translate(locale, "`x` points", number_with_separator(score))}
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
</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
YAML.mapping({
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
feed_threads: Int32, # Number of threads to use for updating feeds
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
use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
default_home: {type: String, default: "Top"},
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending"]},
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
top_enabled: {type: Bool, default: true},
captcha_enabled: {type: Bool, default: true},
login_enabled: {type: Bool, default: true},
registration_enabled: {type: Bool, default: true},
statistics_enabled: {type: Bool, default: false},
admins: {type: Array(String), default: [] of String},
external_port: {type: Int32 | Nil, default: nil},
})
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)
top = [] of {Float64, String}
@ -223,13 +175,22 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
)
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(
title,
plid,
author,
author_id,
video_count,
videos
videos,
thumbnail_id
)
when .includes? "yt-lockup-channel"
author = title.strip
@ -307,6 +268,11 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
paid = true
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(
title: title,
id: id,
@ -319,7 +285,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
length_seconds: length_seconds,
live_now: live_now,
paid: paid,
premium: premium
premium: premium,
premiere_timestamp: premiere_timestamp
)
end
end
@ -390,13 +357,28 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
playlist_title ||= ""
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(
playlist_title,
plid,
author_name,
ucid,
50,
Array(SearchPlaylistVideo).new
video_count,
Array(SearchPlaylistVideo).new,
thumbnail_id
)
end
end
@ -410,7 +392,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
author_name,
ucid,
videos.size,
videos
videos,
videos[0].try &.id
)
end
end

View File

@ -162,6 +162,23 @@ def number_with_separator(number)
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
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)
seperated = number_with_separator(number).gsub(",", ".").split("")
text = seperated.first(2).join
@ -193,19 +210,30 @@ def arg_array(array, start = 1)
return args
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
scheme = "https://"
else
scheme = "http://"
end
if host
host = host.lchop(".")
return "#{scheme}#{host}"
# Add if non-standard port
if port != 80 && port != 443
port = ":#{kemal_config.port}"
else
port = ""
end
if !config.domain
return ""
end
host = config.domain.not_nil!.lchop(".")
return "#{scheme}#{host}#{port}"
end
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)
max_channel = Channel(Int32).new
@ -82,30 +34,14 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
end
end
end
sleep 1.minute
end
end
max_channel.send(max_threads)
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)
max_channel = Channel(Int32).new
@ -129,15 +65,26 @@ def refresh_feeds(db, logger, max_threads = 1)
active_threads += 1
spawn do
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}")
rescue ex
# Create view if it doesn't exist
if ex.message.try &.ends_with? "does not exist"
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
logger.write("CREATE #{view_name}")
if ex.message.try &.ends_with?("does not exist")
# While iterating through, we may have an email stored from a deleted account
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
logger.write("CREATE #{view_name}\n")
end
else
logger.write("REFRESH #{email} : #{ex.message}\n")
end
@ -147,6 +94,8 @@ def refresh_feeds(db, logger, max_threads = 1)
end
end
end
sleep 1.minute
end
end
@ -158,16 +107,17 @@ def subscribe_to_feeds(db, logger, key, config)
spawn do
loop do
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs|
ucid = rs.read(String)
response = subscribe_pubsub(ucid, key, config)
rs.each do
ucid = rs.read(String)
response = subscribe_pubsub(ucid, key, config)
if response.status_code >= 400
logger.write("#{ucid} : #{response.body}\n")
if response.status_code >= 400
logger.write("#{ucid} : #{response.body}\n")
end
end
end
sleep 1.minute
Fiber.yield
end
end
end
@ -198,7 +148,7 @@ def pull_top_videos(config, db)
end
yield videos
Fiber.yield
sleep 1.minute
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
yield videos
Fiber.yield
sleep 1.minute
end
end
@ -226,6 +176,7 @@ def update_decrypt_function
end
yield decrypt_function
sleep 1.minute
end
end
@ -237,7 +188,8 @@ def find_working_proxies(regions)
# proxies = filter_proxies(proxies)
yield region, proxies
Fiber.yield
end
sleep 1.minute
end
end

View File

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

View File

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

View File

@ -31,6 +31,7 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
"video_loop" => false,
"autoplay" => false,
"continue" => false,
"local" => false,
"listen" => false,
"speed" => 1.0,
"quality" => "hd720",
@ -80,6 +81,10 @@ class Preferences
type: Bool,
default: DEFAULT_USER_PREFERENCES.continue,
},
local: {
type: Bool,
default: DEFAULT_USER_PREFERENCES.local,
},
listen: {
type: Bool,
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 = Base64.urlsafe_encode(challenge)
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)
db.exec("DELETE FROM nonces * WHERE nonce = $1", nonce)
if nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", nonce, as: {String, Time})
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
raise translate(locale, "Invalid token")
end
@ -265,7 +274,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
end
if challenge_user_id != user_id
raise translate(locale, "Invalid user")
raise translate(locale, "Invalid token")
end
if expire < Time.now.to_unix
@ -291,7 +300,7 @@ def generate_captcha(key, db)
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px">
<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="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>
@ -323,7 +332,22 @@ def generate_captcha(key, db)
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
challenge, token = create_response(answer, "sign_in", key, db)
return {image: image, challenge: challenge, token: token}
return {
question: image,
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

View File

@ -136,18 +136,6 @@ BYPASS_REGIONS = {
"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
VIDEO_FORMATS = {
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
@ -262,6 +250,63 @@ class Video
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
keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
keywords ||= [] of String
@ -329,6 +374,7 @@ class Video
end
streams.each do |fmt|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function)
end
@ -396,6 +442,7 @@ class Video
end
adaptive_fmts.each do |fmt|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function)
end
@ -654,6 +701,10 @@ def fetch_video(id, proxies, region)
raise "Video unavailable."
end
if !info["title"]?
raise "Video unavailable."
end
title = info["title"]
author = info["author"]
ucid = info["ucid"]
@ -743,11 +794,12 @@ end
def process_video_params(query, preferences)
autoplay = query["autoplay"]?.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
local = query["local"]? && (query["local"] == "true").to_unsafe
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
region = query["region"]?
related_videos = query["related_videos"]?
speed = query["speed"]?.try &.to_f?
video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i?
@ -756,10 +808,11 @@ def process_video_params(query, preferences)
# region ||= preferences.region
autoplay ||= preferences.autoplay.to_unsafe
continue ||= preferences.continue.to_unsafe
related_videos ||= preferences.related_videos.to_unsafe
listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe
preferred_captions ||= preferences.captions
quality ||= preferences.quality
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
volume ||= preferences.volume
@ -767,18 +820,20 @@ def process_video_params(query, preferences)
autoplay ||= DEFAULT_USER_PREFERENCES.autoplay.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
local ||= DEFAULT_USER_PREFERENCES.local.to_unsafe
preferred_captions ||= DEFAULT_USER_PREFERENCES.captions
quality ||= DEFAULT_USER_PREFERENCES.quality
related_videos ||= DEFAULT_USER_PREFERENCES.related_videos.to_unsafe
speed ||= DEFAULT_USER_PREFERENCES.speed
video_loop ||= DEFAULT_USER_PREFERENCES.video_loop.to_unsafe
volume ||= DEFAULT_USER_PREFERENCES.volume
autoplay = autoplay == 1
continue = continue == 1
related_videos = related_videos == 1
listen = listen == 1
local = local == 1
related_videos = related_videos == 1
video_loop = video_loop == 1
if query["t"]?
@ -811,6 +866,7 @@ def process_video_params(query, preferences)
continue: continue,
controls: controls,
listen: listen,
local: local,
preferred_captions: preferred_captions,
quality: quality,
raw: raw,
@ -826,12 +882,26 @@ def process_video_params(query, preferences)
return params
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
VIDEO_THUMBNAILS.each do |thumbnail|
build_thumbnails(id, config, kemal_config).each do |thumbnail|
json.object do
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 "height", thumbnail[:height]
end