diff --git a/CHANGELOG.md b/CHANGELOG.md index 46de1263a..6030f01f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 20f509aae..d8aaef47c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/config/config.yml b/config/config.yml index c6f8420ad..e83a75151 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,5 +1,3 @@ -video_threads: 0 -crawl_threads: 0 channel_threads: 1 feed_threads: 1 db: diff --git a/locales/ar.json b/locales/ar.json index e8b59c46b..7331b0220 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -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: ": "الإصدار الحالى" } diff --git a/locales/de.json b/locales/de.json index e40c0b31a..89bc09ea8 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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: ": "" } diff --git a/locales/en-US.json b/locales/en-US.json index 1848ff208..68204a04c 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -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: " } diff --git a/locales/eu.json b/locales/eu.json index 8f887f715..b71a163e0 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -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: ": "" } diff --git a/locales/fr.json b/locales/fr.json index f5c34a6e0..6ce605754 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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 :" } diff --git a/locales/it.json b/locales/it.json index eeae6ed31..6fae1259c 100644 --- a/locales/it.json +++ b/locales/it.json @@ -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: ": "" } diff --git a/locales/nb_NO.json b/locales/nb_NO.json index d299da2f3..92d43ca0f 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -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: " } diff --git a/locales/nl.json b/locales/nl.json index 2224d326b..3bfe0ac48 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -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: ": "" } diff --git a/locales/pl.json b/locales/pl.json index d9a21cf1e..44db3d658 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -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: " } diff --git a/locales/ru.json b/locales/ru.json index a840a869b..c0d313139 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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: ": "Текущая версия: " } diff --git a/shard.yml b/shard.yml index 7edab3545..2e6bab918 100644 --- a/shard.yml +++ b/shard.yml @@ -1,8 +1,8 @@ name: invidious -version: 0.14.1 +version: 0.15.0 authors: - - Omar Roth + - Omar Roth targets: invidious: diff --git a/src/invidious.cr b/src/invidious.cr index 8ef056234..23248c6a9 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -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>/) 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 diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index bb5480453..126cc2b8d 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -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", diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index c714dd565..98a497a6b 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -308,13 +308,13 @@ def template_youtube_comments(comments, locale)

#{child["author"]} - +

#{child["contentHtml"]}

#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} | [YT] - | - #{number_with_separator(child["likeCount"])} + | + #{number_with_separator(child["likeCount"])} END_HTML if child["creatorHeart"]? @@ -372,8 +372,8 @@ def template_reddit_comments(root, locale) content = <<-END_HTML

- [ - ] - #{author} + [ - ] + #{author} #{translate(locale, "`x` points", number_with_separator(score))} #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}

diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr new file mode 100644 index 000000000..9d5ebd239 --- /dev/null +++ b/src/invidious/helpers/handlers.cr @@ -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 diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 3574e5cc0..10ea7dcb6 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -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\/(?[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\/(?[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 diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 5ccc10095..eb8fa80a7 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -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 = "/") diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index 49745abad..54749f878 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -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 diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 9f844ce6a..883086867 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -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 diff --git a/src/invidious/search.cr b/src/invidious/search.cr index ec97cf852..6805f1198 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -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 diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 42468228c..747b72d8e 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -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 - + 1 2 3 @@ -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 diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 1e4679fc8..e9b190dd5 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -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