mirror of
https://github.com/iv-org/invidious.git
synced 2025-07-31 01:38:31 +00:00
Merge branch 'master' into api-only
This commit is contained in:
commit
705d307b60
62
CHANGELOG.md
62
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)
|
# 0.14.0 (2019-02-06)
|
||||||
|
|
||||||
## Version 0.14.0: Community
|
## Version 0.14.0: Community
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
## Invidious is an alternative front-end to YouTube
|
## Invidious is an alternative front-end to YouTube
|
||||||
|
|
||||||
- Audio-only mode (and no need to keep window open on mobile)
|
- Audio-only mode (and no need to keep window open on mobile)
|
||||||
- [Open-source](https://github.com/omarroth/invidious) (AGPLv3 licensed)
|
- [Free software](https://github.com/omarroth/invidious) (AGPLv3 licensed)
|
||||||
- No ads
|
- No ads
|
||||||
- No need to create a Google account to save subscriptions
|
- No need to create a Google account to save subscriptions
|
||||||
- Lightweight (homepage is ~4 KB compressed)
|
- Lightweight (homepage is ~4 KB compressed)
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
video_threads: 0
|
|
||||||
crawl_threads: 0
|
|
||||||
channel_threads: 1
|
channel_threads: 1
|
||||||
feed_threads: 1
|
feed_threads: 1
|
||||||
db:
|
db:
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "الأجدد",
|
"newest": "الأجدد",
|
||||||
"oldest": "الأقدم",
|
"oldest": "الأقدم",
|
||||||
"popular": "الاكثر شعبية",
|
"popular": "الاكثر شعبية",
|
||||||
"Preview page": "معاينة الصفحة",
|
"last": "",
|
||||||
"Next page": "الصفحة الثانية",
|
"Next page": "الصفحة الثانية",
|
||||||
|
"Previous page": "الصفحة السابقة",
|
||||||
"Clear watch history?": "مسح السجل ؟",
|
"Clear watch history?": "مسح السجل ؟",
|
||||||
"Yes": "نعم",
|
"Yes": "نعم",
|
||||||
"No": "لا",
|
"No": "لا",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "استخراج البيانات كـ JSON",
|
"Export data as JSON": "استخراج البيانات كـ JSON",
|
||||||
"Delete account?": "حذف الحساب ؟",
|
"Delete account?": "حذف الحساب ؟",
|
||||||
"History": "السجل",
|
"History": "السجل",
|
||||||
"Previous page": "الصفحة السابقة",
|
|
||||||
"An alternative front-end to YouTube": "البديل الكامل لموقع يوتيوب",
|
"An alternative front-end to YouTube": "البديل الكامل لموقع يوتيوب",
|
||||||
"JavaScript license information": "معلومات ترخيص JavaScript",
|
"JavaScript license information": "معلومات ترخيص JavaScript",
|
||||||
"source": "المصدر",
|
"source": "المصدر",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "تشغيل تلقائى: ",
|
"Autoplay: ": "تشغيل تلقائى: ",
|
||||||
"Autoplay next video: ": "شغل الفيديو التالى تلقائى: ",
|
"Autoplay next video: ": "شغل الفيديو التالى تلقائى: ",
|
||||||
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
|
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "السرعة الإفتراضية: ",
|
"Default speed: ": "السرعة الإفتراضية: ",
|
||||||
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
|
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
|
||||||
"Player volume: ": "صوت المشغل: ",
|
"Player volume: ": "صوت المشغل: ",
|
||||||
@ -101,11 +102,8 @@
|
|||||||
"Sign out": "تسجيل الخروج",
|
"Sign out": "تسجيل الخروج",
|
||||||
"Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.",
|
"Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.",
|
||||||
"Source available here.": "الأكواد متوفرة هنا.",
|
"Source available here.": "الأكواد متوفرة هنا.",
|
||||||
"Liberapay: ": "ليبرباى: ",
|
|
||||||
"Patreon: ": "باتريون: ",
|
|
||||||
"BTC: ": "بيتكوين: ",
|
|
||||||
"BCH: ": "بيتكوين كاش: ",
|
|
||||||
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
|
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "الشائع",
|
"Trending": "الشائع",
|
||||||
"Watch video on Youtube": "مشاهدة الفيديو على اليوتيوب",
|
"Watch video on Youtube": "مشاهدة الفيديو على اليوتيوب",
|
||||||
"Genre: ": "النوع: ",
|
"Genre: ": "النوع: ",
|
||||||
@ -286,9 +284,12 @@
|
|||||||
"Download as: ": "تحميل كـ",
|
"Download as: ": "تحميل كـ",
|
||||||
"Download": "تحميل",
|
"Download": "تحميل",
|
||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "",
|
"(edited)": "(تم تعديلة)",
|
||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "رابط التعليق على اليوتيوب",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "'x' اعجب بهذا",
|
||||||
"Audio mode": "",
|
"Audio mode": "الوضع الصوتى",
|
||||||
"Video mode": ""
|
"Video mode": "وضع الفيديو",
|
||||||
|
"Videos": "الفيديوهات",
|
||||||
|
"Playlists": "قوائم التشغيل",
|
||||||
|
"Current version: ": "الإصدار الحالى"
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "neueste",
|
"newest": "neueste",
|
||||||
"oldest": "älteste",
|
"oldest": "älteste",
|
||||||
"popular": "beliebt",
|
"popular": "beliebt",
|
||||||
"Preview page": "Vorschau Seite",
|
"last": "",
|
||||||
"Next page": "Nächste Seite",
|
"Next page": "Nächste Seite",
|
||||||
|
"Previous page": "Vorherige Seite",
|
||||||
"Clear watch history?": "Verlauf löschen?",
|
"Clear watch history?": "Verlauf löschen?",
|
||||||
"Yes": "Ja",
|
"Yes": "Ja",
|
||||||
"No": "Nein",
|
"No": "Nein",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Daten als JSON exportieren",
|
"Export data as JSON": "Daten als JSON exportieren",
|
||||||
"Delete account?": "Account löschen?",
|
"Delete account?": "Account löschen?",
|
||||||
"History": "Verlauf",
|
"History": "Verlauf",
|
||||||
"Previous page": "Vorherige Seite",
|
|
||||||
"An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube",
|
"An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube",
|
||||||
"JavaScript license information": "JavaScript Lizenzinformationen",
|
"JavaScript license information": "JavaScript Lizenzinformationen",
|
||||||
"source": "Quelle",
|
"source": "Quelle",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "Automatisch abspielen: ",
|
"Autoplay: ": "Automatisch abspielen: ",
|
||||||
"Autoplay next video: ": "nächstes Video automatisch abspielen: ",
|
"Autoplay next video: ": "nächstes Video automatisch abspielen: ",
|
||||||
"Listen by default: ": "Nur Ton als Standard: ",
|
"Listen by default: ": "Nur Ton als Standard: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "Standardgeschwindigkeit: ",
|
"Default speed: ": "Standardgeschwindigkeit: ",
|
||||||
"Preferred video quality: ": "Bevorzugte Videoqualität: ",
|
"Preferred video quality: ": "Bevorzugte Videoqualität: ",
|
||||||
"Player volume: ": "Playerlautstärke: ",
|
"Player volume: ": "Playerlautstärke: ",
|
||||||
@ -101,11 +102,8 @@
|
|||||||
"Sign out": "Abmelden",
|
"Sign out": "Abmelden",
|
||||||
"Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.",
|
||||||
"Source available here.": "Quellcode verfügbar hier.",
|
"Source available here.": "Quellcode verfügbar hier.",
|
||||||
"Liberapay: ": "Liberapay: ",
|
|
||||||
"Patreon: ": "Patreon: ",
|
|
||||||
"BTC: ": "BTC: ",
|
|
||||||
"BCH: ": "BCH: ",
|
|
||||||
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
|
"View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "Trending",
|
"Trending": "Trending",
|
||||||
"Watch video on Youtube": "Video auf YouTube ansehen",
|
"Watch video on Youtube": "Video auf YouTube ansehen",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
@ -290,5 +288,8 @@
|
|||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "",
|
||||||
"Audio mode": "",
|
"Audio mode": "",
|
||||||
"Video mode": ""
|
"Video mode": "",
|
||||||
|
"Videos": "",
|
||||||
|
"Playlists": "",
|
||||||
|
"Current version: ": ""
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "newest",
|
"newest": "newest",
|
||||||
"oldest": "oldest",
|
"oldest": "oldest",
|
||||||
"popular": "popular",
|
"popular": "popular",
|
||||||
"Preview page": "Preview page",
|
"last": "last",
|
||||||
"Next page": "Next page",
|
"Next page": "Next page",
|
||||||
|
"Previous page": "Previous page",
|
||||||
"Clear watch history?": "Clear watch history?",
|
"Clear watch history?": "Clear watch history?",
|
||||||
"Yes": "Yes",
|
"Yes": "Yes",
|
||||||
"No": "No",
|
"No": "No",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Export data as JSON",
|
"Export data as JSON": "Export data as JSON",
|
||||||
"Delete account?": "Delete account?",
|
"Delete account?": "Delete account?",
|
||||||
"History": "History",
|
"History": "History",
|
||||||
"Previous page": "Previous page",
|
|
||||||
"An alternative front-end to YouTube": "An alternative front-end to YouTube",
|
"An alternative front-end to YouTube": "An alternative front-end to YouTube",
|
||||||
"JavaScript license information": "JavaScript license information",
|
"JavaScript license information": "JavaScript license information",
|
||||||
"source": "source",
|
"source": "source",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "Autoplay: ",
|
"Autoplay: ": "Autoplay: ",
|
||||||
"Autoplay next video: ": "Autoplay next video: ",
|
"Autoplay next video: ": "Autoplay next video: ",
|
||||||
"Listen by default: ": "Listen by default: ",
|
"Listen by default: ": "Listen by default: ",
|
||||||
|
"Proxy videos? ": "Proxy videos? ",
|
||||||
"Default speed: ": "Default speed: ",
|
"Default speed: ": "Default speed: ",
|
||||||
"Preferred video quality: ": "Preferred video quality: ",
|
"Preferred video quality: ": "Preferred video quality: ",
|
||||||
"Player volume: ": "Player volume: ",
|
"Player volume: ": "Player volume: ",
|
||||||
@ -100,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.",
|
||||||
"Source available here.": "Source available here.",
|
"Source available here.": "Source available here.",
|
||||||
"View JavaScript license information.": "View JavaScript license information.",
|
"View JavaScript license information.": "View JavaScript license information.",
|
||||||
|
"View privacy policy.": "View privacy policy.",
|
||||||
"Trending": "Trending",
|
"Trending": "Trending",
|
||||||
"Watch video on Youtube": "Watch video on Youtube",
|
"Watch video on Youtube": "Watch video on Youtube",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
@ -284,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "Youtube permalink of the comment",
|
"Youtube permalink of the comment": "Youtube permalink of the comment",
|
||||||
"`x` marked it with a ❤": "`x` marked it with a ❤",
|
"`x` marked it with a ❤": "`x` marked it with a ❤",
|
||||||
"Audio mode": "Audio mode",
|
"Audio mode": "Audio mode",
|
||||||
"Video mode": "Video mode"
|
"Video mode": "Video mode",
|
||||||
|
"Videos": "Videos",
|
||||||
|
"Playlists": "Playlists",
|
||||||
|
"Current version: ": "Current version: "
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "berrienak",
|
"newest": "berrienak",
|
||||||
"oldest": "zaharrenak",
|
"oldest": "zaharrenak",
|
||||||
"popular": "ospetsuenak",
|
"popular": "ospetsuenak",
|
||||||
"Preview page": "Aurrebista orria",
|
"last": "",
|
||||||
"Next page": "Hurrengo orria",
|
"Next page": "Hurrengo orria",
|
||||||
|
"Previous page": "Aurreko orria",
|
||||||
"Clear watch history?": "Garbitu ikusitakoen historia?",
|
"Clear watch history?": "Garbitu ikusitakoen historia?",
|
||||||
"Yes": "Bai",
|
"Yes": "Bai",
|
||||||
"No": "Ez",
|
"No": "Ez",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Datuak JSON bezala esportatu",
|
"Export data as JSON": "Datuak JSON bezala esportatu",
|
||||||
"Delete account?": "Kontua ezabatu?",
|
"Delete account?": "Kontua ezabatu?",
|
||||||
"History": "Historia",
|
"History": "Historia",
|
||||||
"Previous page": "Aurreko orria",
|
|
||||||
"An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
|
"An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat",
|
||||||
"JavaScript license information": "JavaScript lizentzia informazioa",
|
"JavaScript license information": "JavaScript lizentzia informazioa",
|
||||||
"source": "iturburua",
|
"source": "iturburua",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "",
|
"Autoplay: ": "",
|
||||||
"Autoplay next video: ": "",
|
"Autoplay next video: ": "",
|
||||||
"Listen by default: ": "",
|
"Listen by default: ": "",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "",
|
"Default speed: ": "",
|
||||||
"Preferred video quality: ": "",
|
"Preferred video quality: ": "",
|
||||||
"Player volume: ": "",
|
"Player volume: ": "",
|
||||||
@ -100,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "",
|
"Released under the AGPLv3 by Omar Roth.": "",
|
||||||
"Source available here.": "",
|
"Source available here.": "",
|
||||||
"View JavaScript license information.": "",
|
"View JavaScript license information.": "",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "",
|
"Trending": "",
|
||||||
"Watch video on Youtube": "",
|
"Watch video on Youtube": "",
|
||||||
"Genre: ": "",
|
"Genre: ": "",
|
||||||
@ -284,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "",
|
||||||
"Audio mode": "",
|
"Audio mode": "",
|
||||||
"Video mode": ""
|
"Video mode": "",
|
||||||
|
"Videos": "",
|
||||||
|
"Playlists": "",
|
||||||
|
"Current version: ": ""
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,9 @@
|
|||||||
"newest": "Date d'ajout (la plus récente)",
|
"newest": "Date d'ajout (la plus récente)",
|
||||||
"oldest": "Date d'ajout (la plus ancienne)",
|
"oldest": "Date d'ajout (la plus ancienne)",
|
||||||
"popular": "Les plus populaires",
|
"popular": "Les plus populaires",
|
||||||
|
"last": "Dernières",
|
||||||
"Next page": "Page suivante",
|
"Next page": "Page suivante",
|
||||||
|
"Previous page": "Page précédente",
|
||||||
"Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
|
"Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?",
|
||||||
"Yes": "Oui",
|
"Yes": "Oui",
|
||||||
"No": "Non",
|
"No": "Non",
|
||||||
@ -25,23 +27,22 @@
|
|||||||
"Export subscriptions as OPML": "Exporter les abonnements en OPML",
|
"Export subscriptions as OPML": "Exporter les abonnements en OPML",
|
||||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)",
|
||||||
"Export data as JSON": "Exporter les données au format JSON",
|
"Export data as JSON": "Exporter les données au format JSON",
|
||||||
"Delete account?": "Supprimer votre compte ?",
|
"Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?",
|
||||||
"History": "Historique",
|
"History": "Historique",
|
||||||
"Previous page": "Page précédente",
|
|
||||||
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
|
"An alternative front-end to YouTube": "Un front-end alternatif à YouTube",
|
||||||
"JavaScript license information": "Informations sur les licences JavaScript",
|
"JavaScript license information": "Informations sur les licences JavaScript",
|
||||||
"source": "source",
|
"source": "source",
|
||||||
"Login": "Connexion",
|
"Login": "Connexion",
|
||||||
"Login/Register": "Connexion/S'inscrire",
|
"Login/Register": "Connexion/S'inscrire",
|
||||||
"Login to Google": "Se connecter à Google",
|
"Login to Google": "Se connecter à Google",
|
||||||
"User ID:": "ID utilisateur :",
|
"User ID:": "Identifiant utilisateur :",
|
||||||
"Password:": "Mot de passe :",
|
"Password:": "Mot de passe :",
|
||||||
"Time (h:mm:ss):": "Heure (h:mm:ss) :",
|
"Time (h:mm:ss):": "Heure (h:mm:ss) :",
|
||||||
"Text CAPTCHA": "CAPTCHA Texte",
|
"Text CAPTCHA": "CAPTCHA Texte",
|
||||||
"Image CAPTCHA": "CAPTCHA Image",
|
"Image CAPTCHA": "CAPTCHA Image",
|
||||||
"Sign In": "S'identifier",
|
"Sign In": "S'identifier",
|
||||||
"Register": "S'inscrire",
|
"Register": "S'inscrire",
|
||||||
"Email:": "Email :",
|
"Email:": "E-mail :",
|
||||||
"Google verification code:": "Code de vérification Google :",
|
"Google verification code:": "Code de vérification Google :",
|
||||||
"Preferences": "Préférences",
|
"Preferences": "Préférences",
|
||||||
"Player preferences": "Préférences du Lecteur",
|
"Player preferences": "Préférences du Lecteur",
|
||||||
@ -49,6 +50,7 @@
|
|||||||
"Autoplay: ": "Lire Automatiquement : ",
|
"Autoplay: ": "Lire Automatiquement : ",
|
||||||
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
||||||
"Listen by default: ": "Audio Uniquement par défaut : ",
|
"Listen by default: ": "Audio Uniquement par défaut : ",
|
||||||
|
"Proxy videos? ": "Souhaitez vous charger les vidéos à travers un proxy ?",
|
||||||
"Default speed: ": "Vitesse par défaut : ",
|
"Default speed: ": "Vitesse par défaut : ",
|
||||||
"Preferred video quality: ": "Qualité vidéo souhaitée : ",
|
"Preferred video quality: ": "Qualité vidéo souhaitée : ",
|
||||||
"Player volume: ": "Volume du lecteur : ",
|
"Player volume: ": "Volume du lecteur : ",
|
||||||
@ -56,7 +58,7 @@
|
|||||||
"Default captions: ": "Sous-titres principal : ",
|
"Default captions: ": "Sous-titres principal : ",
|
||||||
"Fallback captions: ": "Sous-titres secondaire : ",
|
"Fallback captions: ": "Sous-titres secondaire : ",
|
||||||
"Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
|
"Show related videos? ": "Voir les vidéos liées à ce sujet ? ",
|
||||||
"Visual preferences": "Préférences visuelles",
|
"Visual preferences": "Préférences du site",
|
||||||
"Dark mode: ": "Mode Sombre : ",
|
"Dark mode: ": "Mode Sombre : ",
|
||||||
"Thin mode: ": "Mode Simplifié : ",
|
"Thin mode: ": "Mode Simplifié : ",
|
||||||
"Subscription preferences": "Préférences de la page d'abonnements",
|
"Subscription preferences": "Préférences de la page d'abonnements",
|
||||||
@ -79,14 +81,14 @@
|
|||||||
"Manage subscriptions": "Gérer les abonnements",
|
"Manage subscriptions": "Gérer les abonnements",
|
||||||
"Watch history": "Historique de visionnage",
|
"Watch history": "Historique de visionnage",
|
||||||
"Delete account": "Supprimer votre compte",
|
"Delete account": "Supprimer votre compte",
|
||||||
"Administrator preferences": "",
|
"Administrator preferences": "Préferences d'Administrateur",
|
||||||
"Default homepage: ": "",
|
"Default homepage: ": "Page d'accueil par defaut :",
|
||||||
"Feed menu: ": "",
|
"Feed menu: ": "Menu des Flux :",
|
||||||
"Top enabled? ": "",
|
"Top enabled? ": "Top activé ?",
|
||||||
"CAPTCHA enabled? ": "",
|
"CAPTCHA enabled? ": "CAPTCHA activé ?",
|
||||||
"Login enabled? ": "",
|
"Login enabled? ": "Connexion activé ?",
|
||||||
"Registration enabled? ": "",
|
"Registration enabled? ": "Inscription activé ?",
|
||||||
"Report statistics? ": "",
|
"Report statistics? ": "Telemetrie activé ?",
|
||||||
"Save preferences": "Enregistrer les préférences",
|
"Save preferences": "Enregistrer les préférences",
|
||||||
"Subscription manager": "Gestionnaire d'abonnement",
|
"Subscription manager": "Gestionnaire d'abonnement",
|
||||||
"`x` subscriptions": "`x` abonnements",
|
"`x` subscriptions": "`x` abonnements",
|
||||||
@ -99,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.",
|
||||||
"Source available here.": "Code Source.",
|
"Source available here.": "Code Source.",
|
||||||
"View JavaScript license information.": "Voir les informations des licences JavaScript.",
|
"View JavaScript license information.": "Voir les informations des licences JavaScript.",
|
||||||
|
"View privacy policy.": "Politique de confidentialité",
|
||||||
"Trending": "Tendances",
|
"Trending": "Tendances",
|
||||||
"Watch video on Youtube": "Voir la vidéo sur Youtube",
|
"Watch video on Youtube": "Voir la vidéo sur Youtube",
|
||||||
"Genre: ": "Genre : ",
|
"Genre: ": "Genre : ",
|
||||||
@ -283,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
|
"Youtube permalink of the comment": "Lien YouTube permanent vers le commentaire",
|
||||||
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
|
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
|
||||||
"Audio mode": "Mode Audio",
|
"Audio mode": "Mode Audio",
|
||||||
"Video mode": "Mode Vidéo"
|
"Video mode": "Mode Vidéo",
|
||||||
|
"Videos": "Vidéos",
|
||||||
|
"Playlists": "Liste de lecture",
|
||||||
|
"Current version: ": "Version actuelle :"
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,9 @@
|
|||||||
"newest": "Data di aggiunta (più recente)",
|
"newest": "Data di aggiunta (più recente)",
|
||||||
"oldest": "Data di aggiunta (più vecchia)",
|
"oldest": "Data di aggiunta (più vecchia)",
|
||||||
"popular": "Tendenze",
|
"popular": "Tendenze",
|
||||||
|
"last": "",
|
||||||
"Next page": "Pagina successiva",
|
"Next page": "Pagina successiva",
|
||||||
|
"Previous page": "Pagina precedente",
|
||||||
"Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?",
|
"Clear watch history?": "Sei sicuro di voler cancellare la cronologia dei video guardati?",
|
||||||
"Yes": "Si",
|
"Yes": "Si",
|
||||||
"No": "No",
|
"No": "No",
|
||||||
@ -27,7 +29,6 @@
|
|||||||
"Export data as JSON": "Esporta i dati in formato JSON",
|
"Export data as JSON": "Esporta i dati in formato JSON",
|
||||||
"Delete account?": "Sei sicuro di voler cancellare l'account?",
|
"Delete account?": "Sei sicuro di voler cancellare l'account?",
|
||||||
"History": "Cronologia",
|
"History": "Cronologia",
|
||||||
"Previous page": "Pagina precedente",
|
|
||||||
"An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube",
|
"An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube",
|
||||||
"JavaScript license information": "Info licenze JavaScript",
|
"JavaScript license information": "Info licenze JavaScript",
|
||||||
"source": "sorgente",
|
"source": "sorgente",
|
||||||
@ -49,6 +50,7 @@
|
|||||||
"Autoplay: ": "Riproduzione automatica: ",
|
"Autoplay: ": "Riproduzione automatica: ",
|
||||||
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
|
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
|
||||||
"Listen by default: ": "Modalità solo audio come predefinita: ",
|
"Listen by default: ": "Modalità solo audio come predefinita: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "Velocità di riproduzione predefinita: ",
|
"Default speed: ": "Velocità di riproduzione predefinita: ",
|
||||||
"Preferred video quality: ": "Preferenza sulla qualità video: ",
|
"Preferred video quality: ": "Preferenza sulla qualità video: ",
|
||||||
"Player volume: ": "Volume di riproduzione: ",
|
"Player volume: ": "Volume di riproduzione: ",
|
||||||
@ -99,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.",
|
||||||
"Source available here.": "Codice sorgente.",
|
"Source available here.": "Codice sorgente.",
|
||||||
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
|
"View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "Tendenze",
|
"Trending": "Tendenze",
|
||||||
"Watch video on Youtube": "Guarda il video su YouTube",
|
"Watch video on Youtube": "Guarda il video su YouTube",
|
||||||
"Genre: ": "Genere: ",
|
"Genre: ": "Genere: ",
|
||||||
@ -283,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "Link permanente al commento di YouTube",
|
"Youtube permalink of the comment": "Link permanente al commento di YouTube",
|
||||||
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
|
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
|
||||||
"Audio mode": "Modalità audio",
|
"Audio mode": "Modalità audio",
|
||||||
"Video mode": "Modalità video"
|
"Video mode": "Modalità video",
|
||||||
|
"Videos": "",
|
||||||
|
"Playlists": "",
|
||||||
|
"Current version: ": ""
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "nyeste",
|
"newest": "nyeste",
|
||||||
"oldest": "eldste",
|
"oldest": "eldste",
|
||||||
"popular": "populært",
|
"popular": "populært",
|
||||||
"Preview page": "Forhåndsvis side",
|
"last": "siste",
|
||||||
"Next page": "Neste side",
|
"Next page": "Neste side",
|
||||||
|
"Previous page": "Forrige side",
|
||||||
"Clear watch history?": "Tøm visningshistorikk?",
|
"Clear watch history?": "Tøm visningshistorikk?",
|
||||||
"Yes": "Ja",
|
"Yes": "Ja",
|
||||||
"No": "Nei",
|
"No": "Nei",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Eksporter data som JSON",
|
"Export data as JSON": "Eksporter data som JSON",
|
||||||
"Delete account?": "Slett konto?",
|
"Delete account?": "Slett konto?",
|
||||||
"History": "Historikk",
|
"History": "Historikk",
|
||||||
"Previous page": "Forrige side",
|
|
||||||
"An alternative front-end to YouTube": "En alternativ grenseflate for YouTube",
|
"An alternative front-end to YouTube": "En alternativ grenseflate for YouTube",
|
||||||
"JavaScript license information": "JavaScript-lisensinformasjon",
|
"JavaScript license information": "JavaScript-lisensinformasjon",
|
||||||
"source": "kilde",
|
"source": "kilde",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "Autoavspilling: ",
|
"Autoplay: ": "Autoavspilling: ",
|
||||||
"Autoplay next video: ": "Autospill neste video: ",
|
"Autoplay next video: ": "Autospill neste video: ",
|
||||||
"Listen by default: ": "Lytt som forvalg: ",
|
"Listen by default: ": "Lytt som forvalg: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "Forvalgt hastighet: ",
|
"Default speed: ": "Forvalgt hastighet: ",
|
||||||
"Preferred video quality: ": "Foretrukket videokvalitet: ",
|
"Preferred video quality: ": "Foretrukket videokvalitet: ",
|
||||||
"Player volume: ": "Avspillerlydstyrke: ",
|
"Player volume: ": "Avspillerlydstyrke: ",
|
||||||
@ -83,11 +84,11 @@
|
|||||||
"Administrator preferences": "Administratorinnstillinger",
|
"Administrator preferences": "Administratorinnstillinger",
|
||||||
"Default homepage: ": "Forvalgt hjemmeside: ",
|
"Default homepage: ": "Forvalgt hjemmeside: ",
|
||||||
"Feed menu: ": "Flyt-meny: ",
|
"Feed menu: ": "Flyt-meny: ",
|
||||||
"Top enabled? ": "",
|
"Top enabled? ": "Topp påskrudd? ",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA påskrudd? ",
|
"CAPTCHA enabled? ": "CAPTCHA påskrudd? ",
|
||||||
"Login enabled? ": "Innlogging påskrudd? ",
|
"Login enabled? ": "Innlogging påskrudd? ",
|
||||||
"Registration enabled? ": "Registrering påskrudd? ",
|
"Registration enabled? ": "Registrering påskrudd? ",
|
||||||
"Report statistics? ": "",
|
"Report statistics? ": "Innrapporter statistikk? ",
|
||||||
"Save preferences": "Lagre innstillinger",
|
"Save preferences": "Lagre innstillinger",
|
||||||
"Subscription manager": "Abonnementsbehandler",
|
"Subscription manager": "Abonnementsbehandler",
|
||||||
"`x` subscriptions": "`x` abonnementer",
|
"`x` subscriptions": "`x` abonnementer",
|
||||||
@ -100,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.",
|
||||||
"Source available here.": "Kildekode tilgjengelig her.",
|
"Source available here.": "Kildekode tilgjengelig her.",
|
||||||
"View JavaScript license information.": "Vis JavaScript-lisensinfo.",
|
"View JavaScript license information.": "Vis JavaScript-lisensinfo.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "Trendsettende",
|
"Trending": "Trendsettende",
|
||||||
"Watch video on Youtube": "Vis video på YouTube",
|
"Watch video on Youtube": "Vis video på YouTube",
|
||||||
"Genre: ": "Sjanger: ",
|
"Genre: ": "Sjanger: ",
|
||||||
@ -284,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
|
"Youtube permalink of the comment": "Permanent YouTube-lenke til innholdet",
|
||||||
"`x` marked it with a ❤": "`x` levnet et ❤",
|
"`x` marked it with a ❤": "`x` levnet et ❤",
|
||||||
"Audio mode": "Lydmodus",
|
"Audio mode": "Lydmodus",
|
||||||
"Video mode": "Video-modus"
|
"Video mode": "Video-modus",
|
||||||
|
"Videos": "Videoer",
|
||||||
|
"Playlists": "Spillelister",
|
||||||
|
"Current version: ": "Nåværende versjon: "
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
"newest": "nieuwste",
|
"newest": "nieuwste",
|
||||||
"oldest": "oudste",
|
"oldest": "oudste",
|
||||||
"popular": "populair",
|
"popular": "populair",
|
||||||
"Preview page": "Pagina voorvertonen",
|
"last": "",
|
||||||
"Next page": "Volgende pagina",
|
"Next page": "Volgende pagina",
|
||||||
|
"Previous page": "Vorige pagina",
|
||||||
"Clear watch history?": "Kijk geschiedenis wissen?",
|
"Clear watch history?": "Kijk geschiedenis wissen?",
|
||||||
"Yes": "Ja",
|
"Yes": "Ja",
|
||||||
"No": "Nee",
|
"No": "Nee",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Exporteer gegevens als JSON",
|
"Export data as JSON": "Exporteer gegevens als JSON",
|
||||||
"Delete account?": "Verwijder account?",
|
"Delete account?": "Verwijder account?",
|
||||||
"History": "Geschiedenis",
|
"History": "Geschiedenis",
|
||||||
"Previous page": "Vorige pagina",
|
|
||||||
"An alternative front-end to YouTube": "Een alternatieve front-end voor YouTube",
|
"An alternative front-end to YouTube": "Een alternatieve front-end voor YouTube",
|
||||||
"JavaScript license information": "JavaScript licentie informatie",
|
"JavaScript license information": "JavaScript licentie informatie",
|
||||||
"source": "bron",
|
"source": "bron",
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"Autoplay: ": "Automatisch afspelen: ",
|
"Autoplay: ": "Automatisch afspelen: ",
|
||||||
"Autoplay next video: ": "Automatisch volgende video afspelen: ",
|
"Autoplay next video: ": "Automatisch volgende video afspelen: ",
|
||||||
"Listen by default: ": "Standaard luisteren: ",
|
"Listen by default: ": "Standaard luisteren: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "Standaard snelheid: ",
|
"Default speed: ": "Standaard snelheid: ",
|
||||||
"Preferred video quality: ": "Video kwaliteit voorkeur: ",
|
"Preferred video quality: ": "Video kwaliteit voorkeur: ",
|
||||||
"Player volume: ": "Afspeler volume: ",
|
"Player volume: ": "Afspeler volume: ",
|
||||||
@ -100,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder AGPLv3 door Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder AGPLv3 door Omar Roth.",
|
||||||
"Source available here.": "Bron beschikbaar hier.",
|
"Source available here.": "Bron beschikbaar hier.",
|
||||||
"View JavaScript license information.": "Bekijk JavaScript licentie informatie.",
|
"View JavaScript license information.": "Bekijk JavaScript licentie informatie.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "Trending",
|
"Trending": "Trending",
|
||||||
"Watch video on Youtube": "Bekijk video op Youtube",
|
"Watch video on Youtube": "Bekijk video op Youtube",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
@ -284,5 +286,8 @@
|
|||||||
"Youtube permalink of the comment": "",
|
"Youtube permalink of the comment": "",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "",
|
||||||
"Audio mode": "",
|
"Audio mode": "",
|
||||||
"Video mode": ""
|
"Video mode": "",
|
||||||
|
"Videos": "",
|
||||||
|
"Playlists": "",
|
||||||
|
"Current version: ": ""
|
||||||
}
|
}
|
||||||
|
115
locales/pl.json
115
locales/pl.json
@ -10,8 +10,9 @@
|
|||||||
"newest": "najnowsze",
|
"newest": "najnowsze",
|
||||||
"oldest": "najstarsze",
|
"oldest": "najstarsze",
|
||||||
"popular": "popularne",
|
"popular": "popularne",
|
||||||
"Preview page": "Podgląd strony",
|
"last": "",
|
||||||
"Next page": "Następna strona",
|
"Next page": "Następna strona",
|
||||||
|
"Previous page": "Poprzednia strona",
|
||||||
"Clear watch history?": "Wyczyścić historię?",
|
"Clear watch history?": "Wyczyścić historię?",
|
||||||
"Yes": "Tak",
|
"Yes": "Tak",
|
||||||
"No": "Nie",
|
"No": "Nie",
|
||||||
@ -28,7 +29,6 @@
|
|||||||
"Export data as JSON": "Eksportuj dane jako JSON",
|
"Export data as JSON": "Eksportuj dane jako JSON",
|
||||||
"Delete account?": "Usunąć konto?",
|
"Delete account?": "Usunąć konto?",
|
||||||
"History": "Historia",
|
"History": "Historia",
|
||||||
"Previous page": "Poprzednia strona",
|
|
||||||
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
|
"An alternative front-end to YouTube": "Alternatywny front-end dla YouTube",
|
||||||
"JavaScript license information": "Informacja o licencji JavaScript",
|
"JavaScript license information": "Informacja o licencji JavaScript",
|
||||||
"source": "źródło",
|
"source": "źródło",
|
||||||
@ -50,12 +50,13 @@
|
|||||||
"Autoplay: ": "Autoodtwarzanie: ",
|
"Autoplay: ": "Autoodtwarzanie: ",
|
||||||
"Autoplay next video: ": "Odtwórz następny film: ",
|
"Autoplay next video: ": "Odtwórz następny film: ",
|
||||||
"Listen by default: ": "Tryb dźwiękowy: ",
|
"Listen by default: ": "Tryb dźwiękowy: ",
|
||||||
|
"Proxy videos? ": "",
|
||||||
"Default speed: ": "Domyślna prędkość: ",
|
"Default speed: ": "Domyślna prędkość: ",
|
||||||
"Preferred video quality: ": "Preferowana jakość filmów: ",
|
"Preferred video quality: ": "Preferowana jakość filmów: ",
|
||||||
"Player volume: ": "Głośność odtwarzacza: ",
|
"Player volume: ": "Głośność odtwarzacza: ",
|
||||||
"Default comments: ": "Domyślne komentarze: ",
|
"Default comments: ": "Domyślne komentarze: ",
|
||||||
"Default captions: ": "Domyślne napisy: ",
|
"Default captions: ": "Domyślne napisy: ",
|
||||||
"Fallback captions: ": "Rezerwowe napisy: ",
|
"Fallback captions: ": "Zastępcze napisy: ",
|
||||||
"Show related videos? ": "Pokaż powiązane filmy? ",
|
"Show related videos? ": "Pokaż powiązane filmy? ",
|
||||||
"Visual preferences": "Preferencje Wizualne",
|
"Visual preferences": "Preferencje Wizualne",
|
||||||
"Dark mode: ": "Ciemny motyw: ",
|
"Dark mode: ": "Ciemny motyw: ",
|
||||||
@ -63,13 +64,13 @@
|
|||||||
"Subscription preferences": "Preferencje subskrybcji",
|
"Subscription preferences": "Preferencje subskrybcji",
|
||||||
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
|
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
|
||||||
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
|
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
|
||||||
"Sort videos by: ": "Sortuj filmy po: ",
|
"Sort videos by: ": "Sortuj filmy: ",
|
||||||
"published": "czasie publikacji",
|
"published": "po czasie publikacji",
|
||||||
"published - reverse": "czasie publikacji od najstarszych",
|
"published - reverse": "po czasie publikacji od najstarszych",
|
||||||
"alphabetically": "alfabetycznie",
|
"alphabetically": "alfabetycznie",
|
||||||
"alphabetically - reverse": "alfabetycznie od tyłu",
|
"alphabetically - reverse": "alfabetycznie od tyłu",
|
||||||
"channel name": "nazwie kanału",
|
"channel name": "po nazwie kanału",
|
||||||
"channel name - reverse": "nazwie kanału od tyłu",
|
"channel name - reverse": "po nazwie kanału od tyłu",
|
||||||
"Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ",
|
"Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ",
|
||||||
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
|
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
|
||||||
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
|
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
|
||||||
@ -80,14 +81,14 @@
|
|||||||
"Manage subscriptions": "Organizuj subskrybcje",
|
"Manage subscriptions": "Organizuj subskrybcje",
|
||||||
"Watch history": "Historia",
|
"Watch history": "Historia",
|
||||||
"Delete account": "Usuń konto",
|
"Delete account": "Usuń konto",
|
||||||
"Administrator preferences": "",
|
"Administrator preferences": "Preferencje administratora",
|
||||||
"Default homepage: ": "",
|
"Default homepage: ": "Domyślna strona główna: ",
|
||||||
"Feed menu: ": "",
|
"Feed menu: ": "",
|
||||||
"Top enabled? ": "",
|
"Top enabled? ": "",
|
||||||
"CAPTCHA enabled? ": "",
|
"CAPTCHA enabled? ": "CAPTCHA aktywna? ",
|
||||||
"Login enabled? ": "",
|
"Login enabled? ": "Logowanie włączone? ",
|
||||||
"Registration enabled? ": "",
|
"Registration enabled? ": "Rejestracja włączona? ",
|
||||||
"Report statistics? ": "",
|
"Report statistics? ": "Raportować statystyki? ",
|
||||||
"Save preferences": "Zapisz preferencje",
|
"Save preferences": "Zapisz preferencje",
|
||||||
"Subscription manager": "Manager subskrybcji",
|
"Subscription manager": "Manager subskrybcji",
|
||||||
"`x` subscriptions": "`x` subskrybcji",
|
"`x` subscriptions": "`x` subskrybcji",
|
||||||
@ -100,6 +101,7 @@
|
|||||||
"Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.",
|
||||||
"Source available here.": "Kod źródłowy dostępny tutaj.",
|
"Source available here.": "Kod źródłowy dostępny tutaj.",
|
||||||
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
|
"View JavaScript license information.": "Wyświetl informację o licencji JavaScript.",
|
||||||
|
"View privacy policy.": "",
|
||||||
"Trending": "Na czasie",
|
"Trending": "Na czasie",
|
||||||
"Watch video on Youtube": "Zobacz film na YouTube",
|
"Watch video on Youtube": "Zobacz film na YouTube",
|
||||||
"Genre: ": "Gatunek: ",
|
"Genre: ": "Gatunek: ",
|
||||||
@ -155,20 +157,20 @@
|
|||||||
"Token is expired, please try again": "Token wygasł, spróbuj ponownie",
|
"Token is expired, please try again": "Token wygasł, spróbuj ponownie",
|
||||||
"English": "angielski",
|
"English": "angielski",
|
||||||
"English (auto-generated)": "angielski (automatycznie generowane)",
|
"English (auto-generated)": "angielski (automatycznie generowane)",
|
||||||
"Afrikaans": "",
|
"Afrikaans": "afrykanerski",
|
||||||
"Albanian": "albański",
|
"Albanian": "albański",
|
||||||
"Amharic": "",
|
"Amharic": "amharski",
|
||||||
"Arabic": "arabski",
|
"Arabic": "arabski",
|
||||||
"Armenian": "",
|
"Armenian": "armeński",
|
||||||
"Azerbaijani": "",
|
"Azerbaijani": "azerski",
|
||||||
"Bangla": "",
|
"Bangla": "bengalski",
|
||||||
"Basque": "",
|
"Basque": "baskijski",
|
||||||
"Belarusian": "białoruski",
|
"Belarusian": "białoruski",
|
||||||
"Bosnian": "bośniacki",
|
"Bosnian": "bośniacki",
|
||||||
"Bulgarian": "bułgarski",
|
"Bulgarian": "bułgarski",
|
||||||
"Burmese": "birmański",
|
"Burmese": "birmański",
|
||||||
"Catalan": "kataloński",
|
"Catalan": "kataloński",
|
||||||
"Cebuano": "",
|
"Cebuano": "cebuański",
|
||||||
"Chinese (Simplified)": "chiński (uproszczony)",
|
"Chinese (Simplified)": "chiński (uproszczony)",
|
||||||
"Chinese (Traditional)": "chiński (tradycyjny)",
|
"Chinese (Traditional)": "chiński (tradycyjny)",
|
||||||
"Corsican": "korsykański",
|
"Corsican": "korsykański",
|
||||||
@ -185,28 +187,28 @@
|
|||||||
"Georgian": "gruziński",
|
"Georgian": "gruziński",
|
||||||
"German": "niemiecki",
|
"German": "niemiecki",
|
||||||
"Greek": "grecki",
|
"Greek": "grecki",
|
||||||
"Gujarati": "",
|
"Gujarati": "gudźarati",
|
||||||
"Haitian Creole": "",
|
"Haitian Creole": "kreolski haitański",
|
||||||
"Hausa": "",
|
"Hausa": "hausa",
|
||||||
"Hawaiian": "hawajski",
|
"Hawaiian": "hawajski",
|
||||||
"Hebrew": "hebrajski",
|
"Hebrew": "hebrajski",
|
||||||
"Hindi": "hindi",
|
"Hindi": "hindi",
|
||||||
"Hmong": "",
|
"Hmong": "hmong",
|
||||||
"Hungarian": "węgierski",
|
"Hungarian": "węgierski",
|
||||||
"Icelandic": "islandzki",
|
"Icelandic": "islandzki",
|
||||||
"Igbo": "",
|
"Igbo": "ibo",
|
||||||
"Indonesian": "indonezyjski",
|
"Indonesian": "indonezyjski",
|
||||||
"Irish": "irlandzki",
|
"Irish": "irlandzki",
|
||||||
"Italian": "włoski",
|
"Italian": "włoski",
|
||||||
"Japanese": "japoński",
|
"Japanese": "japoński",
|
||||||
"Javanese": "jawajski",
|
"Javanese": "jawajski",
|
||||||
"Kannada": "",
|
"Kannada": "kannada",
|
||||||
"Kazakh": "kazachski",
|
"Kazakh": "kazachski",
|
||||||
"Khmer": "",
|
"Khmer": "khmerski",
|
||||||
"Korean": "koreański",
|
"Korean": "koreański",
|
||||||
"Kurdish": "kurdyjski",
|
"Kurdish": "kurdyjski",
|
||||||
"Kyrgyz": "kirgiski",
|
"Kyrgyz": "kirgiski",
|
||||||
"Lao": "",
|
"Lao": "laotański",
|
||||||
"Latin": "łaciński",
|
"Latin": "łaciński",
|
||||||
"Latvian": "łotewski",
|
"Latvian": "łotewski",
|
||||||
"Lithuanian": "litewski",
|
"Lithuanian": "litewski",
|
||||||
@ -214,51 +216,51 @@
|
|||||||
"Macedonian": "macedoński",
|
"Macedonian": "macedoński",
|
||||||
"Malagasy": "malgaski",
|
"Malagasy": "malgaski",
|
||||||
"Malay": "malajski",
|
"Malay": "malajski",
|
||||||
"Malayalam": "",
|
"Malayalam": "malajalam",
|
||||||
"Maltese": "maltański",
|
"Maltese": "maltański",
|
||||||
"Maori": "",
|
"Maori": "maoryski",
|
||||||
"Marathi": "",
|
"Marathi": "marathi",
|
||||||
"Mongolian": "mongolski",
|
"Mongolian": "mongolski",
|
||||||
"Nepali": "nepalski",
|
"Nepali": "nepalski",
|
||||||
"Norwegian": "norweski",
|
"Norwegian": "norweski",
|
||||||
"Nyanja": "",
|
"Nyanja": "njandża",
|
||||||
"Pashto": "",
|
"Pashto": "paszto",
|
||||||
"Persian": "perski",
|
"Persian": "perski",
|
||||||
"Polish": "polski",
|
"Polish": "polski",
|
||||||
"Portuguese": "portugalski",
|
"Portuguese": "portugalski",
|
||||||
"Punjabi": "",
|
"Punjabi": "pendżabski",
|
||||||
"Romanian": "rumuński",
|
"Romanian": "rumuński",
|
||||||
"Russian": "rosyjski",
|
"Russian": "rosyjski",
|
||||||
"Samoan": "",
|
"Samoan": "samoański",
|
||||||
"Scottish Gaelic": "",
|
"Scottish Gaelic": "gaelicki szkocki",
|
||||||
"Serbian": "serbski",
|
"Serbian": "serbski",
|
||||||
"Shona": "",
|
"Shona": "shona",
|
||||||
"Sindhi": "",
|
"Sindhi": "sindhi",
|
||||||
"Sinhala": "",
|
"Sinhala": "syngaleski",
|
||||||
"Slovak": "słowacki",
|
"Slovak": "słowacki",
|
||||||
"Slovenian": "słoweński",
|
"Slovenian": "słoweński",
|
||||||
"Somali": "somalijski",
|
"Somali": "somalijski",
|
||||||
"Southern Sotho": "",
|
"Southern Sotho": "sotho południowy",
|
||||||
"Spanish": "hiszpański",
|
"Spanish": "hiszpański",
|
||||||
"Spanish (Latin America)": "hiszpański (ameryka łacińska)",
|
"Spanish (Latin America)": "hiszpański (ameryka łacińska)",
|
||||||
"Sundanese": "",
|
"Sundanese": "sundajski",
|
||||||
"Swahili": "",
|
"Swahili": "suahili",
|
||||||
"Swedish": "szwedzki",
|
"Swedish": "szwedzki",
|
||||||
"Tajik": "",
|
"Tajik": "tadżycki",
|
||||||
"Tamil": "",
|
"Tamil": "tamilski",
|
||||||
"Telugu": "",
|
"Telugu": "telugu",
|
||||||
"Thai": "tajski",
|
"Thai": "tajski",
|
||||||
"Turkish": "turecki",
|
"Turkish": "turecki",
|
||||||
"Ukrainian": "ukraiński",
|
"Ukrainian": "ukraiński",
|
||||||
"Urdu": "",
|
"Urdu": "urdu",
|
||||||
"Uzbek": "uzbecki",
|
"Uzbek": "uzbecki",
|
||||||
"Vietnamese": "wietnamski",
|
"Vietnamese": "wietnamski",
|
||||||
"Welsh": "walijski",
|
"Welsh": "walijski",
|
||||||
"Western Frisian": "",
|
"Western Frisian": "zachodniofryzyjski",
|
||||||
"Xhosa": "",
|
"Xhosa": "xhosa",
|
||||||
"Yiddish": "",
|
"Yiddish": "jidysz",
|
||||||
"Yoruba": "",
|
"Yoruba": "joruba",
|
||||||
"Zulu": "",
|
"Zulu": "zuluski",
|
||||||
"`x` years": "`x` lat",
|
"`x` years": "`x` lat",
|
||||||
"`x` months": "`x` miesięcy",
|
"`x` months": "`x` miesięcy",
|
||||||
"`x` weeks": "`x` tygodni",
|
"`x` weeks": "`x` tygodni",
|
||||||
@ -272,7 +274,7 @@
|
|||||||
"About": "Informacje",
|
"About": "Informacje",
|
||||||
"Rating: ": "Ocena: ",
|
"Rating: ": "Ocena: ",
|
||||||
"Language: ": "Język: ",
|
"Language: ": "Język: ",
|
||||||
"Default": "",
|
"Default": "Domyślnie",
|
||||||
"Music": "Muzyka",
|
"Music": "Muzyka",
|
||||||
"Gaming": "Gry",
|
"Gaming": "Gry",
|
||||||
"News": "Wiadomości",
|
"News": "Wiadomości",
|
||||||
@ -282,7 +284,10 @@
|
|||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "(edytowany)",
|
"(edited)": "(edytowany)",
|
||||||
"Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube",
|
"Youtube permalink of the comment": "Odnośnik bezpośredni do komentarza na YouTube",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "'x' oznaczonych ❤",
|
||||||
"Audio mode": "Tryb audio",
|
"Audio mode": "Tryb audio",
|
||||||
"Video mode": "Tryb wideo"
|
"Video mode": "Tryb wideo",
|
||||||
|
"Videos": "Filmy",
|
||||||
|
"Playlists": "Playlisty",
|
||||||
|
"Current version: ": "Aktualna wersja: "
|
||||||
}
|
}
|
||||||
|
585
locales/ru.json
585
locales/ru.json
@ -1,294 +1,295 @@
|
|||||||
{
|
{
|
||||||
"`x` subscribers": "`x` подписчиков",
|
"`x` subscribers": "`x` подписчиков",
|
||||||
"`x` videos": "`x` видео",
|
"`x` videos": "`x` видео",
|
||||||
"LIVE": "ПРЯМОЙ ЭФИР",
|
"LIVE": "ПРЯМОЙ ЭФИР",
|
||||||
"Shared `x` ago": "Опубликовано `x` назад",
|
"Shared `x` ago": "Опубликовано `x` назад",
|
||||||
"Unsubscribe": "Отписаться",
|
"Unsubscribe": "Отписаться",
|
||||||
"Subscribe": "Подписаться",
|
"Subscribe": "Подписаться",
|
||||||
"Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
|
"Login to subscribe to `x`": "Войти, чтобы подписаться на `x`",
|
||||||
"View channel on YouTube": "Канал на YouTube",
|
"View channel on YouTube": "Канал на YouTube",
|
||||||
"newest": "новые",
|
"newest": "новые",
|
||||||
"oldest": "старые",
|
"oldest": "старые",
|
||||||
"popular": "популярные",
|
"popular": "популярные",
|
||||||
"Preview page": "Предварительный просмотр",
|
"last": "недавно обновленные",
|
||||||
"Next page": "Следующая страница",
|
"Next page": "Следующая страница",
|
||||||
"Clear watch history?": "Очистить историю просмотров?",
|
"Previous page": "Предыдущая страница",
|
||||||
"Yes": "Да",
|
"Clear watch history?": "Очистить историю просмотров?",
|
||||||
"No": "Нет",
|
"Yes": "Да",
|
||||||
"Import and Export Data": "Импорт и экспорт данных",
|
"No": "Нет",
|
||||||
"Import": "Импорт",
|
"Import and Export Data": "Импорт и экспорт данных",
|
||||||
"Import Invidious data": "Импортировать данные Invidious",
|
"Import": "Импорт",
|
||||||
"Import YouTube subscriptions": "Импортировать YouTube подписки",
|
"Import Invidious data": "Импортировать данные Invidious",
|
||||||
"Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
|
"Import YouTube subscriptions": "Импортировать YouTube подписки",
|
||||||
"Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
|
"Import FreeTube subscriptions (.db)": "Импортировать FreeTube подписки (.db)",
|
||||||
"Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
|
"Import NewPipe subscriptions (.json)": "Импортировать NewPipe подписки (.json)",
|
||||||
"Export": "Экспорт",
|
"Import NewPipe data (.zip)": "Импортировать данные NewPipe (.zip)",
|
||||||
"Export subscriptions as OPML": "Экспортировать подписки в OPML",
|
"Export": "Экспорт",
|
||||||
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
|
"Export subscriptions as OPML": "Экспортировать подписки в OPML",
|
||||||
"Export data as JSON": "Экспортировать данные в JSON",
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в OPML (для NewPipe и FreeTube)",
|
||||||
"Delete account?": "Удалить аккаунт?",
|
"Export data as JSON": "Экспортировать данные в JSON",
|
||||||
"History": "История",
|
"Delete account?": "Удалить аккаунт?",
|
||||||
"Previous page": "Предыдущая страница",
|
"History": "История",
|
||||||
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
|
"An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube",
|
||||||
"JavaScript license information": "Лицензии JavaScript",
|
"JavaScript license information": "Лицензии JavaScript",
|
||||||
"source": "источник",
|
"source": "источник",
|
||||||
"Login": "Войти",
|
"Login": "Войти",
|
||||||
"Login/Register": "Войти/Регистрация",
|
"Login/Register": "Войти/Регистрация",
|
||||||
"Login to Google": "Войти через Google",
|
"Login to Google": "Войти через Google",
|
||||||
"User ID:": "ID пользователя:",
|
"User ID:": "ID пользователя:",
|
||||||
"Password:": "Пароль:",
|
"Password:": "Пароль:",
|
||||||
"Time (h:mm:ss):": "Время (ч:мм:сс):",
|
"Time (h:mm:ss):": "Время (ч:мм:сс):",
|
||||||
"Text CAPTCHA": "Текст капчи",
|
"Text CAPTCHA": "Текст капчи",
|
||||||
"Image CAPTCHA": "Изображение капчи",
|
"Image CAPTCHA": "Изображение капчи",
|
||||||
"Sign In": "Войти",
|
"Sign In": "Войти",
|
||||||
"Register": "Регистрация",
|
"Register": "Регистрация",
|
||||||
"Email:": "Эл. почта:",
|
"Email:": "Эл. почта:",
|
||||||
"Google verification code:": "Код подтверждения Google:",
|
"Google verification code:": "Код подтверждения Google:",
|
||||||
"Preferences": "Настройки",
|
"Preferences": "Настройки",
|
||||||
"Player preferences": "Настройки проигрывателя",
|
"Player preferences": "Настройки проигрывателя",
|
||||||
"Always loop: ": "Всегда повторять: ",
|
"Always loop: ": "Всегда повторять: ",
|
||||||
"Autoplay: ": "Автовоспроизведение: ",
|
"Autoplay: ": "Автовоспроизведение: ",
|
||||||
"Autoplay next video: ": "Автовоспроизведение следующего видео: ",
|
"Autoplay next video: ": "Автовоспроизведение следующего видео: ",
|
||||||
"Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
|
"Listen by default: ": "Режим \"только аудио\" по-умолчанию: ",
|
||||||
"Default speed: ": "Скорость по-умолчанию: ",
|
"Proxy videos? ": "Проксировать видео? ",
|
||||||
"Preferred video quality: ": "Предпочтительное качество видео: ",
|
"Default speed: ": "Скорость по-умолчанию: ",
|
||||||
"Player volume: ": "Громкость воспроизведения: ",
|
"Preferred video quality: ": "Предпочтительное качество видео: ",
|
||||||
"Default comments: ": "Источник комментариев: ",
|
"Player volume: ": "Громкость воспроизведения: ",
|
||||||
"youtube": "YouTube",
|
"Default comments: ": "Источник комментариев: ",
|
||||||
"reddit": "Reddit",
|
"youtube": "YouTube",
|
||||||
"Default captions: ": "Субтитры по-умолчанию: ",
|
"reddit": "Reddit",
|
||||||
"Fallback captions: ": "Резервные субтитры: ",
|
"Default captions: ": "Субтитры по-умолчанию: ",
|
||||||
"Show related videos? ": "Показывать похожие видео? ",
|
"Fallback captions: ": "Резервные субтитры: ",
|
||||||
"Visual preferences": "Визуальные настройки",
|
"Show related videos? ": "Показывать похожие видео? ",
|
||||||
"Dark mode: ": "Темная тема: ",
|
"Visual preferences": "Визуальные настройки",
|
||||||
"Thin mode: ": "Облегченный режим: ",
|
"Dark mode: ": "Темная тема: ",
|
||||||
"Subscription preferences": "Настройки подписок",
|
"Thin mode: ": "Облегченный режим: ",
|
||||||
"Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
|
"Subscription preferences": "Настройки подписок",
|
||||||
"Number of videos shown in feed: ": "Число видео в ленте: ",
|
"Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ",
|
||||||
"Sort videos by: ": "Сортировать видео по: ",
|
"Number of videos shown in feed: ": "Число видео в ленте: ",
|
||||||
"published": "дате публикации",
|
"Sort videos by: ": "Сортировать видео по: ",
|
||||||
"published - reverse": "дате - обратный порядок",
|
"published": "дате публикации",
|
||||||
"alphabetically": "алфавиту",
|
"published - reverse": "дате - обратный порядок",
|
||||||
"alphabetically - reverse": "алфавиту - обратный порядок",
|
"alphabetically": "алфавиту",
|
||||||
"channel name": "имени канала",
|
"alphabetically - reverse": "алфавиту - обратный порядок",
|
||||||
"channel name - reverse": "имени канала - обратный порядок",
|
"channel name": "имени канала",
|
||||||
"Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
|
"channel name - reverse": "имени канала - обратный порядок",
|
||||||
"Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
|
"Only show latest video from channel: ": "Отображать только последние видео с каждого канала: ",
|
||||||
"Only show unwatched: ": "Отображать только непросмотренные видео: ",
|
"Only show latest unwatched video from channel: ": "Отображать только непросмотренные видео с каждого канала: ",
|
||||||
"Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
|
"Only show unwatched: ": "Отображать только непросмотренные видео: ",
|
||||||
"Data preferences": "Настройки данных",
|
"Only show notifications (if there are any): ": "Отображать только оповещения (если есть): ",
|
||||||
"Clear watch history": "Очистить историю просмотра",
|
"Data preferences": "Настройки данных",
|
||||||
"Import/Export data": "Импорт/Экспорт данных",
|
"Clear watch history": "Очистить историю просмотра",
|
||||||
"Manage subscriptions": "Управление подписками",
|
"Import/Export data": "Импорт/Экспорт данных",
|
||||||
"Watch history": "История просмотров",
|
"Manage subscriptions": "Управление подписками",
|
||||||
"Delete account": "Удалить аккаунт",
|
"Watch history": "История просмотров",
|
||||||
"Administrator preferences": "",
|
"Delete account": "Удалить аккаунт",
|
||||||
"Default homepage: ": "",
|
"Administrator preferences": "Настройки администратора",
|
||||||
"Feed menu: ": "",
|
"Default homepage: ": "Главная страница по умолчанию: ",
|
||||||
"Top enabled? ": "",
|
"Feed menu: ": "Меню ленты: ",
|
||||||
"CAPTCHA enabled? ": "",
|
"Top enabled? ": "Включить ТОП? ",
|
||||||
"Login enabled? ": "",
|
"CAPTCHA enabled? ": "Включить капчу? ",
|
||||||
"Registration enabled? ": "",
|
"Login enabled? ": "Включить логин? ",
|
||||||
"Report statistics? ": "",
|
"Registration enabled? ": "Включить регистрацию? ",
|
||||||
"Save preferences": "Сохранить настройки",
|
"Report statistics? ": "Отображать статистику? ",
|
||||||
"Subscription manager": "Менеджер подписок",
|
"Save preferences": "Сохранить настройки",
|
||||||
"`x` subscriptions": "`x` подписок",
|
"Subscription manager": "Менеджер подписок",
|
||||||
"Import/Export": "Импорт/Экспорт",
|
"`x` subscriptions": "`x` подписок",
|
||||||
"unsubscribe": "отписаться",
|
"Import/Export": "Импорт/Экспорт",
|
||||||
"Subscriptions": "Подписки",
|
"unsubscribe": "отписаться",
|
||||||
"`x` unseen notifications": "`x` новых оповещений",
|
"Subscriptions": "Подписки",
|
||||||
"search": "поиск",
|
"`x` unseen notifications": "`x` новых оповещений",
|
||||||
"Sign out": "Выйти",
|
"search": "поиск",
|
||||||
"Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
|
"Sign out": "Выйти",
|
||||||
"Source available here.": "Исходный код доступен здесь.",
|
"Released under the AGPLv3 by Omar Roth.": "Распространяется Omar Roth по AGPLv3.",
|
||||||
"Liberapay: ": "Liberapay: ",
|
"Source available here.": "Исходный код доступен здесь.",
|
||||||
"Patreon: ": "Patreon: ",
|
"View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
|
||||||
"BTC: ": "BTC: ",
|
"View privacy policy.": "См. политику конфиденциальности.",
|
||||||
"BCH: ": "BCH: ",
|
"Trending": "В тренде",
|
||||||
"View JavaScript license information.": "Посмотреть лицензии JavaScript кода.",
|
"Watch video on Youtube": "Смотреть на YouTube",
|
||||||
"Trending": "В тренде",
|
"Genre: ": "Жанр: ",
|
||||||
"Watch video on Youtube": "Смотреть на YouTube",
|
"License: ": "Лицензия: ",
|
||||||
"Genre: ": "Жанр: ",
|
"Family friendly? ": "Семейный просмотр: ",
|
||||||
"License: ": "Лицензия: ",
|
"Wilson score: ": "Рейтинг Вильсона: ",
|
||||||
"Family friendly? ": "Семейный просмотр: ",
|
"Engagement: ": "Вовлеченность: ",
|
||||||
"Wilson score: ": "Рейтинг Вильсона: ",
|
"Whitelisted regions: ": "Доступно для: ",
|
||||||
"Engagement: ": "Вовлеченность: ",
|
"Blacklisted regions: ": "Недоступно для: ",
|
||||||
"Whitelisted regions: ": "Доступно для: ",
|
"Shared `x`": "Опубликовано `x`",
|
||||||
"Blacklisted regions: ": "Недоступно для: ",
|
"Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).",
|
||||||
"Shared `x`": "Опубликовано `x`",
|
"View YouTube comments": "Смотреть комментарии с YouTube",
|
||||||
"Hi! Looks like you have JavaScript disabled. Click here to view comments, keep in mind it may take a bit longer to load.": "Похоже, что у Вас отключен JavaScript. Нажмите сюда, чтобы увидеть комментарии (учтите, что они могут загружаться дольше).",
|
"View more comments on Reddit": "Больше комментариев на Reddit",
|
||||||
"View YouTube comments": "Смотреть комментарии с YouTube",
|
"View `x` comments": "Показать `x` комментариев",
|
||||||
"View more comments on Reddit": "Больше комментариев на Reddit",
|
"View Reddit comments": "Смотреть комментарии с Reddit",
|
||||||
"View `x` comments": "Показать `x` комментариев",
|
"Hide replies": "Скрыть ответы",
|
||||||
"View Reddit comments": "Смотреть комментарии с Reddit",
|
"Show replies": "Показать ответы",
|
||||||
"Hide replies": "Скрыть ответы",
|
"Incorrect password": "Неправильный пароль",
|
||||||
"Show replies": "Показать ответы",
|
"Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
|
||||||
"Incorrect password": "Неправильный пароль",
|
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
|
||||||
"Quota exceeded, try again in a few hours": "Превышена квота, попробуйте снова через несколько часов",
|
"Invalid TFA code": "Неправильный TFA код",
|
||||||
"Unable to login, make sure two-factor authentication (Authenticator or SMS) is enabled.": "Вход не выполнен, проверьте, не включена ли двухфакторная аутентификация.",
|
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
|
||||||
"Invalid TFA code": "Неправильный TFA код",
|
"Invalid answer": "Неверный ответ",
|
||||||
"Login failed. This may be because two-factor authentication is not enabled on your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.",
|
"Invalid CAPTCHA": "Неверная капча",
|
||||||
"Invalid answer": "Неверный ответ",
|
"CAPTCHA is a required field": "Необходимо ввести капчу",
|
||||||
"Invalid CAPTCHA": "Неверная капча",
|
"User ID is a required field": "Необходимо ввести идентификатор пользователя",
|
||||||
"CAPTCHA is a required field": "Необходимо ввести капчу",
|
"Password is a required field": "Необходимо ввести пароль",
|
||||||
"User ID is a required field": "Необходимо ввести идентификатор пользователя",
|
"Invalid username or password": "Недопустимый пароль или имя пользователя",
|
||||||
"Password is a required field": "Необходимо ввести пароль",
|
"Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
|
||||||
"Invalid username or password": "Недопустимый пароль или имя пользователя",
|
"Password cannot be empty": "Пароль не может быть пустым",
|
||||||
"Please sign in using 'Sign in with Google'": "Пожалуйста войдите через Google",
|
"Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
|
||||||
"Password cannot be empty": "Пароль не может быть пустым",
|
"Please sign in": "Пожалуйста, войдите",
|
||||||
"Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов",
|
"Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
|
||||||
"Please sign in": "Пожалуйста, войдите",
|
"channel:`x`": "канал: `x`",
|
||||||
"Invidious Private Feed for `x`": "Приватная лента Invidious для `x`",
|
"Deleted or invalid channel": "Канал удален или не найден",
|
||||||
"channel:`x`": "канал: `x`",
|
"This channel does not exist.": "Такой канал не существует.",
|
||||||
"Deleted or invalid channel": "Канал удален или не найден",
|
"Could not get channel info.": "Невозможно получить информацию о канале.",
|
||||||
"This channel does not exist.": "Такой канал не существует.",
|
"Could not fetch comments": "Невозможно получить комментарии",
|
||||||
"Could not get channel info.": "Невозможно получить информацию о канале.",
|
"View `x` replies": "Показать `x` ответов",
|
||||||
"Could not fetch comments": "Невозможно получить комментарии",
|
"`x` ago": "`x` назад",
|
||||||
"View `x` replies": "Показать `x` ответов",
|
"Load more": "Загрузить больше",
|
||||||
"`x` ago": "`x` назад",
|
"`x` points": "`x` очков",
|
||||||
"Load more": "Загрузить больше",
|
"Could not create mix.": "Невозможно создать \"микс\".",
|
||||||
"`x` points": "`x` очков",
|
"Playlist is empty": "Плейлист пуст",
|
||||||
"Could not create mix.": "Невозможно создать \"микс\".",
|
"Invalid playlist.": "Некорректный плейлист.",
|
||||||
"Playlist is empty": "Плейлист пуст",
|
"Playlist does not exist.": "Плейлист не существует.",
|
||||||
"Invalid playlist.": "Некорректный плейлист.",
|
"Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
|
||||||
"Playlist does not exist.": "Плейлист не существует.",
|
"Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
|
||||||
"Could not pull trending pages.": "Невозможно получить страницы \"в тренде\".",
|
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
|
||||||
"Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле \"challenge\"",
|
"Invalid challenge": "Неправильный ответ в \"challenge\"",
|
||||||
"Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле \"токен\"",
|
"Invalid token": "Неправильный токен",
|
||||||
"Invalid challenge": "Неправильный ответ в \"challenge\"",
|
"Invalid user": "Недопустимое имя пользователя",
|
||||||
"Invalid token": "Неправильный токен",
|
"Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
|
||||||
"Invalid user": "Недопустимое имя пользователя",
|
"English": "Английский",
|
||||||
"Token is expired, please try again": "Срок действия токена истек, попробуйте позже",
|
"English (auto-generated)": "Английский (созданы автоматически)",
|
||||||
"English": "Английский",
|
"Afrikaans": "Африкаанс",
|
||||||
"English (auto-generated)": "Английский (созданы автоматически)",
|
"Albanian": "Албанский",
|
||||||
"Afrikaans": "Африкаанс",
|
"Amharic": "Амхарский",
|
||||||
"Albanian": "Албанский",
|
"Arabic": "Арабский",
|
||||||
"Amharic": "Амхарский",
|
"Armenian": "Армянский",
|
||||||
"Arabic": "Арабский",
|
"Azerbaijani": "Азербайджанский",
|
||||||
"Armenian": "Армянский",
|
"Bangla": "Бенгальский",
|
||||||
"Azerbaijani": "Азербайджанский",
|
"Basque": "Баскский",
|
||||||
"Bangla": "Бенгальский",
|
"Belarusian": "Белорусский",
|
||||||
"Basque": "Баскский",
|
"Bosnian": "Боснийский",
|
||||||
"Belarusian": "Белорусский",
|
"Bulgarian": "Болгарский",
|
||||||
"Bosnian": "Боснийский",
|
"Burmese": "Бирманский",
|
||||||
"Bulgarian": "Болгарский",
|
"Catalan": "Каталонский",
|
||||||
"Burmese": "Бирманский",
|
"Cebuano": "Себуанский",
|
||||||
"Catalan": "Каталонский",
|
"Chinese (Simplified)": "Китайский (упрощенный)",
|
||||||
"Cebuano": "Себуанский",
|
"Chinese (Traditional)": "Китайский (традиционный)",
|
||||||
"Chinese (Simplified)": "Китайский (упрощенный)",
|
"Corsican": "Корсиканский",
|
||||||
"Chinese (Traditional)": "Китайский (традиционный)",
|
"Croatian": "Хорватский",
|
||||||
"Corsican": "Корсиканский",
|
"Czech": "Чешский",
|
||||||
"Croatian": "Хорватский",
|
"Danish": "Датский",
|
||||||
"Czech": "Чешский",
|
"Dutch": "Нидерландский",
|
||||||
"Danish": "Датский",
|
"Esperanto": "Эсперанто",
|
||||||
"Dutch": "Нидерландский",
|
"Estonian": "Эстонский",
|
||||||
"Esperanto": "Эсперанто",
|
"Filipino": "Филиппинский",
|
||||||
"Estonian": "Эстонский",
|
"Finnish": "Финский",
|
||||||
"Filipino": "Филиппинский",
|
"French": "Французский",
|
||||||
"Finnish": "Финский",
|
"Galician": "Галисийский",
|
||||||
"French": "Французский",
|
"Georgian": "Грузинский",
|
||||||
"Galician": "Галисийский",
|
"German": "Немецкий",
|
||||||
"Georgian": "Грузинский",
|
"Greek": "Греческий",
|
||||||
"German": "Немецкий",
|
"Gujarati": "Гуджаратский",
|
||||||
"Greek": "Греческий",
|
"Haitian Creole": "Гаит. креольский",
|
||||||
"Gujarati": "Гуджаратский",
|
"Hausa": "Хауса",
|
||||||
"Haitian Creole": "Гаит. креольский",
|
"Hawaiian": "Гавайский",
|
||||||
"Hausa": "Хауса",
|
"Hebrew": "Иврит",
|
||||||
"Hawaiian": "Гавайский",
|
"Hindi": "Хинди",
|
||||||
"Hebrew": "Иврит",
|
"Hmong": "Хмонг (мяо)",
|
||||||
"Hindi": "Хинди",
|
"Hungarian": "Венгерский",
|
||||||
"Hmong": "Хмонг (мяо)",
|
"Icelandic": "Исландский",
|
||||||
"Hungarian": "Венгерский",
|
"Igbo": "Игбо",
|
||||||
"Icelandic": "Исландский",
|
"Indonesian": "Индонезийский",
|
||||||
"Igbo": "Игбо",
|
"Irish": "Ирландский",
|
||||||
"Indonesian": "Индонезийский",
|
"Italian": "Итальянский",
|
||||||
"Irish": "Ирландский",
|
"Japanese": "Японский",
|
||||||
"Italian": "Итальянский",
|
"Javanese": "Яванский",
|
||||||
"Japanese": "Японский",
|
"Kannada": "Каннада",
|
||||||
"Javanese": "Яванский",
|
"Kazakh": "Казахский",
|
||||||
"Kannada": "Каннада",
|
"Khmer": "Кхмерский",
|
||||||
"Kazakh": "Казахский",
|
"Korean": "Корейский",
|
||||||
"Khmer": "Кхмерский",
|
"Kurdish": "Курдский",
|
||||||
"Korean": "Корейский",
|
"Kyrgyz": "Киргизский",
|
||||||
"Kurdish": "Курдский",
|
"Lao": "Лаосский",
|
||||||
"Kyrgyz": "Киргизский",
|
"Latin": "Латинский",
|
||||||
"Lao": "Лаосский",
|
"Latvian": "Латышский",
|
||||||
"Latin": "Латинский",
|
"Lithuanian": "Литовский",
|
||||||
"Latvian": "Латышский",
|
"Luxembourgish": "Люксембургский",
|
||||||
"Lithuanian": "Литовский",
|
"Macedonian": "Македонский",
|
||||||
"Luxembourgish": "Люксембургский",
|
"Malagasy": "Малагасийский",
|
||||||
"Macedonian": "Македонский",
|
"Malay": "Малайский",
|
||||||
"Malagasy": "Малагасийский",
|
"Malayalam": "Малаялам",
|
||||||
"Malay": "Малайский",
|
"Maltese": "Мальтийский",
|
||||||
"Malayalam": "Малаялам",
|
"Maori": "Маори",
|
||||||
"Maltese": "Мальтийский",
|
"Marathi": "Маратхи",
|
||||||
"Maori": "Маори",
|
"Mongolian": "Монгольская",
|
||||||
"Marathi": "Маратхи",
|
"Nepali": "Непальский",
|
||||||
"Mongolian": "Монгольская",
|
"Norwegian": "Норвежский",
|
||||||
"Nepali": "Непальский",
|
"Nyanja": "Ньянджа",
|
||||||
"Norwegian": "Норвежский",
|
"Pashto": "Пушту",
|
||||||
"Nyanja": "Ньянджа",
|
"Persian": "Персидский",
|
||||||
"Pashto": "Пушту",
|
"Polish": "Польский",
|
||||||
"Persian": "Персидский",
|
"Portuguese": "Португальский",
|
||||||
"Polish": "Польский",
|
"Punjabi": "Панджаби",
|
||||||
"Portuguese": "Португальский",
|
"Romanian": "Румынский",
|
||||||
"Punjabi": "Панджаби",
|
"Russian": "Русский",
|
||||||
"Romanian": "Румынский",
|
"Samoan": "Самоанский",
|
||||||
"Russian": "Русский",
|
"Scottish Gaelic": "Шотландский (гэльский)",
|
||||||
"Samoan": "Самоанский",
|
"Serbian": "Сербский",
|
||||||
"Scottish Gaelic": "Шотландский (гэльский)",
|
"Shona": "Шона",
|
||||||
"Serbian": "Сербский",
|
"Sindhi": "Синдхи",
|
||||||
"Shona": "Шона",
|
"Sinhala": "Сингальский",
|
||||||
"Sindhi": "Синдхи",
|
"Slovak": "Словацкий",
|
||||||
"Sinhala": "Сингальский",
|
"Slovenian": "Словенский",
|
||||||
"Slovak": "Словацкий",
|
"Somali": "Сомалийский",
|
||||||
"Slovenian": "Словенский",
|
"Southern Sotho": "Сесото (южный сото)",
|
||||||
"Somali": "Сомалийский",
|
"Spanish": "Испанский",
|
||||||
"Southern Sotho": "Сесото (южный сото)",
|
"Spanish (Latin America)": "Испанский (Латинская Америка)",
|
||||||
"Spanish": "Испанский",
|
"Sundanese": "Сунданский",
|
||||||
"Spanish (Latin America)": "Испанский (Латинская Америка)",
|
"Swahili": "Суахили",
|
||||||
"Sundanese": "Сунданский",
|
"Swedish": "Шведский",
|
||||||
"Swahili": "Суахили",
|
"Tajik": "Таджикский",
|
||||||
"Swedish": "Шведский",
|
"Tamil": "Тамильский",
|
||||||
"Tajik": "Таджикский",
|
"Telugu": "Телугу",
|
||||||
"Tamil": "Тамильский",
|
"Thai": "Тайский",
|
||||||
"Telugu": "Телугу",
|
"Turkish": "Турецкий",
|
||||||
"Thai": "Тайский",
|
"Ukrainian": "Украинский",
|
||||||
"Turkish": "Турецкий",
|
"Urdu": "Урду",
|
||||||
"Ukrainian": "Украинский",
|
"Uzbek": "Узбекский",
|
||||||
"Urdu": "Урду",
|
"Vietnamese": "Вьетнамский",
|
||||||
"Uzbek": "Узбекский",
|
"Welsh": "Валлийский",
|
||||||
"Vietnamese": "Вьетнамский",
|
"Western Frisian": "Западнофризский",
|
||||||
"Welsh": "Валлийский",
|
"Xhosa": "Коса",
|
||||||
"Western Frisian": "Западнофризский",
|
"Yiddish": "Идиш",
|
||||||
"Xhosa": "Коса",
|
"Yoruba": "Йоруба",
|
||||||
"Yiddish": "Идиш",
|
"Zulu": "Зулусский",
|
||||||
"Yoruba": "Йоруба",
|
"`x` years": "`x` лет",
|
||||||
"Zulu": "Зулусский",
|
"`x` months": "`x` месяцев",
|
||||||
"`x` years": "`x` лет",
|
"`x` weeks": "`x` недель",
|
||||||
"`x` months": "`x` месяцев",
|
"`x` days": "`x` дней",
|
||||||
"`x` weeks": "`x` недель",
|
"`x` hours": "`x` часов",
|
||||||
"`x` days": "`x` дней",
|
"`x` minutes": "`x` минут",
|
||||||
"`x` hours": "`x` часов",
|
"`x` seconds": "`x` секунд",
|
||||||
"`x` minutes": "`x` минут",
|
"Fallback comments: ": "Резервные комментарии: ",
|
||||||
"`x` seconds": "`x` секунд",
|
"Popular": "Популярное",
|
||||||
"Fallback comments: ": "Резервные комментарии: ",
|
"Top": "Топ",
|
||||||
"Popular": "Популярное",
|
"About": "О сайте",
|
||||||
"Top": "Топ",
|
"Rating: ": "Рейтинг: ",
|
||||||
"About": "О сайте",
|
"Language: ": "Язык: ",
|
||||||
"Rating: ": "Рейтинг: ",
|
"Default": "По-умолчанию",
|
||||||
"Language: ": "Язык: ",
|
"Music": "Музыка",
|
||||||
"Default": "По-умолчанию",
|
"Gaming": "Игры",
|
||||||
"Music": "Музыка",
|
"News": "Новости",
|
||||||
"Gaming": "Игры",
|
"Movies": "Фильмы",
|
||||||
"News": "Новости",
|
"Download": "Скачать",
|
||||||
"Movies": "Фильмы",
|
"Download as: ": "Скачать как: ",
|
||||||
"Download": "Скачать",
|
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||||
"Download as: ": "Скачать как: ",
|
"(edited)": "(изменено)",
|
||||||
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
"Youtube permalink of the comment": "Прямая ссылка на YouTube",
|
||||||
"(edited)": "(изменено)",
|
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
|
||||||
"Youtube permalink of the comment": "Прямая ссылка на YouTube",
|
"Audio mode": "Аудио режим",
|
||||||
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
|
"Video mode": "Видео режим",
|
||||||
"Audio mode": "Аудио режим",
|
"Videos": "Видео",
|
||||||
"Video mode": "Видео режим"
|
"Playlists": "Плейлисты",
|
||||||
|
"Current version: ": "Текущая версия: "
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
name: invidious
|
name: invidious
|
||||||
version: 0.14.1
|
version: 0.15.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Omar Roth <omarroth@hotmail.com>
|
- Omar Roth <omarroth@protonmail.com>
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
invidious:
|
invidious:
|
||||||
|
326
src/invidious.cr
326
src/invidious.cr
@ -1,5 +1,5 @@
|
|||||||
# "Invidious" (which is an alternative front-end to YouTube)
|
# "Invidious" (which is an alternative front-end to YouTube)
|
||||||
# Copyright (C) 2018 Omar Roth
|
# Copyright (C) 2019 Omar Roth
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
# it under the terms of the GNU Affero General Public License as published
|
||||||
@ -17,6 +17,7 @@
|
|||||||
require "digest/md5"
|
require "digest/md5"
|
||||||
require "file_utils"
|
require "file_utils"
|
||||||
require "kemal"
|
require "kemal"
|
||||||
|
require "markdown"
|
||||||
require "openssl/hmac"
|
require "openssl/hmac"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "pg"
|
require "pg"
|
||||||
@ -35,14 +36,6 @@ logger = Invidious::LogHandler.new
|
|||||||
|
|
||||||
Kemal.config.extra_options do |parser|
|
Kemal.config.extra_options do |parser|
|
||||||
parser.banner = "Usage: invidious [arguments]"
|
parser.banner = "Usage: invidious [arguments]"
|
||||||
parser.on("-t THREADS", "--crawl-threads=THREADS", "Number of threads for crawling YouTube (default: #{config.crawl_threads})") do |number|
|
|
||||||
begin
|
|
||||||
config.crawl_threads = number.to_i
|
|
||||||
rescue ex
|
|
||||||
puts "THREADS must be integer"
|
|
||||||
exit
|
|
||||||
end
|
|
||||||
end
|
|
||||||
parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number|
|
parser.on("-c THREADS", "--channel-threads=THREADS", "Number of threads for refreshing channels (default: #{config.channel_threads})") do |number|
|
||||||
begin
|
begin
|
||||||
config.channel_threads = number.to_i
|
config.channel_threads = number.to_i
|
||||||
@ -59,14 +52,6 @@ Kemal.config.extra_options do |parser|
|
|||||||
exit
|
exit
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
parser.on("-v THREADS", "--video-threads=THREADS", "Number of threads for refreshing videos (default: #{config.video_threads})") do |number|
|
|
||||||
begin
|
|
||||||
config.video_threads = number.to_i
|
|
||||||
rescue ex
|
|
||||||
puts "THREADS must be integer"
|
|
||||||
exit
|
|
||||||
end
|
|
||||||
end
|
|
||||||
parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output|
|
parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output|
|
||||||
FileUtils.mkdir_p(File.dirname(output))
|
FileUtils.mkdir_p(File.dirname(output))
|
||||||
logger = Invidious::LogHandler.new(File.open(output, mode: "a"))
|
logger = Invidious::LogHandler.new(File.open(output, mode: "a"))
|
||||||
@ -79,10 +64,10 @@ YT_URL = URI.parse("https://www.youtube.com")
|
|||||||
REDDIT_URL = URI.parse("https://www.reddit.com")
|
REDDIT_URL = URI.parse("https://www.reddit.com")
|
||||||
LOGIN_URL = URI.parse("https://accounts.google.com")
|
LOGIN_URL = URI.parse("https://accounts.google.com")
|
||||||
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
|
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
|
||||||
TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@hotmail.com.json")
|
TEXTCAPTCHA_URL = URI.parse("http://textcaptcha.com/omarroth@protonmail.com.json")
|
||||||
CURRENT_COMMIT = `git rev-list HEAD --max-count=1 --abbrev-commit`.strip
|
CURRENT_BRANCH = {{ "#{`git branch | sed -n '/\* /s///p'`.strip}" }}
|
||||||
CURRENT_VERSION = `git describe --tags $(git rev-list --tags --max-count=1)`.strip
|
CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
|
||||||
CURRENT_BRANCH = `git status | head -1`.strip
|
CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }}
|
||||||
|
|
||||||
LOCALES = {
|
LOCALES = {
|
||||||
"ar" => load_locale("ar"),
|
"ar" => load_locale("ar"),
|
||||||
@ -110,8 +95,6 @@ spawn do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
proxies = PROXY_LIST
|
|
||||||
|
|
||||||
before_all do |env|
|
before_all do |env|
|
||||||
env.response.headers["X-XSS-Protection"] = "1; mode=block;"
|
env.response.headers["X-XSS-Protection"] = "1; mode=block;"
|
||||||
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
@ -126,24 +109,22 @@ end
|
|||||||
get "/api/v1/stats" do |env|
|
get "/api/v1/stats" do |env|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
if statistics["error"]?
|
|
||||||
halt env, status_code: 500, response: statistics.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
if !config.statistics_enabled
|
if !config.statistics_enabled
|
||||||
error_message = {"error" => "Statistics are not enabled."}.to_json
|
error_message = {"error" => "Statistics are not enabled."}.to_json
|
||||||
halt env, status_code: 400, response: error_message
|
env.response.status_code = 400
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
if statistics["error"]?
|
||||||
statistics.to_pretty_json
|
env.response.status_code = 500
|
||||||
else
|
next statistics.to_json
|
||||||
statistics.to_json
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
statistics.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/captions/:id" do |env|
|
get "/api/v1/captions/:id" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -156,7 +137,8 @@ get "/api/v1/captions/:id" do |env|
|
|||||||
rescue ex : VideoRedirect
|
rescue ex : VideoRedirect
|
||||||
next env.redirect "/api/v1/captions/#{ex.message}"
|
next env.redirect "/api/v1/captions/#{ex.message}"
|
||||||
rescue ex
|
rescue ex
|
||||||
halt env, status_code: 500
|
env.response.status_code = 500
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
captions = video.captions
|
captions = video.captions
|
||||||
@ -182,11 +164,7 @@ get "/api/v1/captions/:id" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
next response
|
||||||
next JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
next response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
env.response.content_type = "text/vtt"
|
env.response.content_type = "text/vtt"
|
||||||
@ -198,7 +176,8 @@ get "/api/v1/captions/:id" do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
if caption.empty?
|
if caption.empty?
|
||||||
halt env, status_code: 404
|
env.response.status_code = 404
|
||||||
|
next
|
||||||
else
|
else
|
||||||
caption = caption[0]
|
caption = caption[0]
|
||||||
end
|
end
|
||||||
@ -248,7 +227,7 @@ get "/api/v1/captions/:id" do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/comments/:id" do |env|
|
get "/api/v1/comments/:id" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
region = env.params.query["region"]?
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
@ -268,7 +247,8 @@ get "/api/v1/comments/:id" do |env|
|
|||||||
comments = fetch_youtube_comments(id, continuation, proxies, format, locale, region)
|
comments = fetch_youtube_comments(id, continuation, proxies, format, locale, region)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
next comments
|
next comments
|
||||||
@ -286,18 +266,15 @@ get "/api/v1/comments/:id" do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
if !reddit_thread || !comments
|
if !reddit_thread || !comments
|
||||||
halt env, status_code: 404
|
env.response.status_code = 404
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
if format == "json"
|
if format == "json"
|
||||||
reddit_thread = JSON.parse(reddit_thread.to_json).as_h
|
reddit_thread = JSON.parse(reddit_thread.to_json).as_h
|
||||||
reddit_thread["comments"] = JSON.parse(comments.to_json)
|
reddit_thread["comments"] = JSON.parse(comments.to_json)
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
next reddit_thread.to_json
|
||||||
next reddit_thread.to_pretty_json
|
|
||||||
else
|
|
||||||
next reddit_thread.to_json
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
response = {
|
response = {
|
||||||
"title" => reddit_thread.title,
|
"title" => reddit_thread.title,
|
||||||
@ -305,23 +282,20 @@ get "/api/v1/comments/:id" do |env|
|
|||||||
"contentHtml" => content_html,
|
"contentHtml" => content_html,
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
next response.to_json
|
||||||
next response.to_pretty_json
|
|
||||||
else
|
|
||||||
next response.to_json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/insights/:id" do |env|
|
get "/api/v1/insights/:id" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json
|
error_message = {"error" => "YouTube has removed publicly-available analytics."}.to_json
|
||||||
halt env, status_code: 410, response: error_message
|
env.response.status_code = 410
|
||||||
|
next error_message
|
||||||
|
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
@ -398,15 +372,11 @@ get "/api/v1/insights/:id" do |env|
|
|||||||
"graphData" => graph_data,
|
"graphData" => graph_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
next response.to_json
|
||||||
next response.to_pretty_json
|
|
||||||
else
|
|
||||||
next response.to_json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/videos/:id" do |env|
|
get "/api/v1/videos/:id" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -419,7 +389,8 @@ get "/api/v1/videos/:id" do |env|
|
|||||||
next env.redirect "/api/v1/videos/#{ex.message}"
|
next env.redirect "/api/v1/videos/#{ex.message}"
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
fmt_stream = video.fmt_stream(decrypt_function)
|
fmt_stream = video.fmt_stream(decrypt_function)
|
||||||
@ -432,7 +403,7 @@ get "/api/v1/videos/:id" do |env|
|
|||||||
json.field "title", video.title
|
json.field "title", video.title
|
||||||
json.field "videoId", video.id
|
json.field "videoId", video.id
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
video.description, description = html_to_content(video.description)
|
video.description, description = html_to_content(video.description)
|
||||||
@ -475,19 +446,18 @@ get "/api/v1/videos/:id" do |env|
|
|||||||
json.field "subCountText", video.sub_count_text
|
json.field "subCountText", video.sub_count_text
|
||||||
|
|
||||||
json.field "lengthSeconds", video.info["length_seconds"].to_i
|
json.field "lengthSeconds", video.info["length_seconds"].to_i
|
||||||
if video.info["allow_ratings"]?
|
json.field "allowRatings", video.allow_ratings
|
||||||
json.field "allowRatings", video.info["allow_ratings"] == "1"
|
|
||||||
else
|
|
||||||
json.field "allowRatings", false
|
|
||||||
end
|
|
||||||
json.field "rating", video.info["avg_rating"].to_f32
|
json.field "rating", video.info["avg_rating"].to_f32
|
||||||
|
json.field "isListed", video.is_listed
|
||||||
|
json.field "liveNow", video.live_now
|
||||||
|
json.field "isUpcoming", video.is_upcoming
|
||||||
|
|
||||||
if video.info["is_listed"]?
|
if video.premiere_timestamp
|
||||||
json.field "isListed", video.info["is_listed"] == "1"
|
json.field "premiereTimestamp", video.premiere_timestamp.not_nil!.to_unix
|
||||||
end
|
end
|
||||||
|
|
||||||
if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
if video.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
||||||
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
|
host_url = make_host_url(config, Kemal.config)
|
||||||
|
|
||||||
host_params = env.request.query_params
|
host_params = env.request.query_params
|
||||||
host_params.delete_all("v")
|
host_params.delete_all("v")
|
||||||
@ -595,7 +565,7 @@ get "/api/v1/videos/:id" do |env|
|
|||||||
json.field "videoId", rv["id"]
|
json.field "videoId", rv["id"]
|
||||||
json.field "title", rv["title"]
|
json.field "title", rv["title"]
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, rv["id"])
|
generate_thumbnails(json, rv["id"], config, Kemal.config)
|
||||||
end
|
end
|
||||||
json.field "author", rv["author"]
|
json.field "author", rv["author"]
|
||||||
json.field "lengthSeconds", rv["length_seconds"].to_i
|
json.field "lengthSeconds", rv["length_seconds"].to_i
|
||||||
@ -608,15 +578,11 @@ get "/api/v1/videos/:id" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
video_info
|
||||||
JSON.parse(video_info).to_pretty_json
|
|
||||||
else
|
|
||||||
video_info
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/trending" do |env|
|
get "/api/v1/trending" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -627,7 +593,8 @@ get "/api/v1/trending" do |env|
|
|||||||
trending = fetch_trending(trending_type, proxies, region, locale)
|
trending = fetch_trending(trending_type, proxies, region, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
videos = JSON.build do |json|
|
videos = JSON.build do |json|
|
||||||
@ -637,7 +604,7 @@ get "/api/v1/trending" do |env|
|
|||||||
json.field "title", video.title
|
json.field "title", video.title
|
||||||
json.field "videoId", video.id
|
json.field "videoId", video.id
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "lengthSeconds", video.length_seconds
|
json.field "lengthSeconds", video.length_seconds
|
||||||
@ -659,15 +626,11 @@ get "/api/v1/trending" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
videos
|
||||||
JSON.parse(videos).to_pretty_json
|
|
||||||
else
|
|
||||||
videos
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/channels/:ucid" do |env|
|
get "/api/v1/channels/:ucid" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -679,7 +642,8 @@ get "/api/v1/channels/:ucid" do |env|
|
|||||||
author, ucid, auto_generated = get_about_info(ucid, locale)
|
author, ucid, auto_generated = get_about_info(ucid, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
@ -691,7 +655,8 @@ get "/api/v1/channels/:ucid" do |env|
|
|||||||
videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
|
videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -820,7 +785,7 @@ get "/api/v1/channels/:ucid" do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", video.description
|
json.field "description", video.description
|
||||||
@ -866,16 +831,12 @@ get "/api/v1/channels/:ucid" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
channel_info
|
||||||
JSON.parse(channel_info).to_pretty_json
|
|
||||||
else
|
|
||||||
channel_info
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
["/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"].each do |route|
|
["/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"].each do |route|
|
||||||
get route do |env|
|
get route do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -890,14 +851,16 @@ end
|
|||||||
author, ucid, auto_generated = get_about_info(ucid, locale)
|
author, ucid, auto_generated = get_about_info(ucid, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
|
videos, count = get_60_videos(ucid, page, auto_generated, sort_by)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
result = JSON.build do |json|
|
result = JSON.build do |json|
|
||||||
@ -918,7 +881,7 @@ end
|
|||||||
end
|
end
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", video.description
|
json.field "description", video.description
|
||||||
@ -936,17 +899,13 @@ end
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
result
|
||||||
JSON.parse(result).to_pretty_json
|
|
||||||
else
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route|
|
["/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"].each do |route|
|
||||||
get route do |env|
|
get route do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -956,7 +915,8 @@ end
|
|||||||
videos = get_latest_videos(ucid)
|
videos = get_latest_videos(ucid)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
response = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
@ -970,7 +930,7 @@ end
|
|||||||
json.field "authorUrl", "/channel/#{ucid}"
|
json.field "authorUrl", "/channel/#{ucid}"
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", video.description
|
json.field "description", video.description
|
||||||
@ -988,17 +948,13 @@ end
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route|
|
["/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"].each do |route|
|
||||||
get route do |env|
|
get route do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -1011,8 +967,9 @@ end
|
|||||||
begin
|
begin
|
||||||
author, ucid, auto_generated = get_about_info(ucid, locale)
|
author, ucid, auto_generated = get_about_info(ucid, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = ex.message
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
|
items, continuation = fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
|
||||||
@ -1041,7 +998,7 @@ end
|
|||||||
json.field "lengthSeconds", video.length_seconds
|
json.field "lengthSeconds", video.length_seconds
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -1057,16 +1014,12 @@ end
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/channels/search/:ucid" do |env|
|
get "/api/v1/channels/search/:ucid" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -1094,7 +1047,7 @@ get "/api/v1/channels/search/:ucid" do |env|
|
|||||||
json.field "authorUrl", "/channel/#{item.ucid}"
|
json.field "authorUrl", "/channel/#{item.ucid}"
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, item.id)
|
generate_thumbnails(json, item.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", item.description
|
json.field "description", item.description
|
||||||
@ -1126,7 +1079,7 @@ get "/api/v1/channels/search/:ucid" do |env|
|
|||||||
json.field "lengthSeconds", video.length_seconds
|
json.field "lengthSeconds", video.length_seconds
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -1162,15 +1115,11 @@ get "/api/v1/channels/search/:ucid" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/search" do |env|
|
get "/api/v1/search" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
region = env.params.query["region"]?
|
region = env.params.query["region"]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
@ -1223,7 +1172,7 @@ get "/api/v1/search" do |env|
|
|||||||
json.field "authorUrl", "/channel/#{item.ucid}"
|
json.field "authorUrl", "/channel/#{item.ucid}"
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, item.id)
|
generate_thumbnails(json, item.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", item.description
|
json.field "description", item.description
|
||||||
@ -1255,7 +1204,7 @@ get "/api/v1/search" do |env|
|
|||||||
json.field "lengthSeconds", video.length_seconds
|
json.field "lengthSeconds", video.length_seconds
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -1291,15 +1240,11 @@ get "/api/v1/search" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/playlists/:plid" do |env|
|
get "/api/v1/playlists/:plid" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
plid = env.params.url["plid"]
|
plid = env.params.url["plid"]
|
||||||
@ -1320,7 +1265,8 @@ get "/api/v1/playlists/:plid" do |env|
|
|||||||
playlist = fetch_playlist(plid, locale)
|
playlist = fetch_playlist(plid, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => "Playlist is empty"}.to_json
|
error_message = {"error" => "Playlist is empty"}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
@ -1371,7 +1317,7 @@ get "/api/v1/playlists/:plid" do |env|
|
|||||||
json.field "authorUrl", "/channel/#{video.ucid}"
|
json.field "authorUrl", "/channel/#{video.ucid}"
|
||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "index", video.index
|
json.field "index", video.index
|
||||||
@ -1394,15 +1340,11 @@ get "/api/v1/playlists/:plid" do |env|
|
|||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/mixes/:rdid" do |env|
|
get "/api/v1/mixes/:rdid" do |env|
|
||||||
locale = LOCALES[env.get("locale").as(String)]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
@ -1426,7 +1368,8 @@ get "/api/v1/mixes/:rdid" do |env|
|
|||||||
mix.videos = mix.videos[index..-1]
|
mix.videos = mix.videos[index..-1]
|
||||||
rescue ex
|
rescue ex
|
||||||
error_message = {"error" => ex.message}.to_json
|
error_message = {"error" => ex.message}.to_json
|
||||||
halt env, status_code: 500, response: error_message
|
env.response.status_code = 500
|
||||||
|
next error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
response = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
@ -1447,7 +1390,7 @@ get "/api/v1/mixes/:rdid" do |env|
|
|||||||
|
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
json.array do
|
json.array do
|
||||||
generate_thumbnails(json, video.id)
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1472,11 +1415,7 @@ get "/api/v1/mixes/:rdid" do |env|
|
|||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
response
|
||||||
JSON.parse(response).to_pretty_json
|
|
||||||
else
|
|
||||||
response
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/manifest/dash/id/videoplayback" do |env|
|
get "/api/manifest/dash/id/videoplayback" do |env|
|
||||||
@ -1508,10 +1447,11 @@ get "/api/manifest/dash/id/:id" do |env|
|
|||||||
|
|
||||||
next env.redirect url
|
next env.redirect url
|
||||||
rescue ex
|
rescue ex
|
||||||
halt env, status_code: 403
|
env.response.status_code = 403
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
if dashmpd = video.player_response["streamingData"]["dashManifestUrl"]?.try &.as_s
|
if dashmpd = video.player_response["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s
|
||||||
manifest = client.get(dashmpd).body
|
manifest = client.get(dashmpd).body
|
||||||
|
|
||||||
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
|
||||||
@ -1595,13 +1535,14 @@ get "/api/manifest/hls_variant/*" do |env|
|
|||||||
manifest = client.get(env.request.path)
|
manifest = client.get(env.request.path)
|
||||||
|
|
||||||
if manifest.status_code != 200
|
if manifest.status_code != 200
|
||||||
halt env, status_code: manifest.status_code
|
env.response.status_code = manifest.status_code
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
env.response.content_type = "application/x-mpegURL"
|
env.response.content_type = "application/x-mpegURL"
|
||||||
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
env.response.headers.add("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
|
host_url = make_host_url(config, Kemal.config)
|
||||||
|
|
||||||
manifest = manifest.body
|
manifest = manifest.body
|
||||||
manifest.gsub("https://www.youtube.com", host_url)
|
manifest.gsub("https://www.youtube.com", host_url)
|
||||||
@ -1612,10 +1553,11 @@ get "/api/manifest/hls_playlist/*" do |env|
|
|||||||
manifest = client.get(env.request.path)
|
manifest = client.get(env.request.path)
|
||||||
|
|
||||||
if manifest.status_code != 200
|
if manifest.status_code != 200
|
||||||
halt env, status_code: manifest.status_code
|
env.response.status_code = manifest.status_code
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
|
host_url = make_host_url(config, Kemal.config)
|
||||||
|
|
||||||
manifest = manifest.body.gsub("https://www.youtube.com", host_url)
|
manifest = manifest.body.gsub("https://www.youtube.com", host_url)
|
||||||
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)
|
manifest = manifest.gsub(/https:\/\/r\d---.{11}\.c\.youtube\.com/, host_url)
|
||||||
@ -1648,7 +1590,8 @@ get "/latest_version" do |env|
|
|||||||
local = local == "true"
|
local = local == "true"
|
||||||
|
|
||||||
if !id || !itag
|
if !id || !itag
|
||||||
halt env, status_code: 400
|
env.response.status_code = 400
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
video = fetch_video(id, proxies, region: region)
|
video = fetch_video(id, proxies, region: region)
|
||||||
@ -1658,9 +1601,11 @@ get "/latest_version" do |env|
|
|||||||
|
|
||||||
urls = (fmt_stream + adaptive_fmts).select { |fmt| fmt["itag"] == itag }
|
urls = (fmt_stream + adaptive_fmts).select { |fmt| fmt["itag"] == itag }
|
||||||
if urls.empty?
|
if urls.empty?
|
||||||
halt env, status_code: 404
|
env.response.status_code = 404
|
||||||
|
next
|
||||||
elsif urls.size > 1
|
elsif urls.size > 1
|
||||||
halt env, status_code: 409
|
env.response.status_code = 409
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
url = urls[0]["url"]
|
url = urls[0]["url"]
|
||||||
@ -1734,24 +1679,39 @@ get "/videoplayback" do |env|
|
|||||||
query_params = env.params.query
|
query_params = env.params.query
|
||||||
|
|
||||||
fvip = query_params["fvip"]? || "3"
|
fvip = query_params["fvip"]? || "3"
|
||||||
mn = query_params["mn"].split(",")[-1]
|
mns = query_params["mn"].split(",")
|
||||||
host = "https://r#{fvip}---#{mn}.googlevideo.com"
|
|
||||||
|
if query_params["host"]? && !query_params["host"].empty?
|
||||||
|
host = "https://#{query_params["host"]}"
|
||||||
|
query_params.delete("host")
|
||||||
|
else
|
||||||
|
host = "https://r#{fvip}---#{mns.pop}.googlevideo.com"
|
||||||
|
end
|
||||||
|
|
||||||
url = "/videoplayback?#{query_params.to_s}"
|
url = "/videoplayback?#{query_params.to_s}"
|
||||||
|
|
||||||
headers = env.request.headers
|
headers = HTTP::Headers.new
|
||||||
headers.delete("Host")
|
{"Accept", "Accept-Encoding", "Connection", "Range"}.each do |header|
|
||||||
headers.delete("Cookie")
|
if env.request.headers[header]?
|
||||||
headers.delete("User-Agent")
|
headers[header] = env.request.headers[header]
|
||||||
headers.delete("Referer")
|
end
|
||||||
|
end
|
||||||
|
|
||||||
region = query_params["region"]?
|
region = query_params["region"]?
|
||||||
|
|
||||||
response = HTTP::Client::Response.new(403)
|
response = HTTP::Client::Response.new(403)
|
||||||
loop do
|
5.times do
|
||||||
begin
|
begin
|
||||||
client = make_client(URI.parse(host), proxies, region)
|
client = make_client(URI.parse(host), proxies, region)
|
||||||
response = client.head(url, headers)
|
response = client.head(url, headers)
|
||||||
break
|
break
|
||||||
|
rescue Socket::Addrinfo::Error
|
||||||
|
if !mns.empty?
|
||||||
|
mn = mns.pop
|
||||||
|
end
|
||||||
|
fvip = "3"
|
||||||
|
|
||||||
|
host = "https://r#{fvip}---#{mn}.googlevideo.com"
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -1769,7 +1729,8 @@ get "/videoplayback" do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
if response.status_code >= 400
|
if response.status_code >= 400
|
||||||
halt env, status_code: response.status_code
|
env.response.status_code = response.status_code
|
||||||
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
client = make_client(URI.parse(host), proxies, region)
|
client = make_client(URI.parse(host), proxies, region)
|
||||||
@ -1801,6 +1762,7 @@ get "/videoplayback" do |env|
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# We need this so the below route works as expected
|
||||||
get "/ggpht*" do |env|
|
get "/ggpht*" do |env|
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1809,11 +1771,12 @@ get "/ggpht/*" do |env|
|
|||||||
client = make_client(URI.parse(host))
|
client = make_client(URI.parse(host))
|
||||||
url = env.request.path.lchop("/ggpht")
|
url = env.request.path.lchop("/ggpht")
|
||||||
|
|
||||||
headers = env.request.headers
|
headers = HTTP::Headers.new
|
||||||
headers.delete("Host")
|
{"Range", "Accept", "Accept-Encoding"}.each do |header|
|
||||||
headers.delete("Cookie")
|
if env.request.headers[header]?
|
||||||
headers.delete("User-Agent")
|
headers[header] = env.request.headers[header]
|
||||||
headers.delete("Referer")
|
end
|
||||||
|
end
|
||||||
|
|
||||||
client.get(url, headers) do |response|
|
client.get(url, headers) do |response|
|
||||||
env.response.status_code = response.status_code
|
env.response.status_code = response.status_code
|
||||||
@ -1858,7 +1821,7 @@ get "/vi/:id/:name" do |env|
|
|||||||
client = make_client(URI.parse(host))
|
client = make_client(URI.parse(host))
|
||||||
|
|
||||||
if name == "maxres.jpg"
|
if name == "maxres.jpg"
|
||||||
VIDEO_THUMBNAILS.each do |thumb|
|
build_thumbnails(id, config, Kemal.config).each do |thumb|
|
||||||
if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200
|
if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200
|
||||||
name = thumb[:url] + ".jpg"
|
name = thumb[:url] + ".jpg"
|
||||||
break
|
break
|
||||||
@ -1867,11 +1830,12 @@ get "/vi/:id/:name" do |env|
|
|||||||
end
|
end
|
||||||
url = "/vi/#{id}/#{name}"
|
url = "/vi/#{id}/#{name}"
|
||||||
|
|
||||||
headers = env.request.headers
|
headers = HTTP::Headers.new
|
||||||
headers.delete("Host")
|
{"Range", "Accept", "Accept-Encoding"}.each do |header|
|
||||||
headers.delete("Cookie")
|
if env.request.headers[header]?
|
||||||
headers.delete("User-Agent")
|
headers[header] = env.request.headers[header]
|
||||||
headers.delete("Referer")
|
end
|
||||||
|
end
|
||||||
|
|
||||||
client.get(url, headers) do |response|
|
client.get(url, headers) do |response|
|
||||||
env.response.status_code = response.status_code
|
env.response.status_code = response.status_code
|
||||||
|
@ -10,13 +10,15 @@ end
|
|||||||
|
|
||||||
class ChannelVideo
|
class ChannelVideo
|
||||||
add_mapping({
|
add_mapping({
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
published: Time,
|
published: Time,
|
||||||
updated: Time,
|
updated: Time,
|
||||||
ucid: String,
|
ucid: String,
|
||||||
author: String,
|
author: String,
|
||||||
length_seconds: {type: Int32, default: 0},
|
length_seconds: {type: Int32, default: 0},
|
||||||
|
live_now: {type: Bool, default: false},
|
||||||
|
premiere_timestamp: {type: Time?, default: nil},
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -112,15 +114,32 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
rss.xpath_nodes("//feed/entry").each do |entry|
|
rss.xpath_nodes("//feed/entry").each do |entry|
|
||||||
video_id = entry.xpath_node("videoid").not_nil!.content
|
video_id = entry.xpath_node("videoid").not_nil!.content
|
||||||
title = entry.xpath_node("title").not_nil!.content
|
title = entry.xpath_node("title").not_nil!.content
|
||||||
published = Time.parse(entry.xpath_node("published").not_nil!.content, "%FT%X%z", Time::Location.local)
|
published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content)
|
||||||
updated = Time.parse(entry.xpath_node("updated").not_nil!.content, "%FT%X%z", Time::Location.local)
|
updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content)
|
||||||
author = entry.xpath_node("author/name").not_nil!.content
|
author = entry.xpath_node("author/name").not_nil!.content
|
||||||
ucid = entry.xpath_node("channelid").not_nil!.content
|
ucid = entry.xpath_node("channelid").not_nil!.content
|
||||||
|
|
||||||
length_seconds = videos.select { |video| video.id == video_id }[0]?.try &.length_seconds
|
channel_video = videos.select { |video| video.id == video_id }[0]?
|
||||||
|
|
||||||
|
length_seconds = channel_video.try &.length_seconds
|
||||||
length_seconds ||= 0
|
length_seconds ||= 0
|
||||||
|
|
||||||
video = ChannelVideo.new(video_id, title, published, Time.now, ucid, author, length_seconds)
|
live_now = channel_video.try &.live_now
|
||||||
|
live_now ||= false
|
||||||
|
|
||||||
|
premiere_timestamp = channel_video.try &.premiere_timestamp
|
||||||
|
|
||||||
|
video = ChannelVideo.new(
|
||||||
|
video_id,
|
||||||
|
title,
|
||||||
|
published,
|
||||||
|
Time.now,
|
||||||
|
ucid,
|
||||||
|
author,
|
||||||
|
length_seconds,
|
||||||
|
live_now,
|
||||||
|
premiere_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
db.exec("UPDATE users SET notifications = notifications || $1 \
|
db.exec("UPDATE users SET notifications = notifications || $1 \
|
||||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid)
|
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid)
|
||||||
@ -128,9 +147,12 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
video_array = video.to_a
|
video_array = video.to_a
|
||||||
args = arg_array(video_array)
|
args = arg_array(video_array)
|
||||||
|
|
||||||
|
# We don't include the 'premire_timestamp' here because channel pages don't include them,
|
||||||
|
# meaning the above timestamp is always null
|
||||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) \
|
db.exec("INSERT INTO channel_videos VALUES (#{args}) \
|
||||||
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
|
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
|
||||||
updated = $4, ucid = $5, author = $6, length_seconds = $7", video_array)
|
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
|
||||||
|
live_now = $8", video_array)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
page = 1
|
page = 1
|
||||||
@ -157,7 +179,17 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
end
|
end
|
||||||
|
|
||||||
count = nodeset.size
|
count = nodeset.size
|
||||||
videos = videos.map { |video| ChannelVideo.new(video.id, video.title, video.published, Time.now, video.ucid, video.author, video.length_seconds) }
|
videos = videos.map { |video| ChannelVideo.new(
|
||||||
|
video.id,
|
||||||
|
video.title,
|
||||||
|
video.published,
|
||||||
|
Time.now,
|
||||||
|
video.ucid,
|
||||||
|
video.author,
|
||||||
|
video.length_seconds,
|
||||||
|
video.live_now,
|
||||||
|
video.premiere_timestamp
|
||||||
|
) }
|
||||||
|
|
||||||
videos.each do |video|
|
videos.each do |video|
|
||||||
ids << video.id
|
ids << video.id
|
||||||
@ -170,8 +202,12 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
video_array = video.to_a
|
video_array = video.to_a
|
||||||
args = arg_array(video_array)
|
args = arg_array(video_array)
|
||||||
|
|
||||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \
|
# We don't include the 'premire_timestamp' here because channel pages don't include them,
|
||||||
published = $3, updated = $4, ucid = $5, author = $6, length_seconds = $7", video_array)
|
# meaning the above timestamp is always null
|
||||||
|
db.exec("INSERT INTO channel_videos VALUES (#{args}) \
|
||||||
|
ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \
|
||||||
|
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
|
||||||
|
live_now = $8", video_array)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -194,12 +230,14 @@ end
|
|||||||
def subscribe_pubsub(ucid, key, config)
|
def subscribe_pubsub(ucid, key, config)
|
||||||
client = make_client(PUBSUB_URL)
|
client = make_client(PUBSUB_URL)
|
||||||
time = Time.now.to_unix.to_s
|
time = Time.now.to_unix.to_s
|
||||||
|
nonce = Random::Secure.hex(4)
|
||||||
|
signature = "#{time}:#{nonce}"
|
||||||
|
|
||||||
host_url = make_host_url(Kemal.config.ssl || config.https_only, config.domain)
|
host_url = make_host_url(config, Kemal.config)
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"hub.callback" => "#{host_url}/feed/webhook/#{time}:#{OpenSSL::HMAC.hexdigest(:sha1, key, time)}",
|
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
||||||
"hub.topic" => "https://www.youtube.com/feeds/videos.xml?channel_id=#{ucid}",
|
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?channel_id=#{ucid}",
|
||||||
"hub.verify" => "async",
|
"hub.verify" => "async",
|
||||||
"hub.mode" => "subscribe",
|
"hub.mode" => "subscribe",
|
||||||
"hub.lease_seconds" => "432000",
|
"hub.lease_seconds" => "432000",
|
||||||
|
@ -308,13 +308,13 @@ def template_youtube_comments(comments, locale)
|
|||||||
<p>
|
<p>
|
||||||
<b>
|
<b>
|
||||||
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
|
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
|
||||||
</b>
|
</b>
|
||||||
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
|
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
|
||||||
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
||||||
|
|
|
|
||||||
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a>
|
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "Youtube permalink of the comment")}">[YT]</a>
|
||||||
|
|
|
|
||||||
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
|
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
|
||||||
END_HTML
|
END_HTML
|
||||||
|
|
||||||
if child["creatorHeart"]?
|
if child["creatorHeart"]?
|
||||||
@ -372,8 +372,8 @@ def template_reddit_comments(root, locale)
|
|||||||
|
|
||||||
content = <<-END_HTML
|
content = <<-END_HTML
|
||||||
<p>
|
<p>
|
||||||
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
|
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
|
||||||
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
|
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
|
||||||
#{translate(locale, "`x` points", number_with_separator(score))}
|
#{translate(locale, "`x` points", number_with_separator(score))}
|
||||||
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
|
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
|
||||||
</p>
|
</p>
|
||||||
|
133
src/invidious/helpers/handlers.cr
Normal file
133
src/invidious/helpers/handlers.cr
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
module HTTP::Handler
|
||||||
|
@@exclude_routes_tree = Radix::Tree(String).new
|
||||||
|
|
||||||
|
macro exclude(paths, method = "GET")
|
||||||
|
class_name = {{@type.name}}
|
||||||
|
method_downcase = {{method.downcase}}
|
||||||
|
class_name_method = "#{class_name}/#{method_downcase}"
|
||||||
|
({{paths}}).each do |path|
|
||||||
|
@@exclude_routes_tree.add class_name_method + path, '/' + method_downcase + path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def exclude_match?(env : HTTP::Server::Context)
|
||||||
|
@@exclude_routes_tree.find(radix_path(env.request.method, env.request.path)).found?
|
||||||
|
end
|
||||||
|
|
||||||
|
private def radix_path(method : String, path : String)
|
||||||
|
"#{self.class}/#{method.downcase}#{path}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Kemal::RouteHandler
|
||||||
|
exclude ["/api/v1/*"]
|
||||||
|
|
||||||
|
# Processes the route if it's a match. Otherwise renders 404.
|
||||||
|
private def process_request(context)
|
||||||
|
raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found?
|
||||||
|
content = context.route.handler.call(context)
|
||||||
|
|
||||||
|
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context)
|
||||||
|
raise Kemal::Exceptions::CustomException.new(context)
|
||||||
|
end
|
||||||
|
|
||||||
|
context.response.print(content)
|
||||||
|
context
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Kemal::ExceptionHandler
|
||||||
|
exclude ["/api/v1/*"]
|
||||||
|
|
||||||
|
private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
|
||||||
|
return if context.response.closed?
|
||||||
|
return if exclude_match? context
|
||||||
|
|
||||||
|
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
|
||||||
|
context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
|
||||||
|
context.response.status_code = status_code
|
||||||
|
context.response.print Kemal.config.error_handlers[status_code].call(context, exception)
|
||||||
|
context
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class FilteredCompressHandler < Kemal::Handler
|
||||||
|
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/ggpht/*"]
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
return call_next env if exclude_match? env
|
||||||
|
|
||||||
|
{% if flag?(:without_zlib) %}
|
||||||
|
call_next env
|
||||||
|
{% else %}
|
||||||
|
request_headers = env.request.headers
|
||||||
|
|
||||||
|
if request_headers.includes_word?("Accept-Encoding", "gzip")
|
||||||
|
env.response.headers["Content-Encoding"] = "gzip"
|
||||||
|
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
|
||||||
|
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
|
||||||
|
env.response.headers["Content-Encoding"] = "deflate"
|
||||||
|
env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
call_next env
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class APIHandler < Kemal::Handler
|
||||||
|
only ["/api/v1/*"]
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
return call_next env unless only_match? env
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
# Here we swap out the socket IO so we can modify the response as needed
|
||||||
|
output = env.response.output
|
||||||
|
env.response.output = IO::Memory.new
|
||||||
|
|
||||||
|
begin
|
||||||
|
call_next env
|
||||||
|
|
||||||
|
env.response.output.rewind
|
||||||
|
response = env.response.output.gets_to_end
|
||||||
|
|
||||||
|
if env.response.headers["Content-Type"]?.try &.== "application/json"
|
||||||
|
response = JSON.parse(response)
|
||||||
|
|
||||||
|
if env.params.query["pretty"]? && env.params.query["pretty"] == "1"
|
||||||
|
response = response.to_pretty_json
|
||||||
|
else
|
||||||
|
response = response.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
ensure
|
||||||
|
env.response.output = output
|
||||||
|
env.response.puts response
|
||||||
|
|
||||||
|
env.response.flush
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class DenyFrame < Kemal::Handler
|
||||||
|
exclude ["/embed/*"]
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
return call_next env if exclude_match? env
|
||||||
|
|
||||||
|
env.response.headers["X-Frame-Options"] = "sameorigin"
|
||||||
|
call_next env
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Temp fix for https://github.com/crystal-lang/crystal/issues/7383
|
||||||
|
class HTTP::Client
|
||||||
|
private def handle_response(response)
|
||||||
|
# close unless response.keep_alive?
|
||||||
|
response
|
||||||
|
end
|
||||||
|
end
|
@ -1,7 +1,5 @@
|
|||||||
class Config
|
class Config
|
||||||
YAML.mapping({
|
YAML.mapping({
|
||||||
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
|
|
||||||
crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
|
|
||||||
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||||
feed_threads: Int32, # Number of threads to use for updating feeds
|
feed_threads: Int32, # Number of threads to use for updating feeds
|
||||||
db: NamedTuple( # Database configuration
|
db: NamedTuple( # Database configuration
|
||||||
@ -17,63 +15,17 @@ user: String,
|
|||||||
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
|
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
|
||||||
use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
use_pubsub_feeds: {type: Bool, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||||
default_home: {type: String, default: "Top"},
|
default_home: {type: String, default: "Top"},
|
||||||
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending"]},
|
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
|
||||||
top_enabled: {type: Bool, default: true},
|
top_enabled: {type: Bool, default: true},
|
||||||
captcha_enabled: {type: Bool, default: true},
|
captcha_enabled: {type: Bool, default: true},
|
||||||
login_enabled: {type: Bool, default: true},
|
login_enabled: {type: Bool, default: true},
|
||||||
registration_enabled: {type: Bool, default: true},
|
registration_enabled: {type: Bool, default: true},
|
||||||
statistics_enabled: {type: Bool, default: false},
|
statistics_enabled: {type: Bool, default: false},
|
||||||
admins: {type: Array(String), default: [] of String},
|
admins: {type: Array(String), default: [] of String},
|
||||||
|
external_port: {type: Int32 | Nil, default: nil},
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
class FilteredCompressHandler < Kemal::Handler
|
|
||||||
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/api/*", "/ggpht/*"]
|
|
||||||
|
|
||||||
def call(env)
|
|
||||||
return call_next env if exclude_match? env
|
|
||||||
|
|
||||||
{% if flag?(:without_zlib) %}
|
|
||||||
call_next env
|
|
||||||
{% else %}
|
|
||||||
request_headers = env.request.headers
|
|
||||||
|
|
||||||
if request_headers.includes_word?("Accept-Encoding", "gzip")
|
|
||||||
env.response.headers["Content-Encoding"] = "gzip"
|
|
||||||
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
|
|
||||||
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
|
|
||||||
env.response.headers["Content-Encoding"] = "deflate"
|
|
||||||
env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
call_next env
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class APIHandler < Kemal::Handler
|
|
||||||
only ["/api/v1/*"]
|
|
||||||
|
|
||||||
def call(env)
|
|
||||||
return call_next env unless only_match? env
|
|
||||||
|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
|
|
||||||
call_next env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class DenyFrame < Kemal::Handler
|
|
||||||
exclude ["/embed/*"]
|
|
||||||
|
|
||||||
def call(env)
|
|
||||||
return call_next env if exclude_match? env
|
|
||||||
|
|
||||||
env.response.headers["X-Frame-Options"] = "sameorigin"
|
|
||||||
call_next env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def rank_videos(db, n)
|
def rank_videos(db, n)
|
||||||
top = [] of {Float64, String}
|
top = [] of {Float64, String}
|
||||||
|
|
||||||
@ -223,13 +175,22 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
playlist_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]?
|
||||||
|
playlist_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"]
|
||||||
|
if !playlist_thumbnail || playlist_thumbnail.empty?
|
||||||
|
thumbnail_id = videos[0]?.try &.id
|
||||||
|
else
|
||||||
|
thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
|
||||||
|
end
|
||||||
|
|
||||||
items << SearchPlaylist.new(
|
items << SearchPlaylist.new(
|
||||||
title,
|
title,
|
||||||
plid,
|
plid,
|
||||||
author,
|
author,
|
||||||
author_id,
|
author_id,
|
||||||
video_count,
|
video_count,
|
||||||
videos
|
videos,
|
||||||
|
thumbnail_id
|
||||||
)
|
)
|
||||||
when .includes? "yt-lockup-channel"
|
when .includes? "yt-lockup-channel"
|
||||||
author = title.strip
|
author = title.strip
|
||||||
@ -307,6 +268,11 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
paid = true
|
paid = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
premiere_timestamp = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/span[@class="localized-date"])).try &.["data-timestamp"]?.try &.to_i64
|
||||||
|
if premiere_timestamp
|
||||||
|
premiere_timestamp = Time.unix(premiere_timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
items << SearchVideo.new(
|
items << SearchVideo.new(
|
||||||
title: title,
|
title: title,
|
||||||
id: id,
|
id: id,
|
||||||
@ -319,7 +285,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
live_now: live_now,
|
live_now: live_now,
|
||||||
paid: paid,
|
paid: paid,
|
||||||
premium: premium
|
premium: premium,
|
||||||
|
premiere_timestamp: premiere_timestamp
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -390,13 +357,28 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
playlist_title ||= ""
|
playlist_title ||= ""
|
||||||
plid ||= ""
|
plid ||= ""
|
||||||
|
|
||||||
|
playlist_thumbnail = child_node.xpath_node(%q(.//span/img)).try &.["data-thumb"]?
|
||||||
|
playlist_thumbnail ||= child_node.xpath_node(%q(.//span/img)).try &.["src"]
|
||||||
|
if !playlist_thumbnail || playlist_thumbnail.empty?
|
||||||
|
thumbnail_id = videos[0]?.try &.id
|
||||||
|
else
|
||||||
|
thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
|
||||||
|
end
|
||||||
|
|
||||||
|
video_count_label = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
|
||||||
|
if video_count_label
|
||||||
|
video_count = video_count_label.content.strip.match(/^\d+/).try &.[0].to_i?
|
||||||
|
end
|
||||||
|
video_count ||= 50
|
||||||
|
|
||||||
items << SearchPlaylist.new(
|
items << SearchPlaylist.new(
|
||||||
playlist_title,
|
playlist_title,
|
||||||
plid,
|
plid,
|
||||||
author_name,
|
author_name,
|
||||||
ucid,
|
ucid,
|
||||||
50,
|
video_count,
|
||||||
Array(SearchPlaylistVideo).new
|
Array(SearchPlaylistVideo).new,
|
||||||
|
thumbnail_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -410,7 +392,8 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
author_name,
|
author_name,
|
||||||
ucid,
|
ucid,
|
||||||
videos.size,
|
videos.size,
|
||||||
videos
|
videos,
|
||||||
|
videos[0].try &.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -162,6 +162,23 @@ def number_with_separator(number)
|
|||||||
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
|
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def short_text_to_number(short_text)
|
||||||
|
case short_text
|
||||||
|
when .ends_with? "M"
|
||||||
|
number = short_text.rstrip(" mM").to_f
|
||||||
|
number *= 1000000
|
||||||
|
when .ends_with? "K"
|
||||||
|
number = short_text.rstrip(" kK").to_f
|
||||||
|
number *= 1000
|
||||||
|
else
|
||||||
|
number = short_text.rstrip(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
number = number.to_i
|
||||||
|
|
||||||
|
return number
|
||||||
|
end
|
||||||
|
|
||||||
def number_to_short_text(number)
|
def number_to_short_text(number)
|
||||||
seperated = number_with_separator(number).gsub(",", ".").split("")
|
seperated = number_with_separator(number).gsub(",", ".").split("")
|
||||||
text = seperated.first(2).join
|
text = seperated.first(2).join
|
||||||
@ -193,19 +210,30 @@ def arg_array(array, start = 1)
|
|||||||
return args
|
return args
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_host_url(ssl, host)
|
def make_host_url(config, kemal_config)
|
||||||
|
ssl = config.https_only || kemal_config.ssl
|
||||||
|
port = config.external_port || kemal_config.port
|
||||||
|
|
||||||
if ssl
|
if ssl
|
||||||
scheme = "https://"
|
scheme = "https://"
|
||||||
else
|
else
|
||||||
scheme = "http://"
|
scheme = "http://"
|
||||||
end
|
end
|
||||||
|
|
||||||
if host
|
# Add if non-standard port
|
||||||
host = host.lchop(".")
|
if port != 80 && port != 443
|
||||||
return "#{scheme}#{host}"
|
port = ":#{kemal_config.port}"
|
||||||
else
|
else
|
||||||
|
port = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
if !config.domain
|
||||||
return ""
|
return ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
host = config.domain.not_nil!.lchop(".")
|
||||||
|
|
||||||
|
return "#{scheme}#{host}#{port}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_referer(env, fallback = "/")
|
def get_referer(env, fallback = "/")
|
||||||
|
@ -1,51 +1,3 @@
|
|||||||
def crawl_videos(db, logger)
|
|
||||||
ids = Deque(String).new
|
|
||||||
random = Random.new
|
|
||||||
|
|
||||||
search(random.base64(3)).as(Tuple)[1].each do |video|
|
|
||||||
if video.is_a?(SearchVideo)
|
|
||||||
ids << video.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
loop do
|
|
||||||
if ids.empty?
|
|
||||||
search(random.base64(3)).as(Tuple)[1].each do |video|
|
|
||||||
if video.is_a?(SearchVideo)
|
|
||||||
ids << video.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
id = ids[0]
|
|
||||||
video = get_video(id, db)
|
|
||||||
rescue ex
|
|
||||||
logger.write("#{id} : #{ex.message}\n")
|
|
||||||
next
|
|
||||||
ensure
|
|
||||||
ids.delete(id)
|
|
||||||
end
|
|
||||||
|
|
||||||
rvs = [] of Hash(String, String)
|
|
||||||
video.info["rvs"]?.try &.split(",").each do |rv|
|
|
||||||
rvs << HTTP::Params.parse(rv).to_h
|
|
||||||
end
|
|
||||||
|
|
||||||
rvs.each do |rv|
|
|
||||||
if rv.has_key?("id") && !db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", rv["id"], as: Bool)
|
|
||||||
ids.delete(id)
|
|
||||||
ids << rv["id"]
|
|
||||||
if ids.size == 150
|
|
||||||
ids.shift
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
|
def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
|
||||||
max_channel = Channel(Int32).new
|
max_channel = Channel(Int32).new
|
||||||
|
|
||||||
@ -82,30 +34,14 @@ def refresh_channels(db, logger, max_threads = 1, full_refresh = false)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
max_channel.send(max_threads)
|
max_channel.send(max_threads)
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh_videos(db, logger)
|
|
||||||
loop do
|
|
||||||
db.query("SELECT id FROM videos ORDER BY updated") do |rs|
|
|
||||||
rs.each do
|
|
||||||
begin
|
|
||||||
id = rs.read(String)
|
|
||||||
video = get_video(id, db)
|
|
||||||
rescue ex
|
|
||||||
logger.write("#{id} : #{ex.message}\n")
|
|
||||||
next
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_feeds(db, logger, max_threads = 1)
|
def refresh_feeds(db, logger, max_threads = 1)
|
||||||
max_channel = Channel(Int32).new
|
max_channel = Channel(Int32).new
|
||||||
|
|
||||||
@ -129,15 +65,26 @@ def refresh_feeds(db, logger, max_threads = 1)
|
|||||||
active_threads += 1
|
active_threads += 1
|
||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
|
db.query("SELECT * FROM #{view_name} LIMIT 1") do |rs|
|
||||||
|
# View doesn't contain same number of rows as ChannelVideo
|
||||||
|
if ChannelVideo.from_rs(rs)[0]?.try &.to_a.size.try &.!= rs.column_count
|
||||||
|
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||||
|
raise "valid schema does not exist"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
||||||
rescue ex
|
rescue ex
|
||||||
# Create view if it doesn't exist
|
# Create view if it doesn't exist
|
||||||
if ex.message.try &.ends_with? "does not exist"
|
if ex.message.try &.ends_with?("does not exist")
|
||||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
# While iterating through, we may have an email stored from a deleted account
|
||||||
SELECT * FROM channel_videos WHERE \
|
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
||||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
|
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
||||||
ORDER BY published DESC;")
|
SELECT * FROM channel_videos WHERE \
|
||||||
logger.write("CREATE #{view_name}")
|
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
|
||||||
|
ORDER BY published DESC;")
|
||||||
|
logger.write("CREATE #{view_name}\n")
|
||||||
|
end
|
||||||
else
|
else
|
||||||
logger.write("REFRESH #{email} : #{ex.message}\n")
|
logger.write("REFRESH #{email} : #{ex.message}\n")
|
||||||
end
|
end
|
||||||
@ -147,6 +94,8 @@ def refresh_feeds(db, logger, max_threads = 1)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -158,16 +107,17 @@ def subscribe_to_feeds(db, logger, key, config)
|
|||||||
spawn do
|
spawn do
|
||||||
loop do
|
loop do
|
||||||
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs|
|
db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > '4 days'") do |rs|
|
||||||
ucid = rs.read(String)
|
rs.each do
|
||||||
response = subscribe_pubsub(ucid, key, config)
|
ucid = rs.read(String)
|
||||||
|
response = subscribe_pubsub(ucid, key, config)
|
||||||
|
|
||||||
if response.status_code >= 400
|
if response.status_code >= 400
|
||||||
logger.write("#{ucid} : #{response.body}\n")
|
logger.write("#{ucid} : #{response.body}\n")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
Fiber.yield
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -198,7 +148,7 @@ def pull_top_videos(config, db)
|
|||||||
end
|
end
|
||||||
|
|
||||||
yield videos
|
yield videos
|
||||||
Fiber.yield
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -213,7 +163,7 @@ def pull_popular_videos(db)
|
|||||||
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
|
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
|
||||||
|
|
||||||
yield videos
|
yield videos
|
||||||
Fiber.yield
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -226,6 +176,7 @@ def update_decrypt_function
|
|||||||
end
|
end
|
||||||
|
|
||||||
yield decrypt_function
|
yield decrypt_function
|
||||||
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -237,7 +188,8 @@ def find_working_proxies(regions)
|
|||||||
# proxies = filter_proxies(proxies)
|
# proxies = filter_proxies(proxies)
|
||||||
|
|
||||||
yield region, proxies
|
yield region, proxies
|
||||||
Fiber.yield
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sleep 1.minute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -8,6 +8,7 @@ class PlaylistVideo
|
|||||||
published: Time,
|
published: Time,
|
||||||
playlists: Array(String),
|
playlists: Array(String),
|
||||||
index: Int32,
|
index: Int32,
|
||||||
|
live_now: Bool,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -101,8 +102,10 @@ def extract_playlist(plid, nodeset, index)
|
|||||||
anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1]))
|
anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1]))
|
||||||
if anchor && !anchor.content.empty?
|
if anchor && !anchor.content.empty?
|
||||||
length_seconds = decode_length_seconds(anchor.content)
|
length_seconds = decode_length_seconds(anchor.content)
|
||||||
|
live_now = false
|
||||||
else
|
else
|
||||||
length_seconds = 0
|
length_seconds = 0
|
||||||
|
live_now = true
|
||||||
end
|
end
|
||||||
|
|
||||||
videos << PlaylistVideo.new(
|
videos << PlaylistVideo.new(
|
||||||
@ -114,6 +117,7 @@ def extract_playlist(plid, nodeset, index)
|
|||||||
published: Time.now,
|
published: Time.now,
|
||||||
playlists: [plid],
|
playlists: [plid],
|
||||||
index: index + offset,
|
index: index + offset,
|
||||||
|
live_now: live_now
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
class SearchVideo
|
class SearchVideo
|
||||||
add_mapping({
|
add_mapping({
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
author: String,
|
author: String,
|
||||||
ucid: String,
|
ucid: String,
|
||||||
published: Time,
|
published: Time,
|
||||||
views: Int64,
|
views: Int64,
|
||||||
description: String,
|
description: String,
|
||||||
description_html: String,
|
description_html: String,
|
||||||
length_seconds: Int32,
|
length_seconds: Int32,
|
||||||
live_now: Bool,
|
live_now: Bool,
|
||||||
paid: Bool,
|
paid: Bool,
|
||||||
premium: Bool,
|
premium: Bool,
|
||||||
|
premiere_timestamp: Time?,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -25,12 +26,13 @@ end
|
|||||||
|
|
||||||
class SearchPlaylist
|
class SearchPlaylist
|
||||||
add_mapping({
|
add_mapping({
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
author: String,
|
author: String,
|
||||||
ucid: String,
|
ucid: String,
|
||||||
video_count: Int32,
|
video_count: Int32,
|
||||||
videos: Array(SearchPlaylistVideo),
|
videos: Array(SearchPlaylistVideo),
|
||||||
|
thumbnail_id: String?,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
|
|||||||
"video_loop" => false,
|
"video_loop" => false,
|
||||||
"autoplay" => false,
|
"autoplay" => false,
|
||||||
"continue" => false,
|
"continue" => false,
|
||||||
|
"local" => false,
|
||||||
"listen" => false,
|
"listen" => false,
|
||||||
"speed" => 1.0,
|
"speed" => 1.0,
|
||||||
"quality" => "hd720",
|
"quality" => "hd720",
|
||||||
@ -80,6 +81,10 @@ class Preferences
|
|||||||
type: Bool,
|
type: Bool,
|
||||||
default: DEFAULT_USER_PREFERENCES.continue,
|
default: DEFAULT_USER_PREFERENCES.continue,
|
||||||
},
|
},
|
||||||
|
local: {
|
||||||
|
type: Bool,
|
||||||
|
default: DEFAULT_USER_PREFERENCES.local,
|
||||||
|
},
|
||||||
listen: {
|
listen: {
|
||||||
type: Bool,
|
type: Bool,
|
||||||
default: DEFAULT_USER_PREFERENCES.listen,
|
default: DEFAULT_USER_PREFERENCES.listen,
|
||||||
@ -250,8 +255,12 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
|
|||||||
challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
|
challenge = OpenSSL::HMAC.digest(:sha256, key, challenge)
|
||||||
challenge = Base64.urlsafe_encode(challenge)
|
challenge = Base64.urlsafe_encode(challenge)
|
||||||
|
|
||||||
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)
|
if nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", nonce, as: {String, Time})
|
||||||
db.exec("DELETE FROM nonces * WHERE nonce = $1", nonce)
|
if nonce[1] > Time.now
|
||||||
|
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
|
||||||
|
else
|
||||||
|
raise translate(locale, "Invalid token")
|
||||||
|
end
|
||||||
else
|
else
|
||||||
raise translate(locale, "Invalid token")
|
raise translate(locale, "Invalid token")
|
||||||
end
|
end
|
||||||
@ -265,7 +274,7 @@ def validate_response(challenge, token, user_id, operation, key, db, locale)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if challenge_user_id != user_id
|
if challenge_user_id != user_id
|
||||||
raise translate(locale, "Invalid user")
|
raise translate(locale, "Invalid token")
|
||||||
end
|
end
|
||||||
|
|
||||||
if expire < Time.now.to_unix
|
if expire < Time.now.to_unix
|
||||||
@ -291,7 +300,7 @@ def generate_captcha(key, db)
|
|||||||
clock_svg = <<-END_SVG
|
clock_svg = <<-END_SVG
|
||||||
<svg viewBox="0 0 100 100" width="200px">
|
<svg viewBox="0 0 100 100" width="200px">
|
||||||
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
|
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
|
||||||
|
|
||||||
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
|
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
|
||||||
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
|
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
|
||||||
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
|
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
|
||||||
@ -323,7 +332,22 @@ def generate_captcha(key, db)
|
|||||||
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
|
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
|
||||||
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
|
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
|
||||||
|
|
||||||
challenge, token = create_response(answer, "sign_in", key, db)
|
return {
|
||||||
|
question: image,
|
||||||
return {image: image, challenge: challenge, token: token}
|
tokens: [create_response(answer, "sign_in", key, db)],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_text_captcha(key, db)
|
||||||
|
response = HTTP::Client.get(TEXTCAPTCHA_URL).body
|
||||||
|
response = JSON.parse(response)
|
||||||
|
|
||||||
|
tokens = response["a"].as_a.map do |answer|
|
||||||
|
create_response(answer.as_s, "sign_in", key, db)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
question: response["q"].as_s,
|
||||||
|
tokens: tokens,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
@ -136,18 +136,6 @@ BYPASS_REGIONS = {
|
|||||||
"TR",
|
"TR",
|
||||||
}
|
}
|
||||||
|
|
||||||
VIDEO_THUMBNAILS = {
|
|
||||||
{name: "maxres", host: "#{CONFIG.domain}", url: "maxres", height: 720, width: 1280},
|
|
||||||
{name: "maxresdefault", host: "i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
|
|
||||||
{name: "sddefault", host: "i.ytimg.com", url: "sddefault", height: 480, width: 640},
|
|
||||||
{name: "high", host: "i.ytimg.com", url: "hqdefault", height: 360, width: 480},
|
|
||||||
{name: "medium", host: "i.ytimg.com", url: "mqdefault", height: 180, width: 320},
|
|
||||||
{name: "default", host: "i.ytimg.com", url: "default", height: 90, width: 120},
|
|
||||||
{name: "start", host: "i.ytimg.com", url: "1", height: 90, width: 120},
|
|
||||||
{name: "middle", host: "i.ytimg.com", url: "2", height: 90, width: 120},
|
|
||||||
{name: "end", host: "i.ytimg.com", url: "3", height: 90, width: 120},
|
|
||||||
}
|
|
||||||
|
|
||||||
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
|
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
|
||||||
VIDEO_FORMATS = {
|
VIDEO_FORMATS = {
|
||||||
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
|
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
|
||||||
@ -262,6 +250,63 @@ class Video
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allow_ratings
|
||||||
|
allow_ratings = player_response["videoDetails"].try &.["allowRatings"]?.try &.as_bool
|
||||||
|
|
||||||
|
if allow_ratings.nil?
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return allow_ratings
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_now
|
||||||
|
live_now = self.player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool
|
||||||
|
|
||||||
|
if live_now.nil?
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return live_now
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_listed
|
||||||
|
is_listed = player_response["videoDetails"].try &.["isCrawlable"]?.try &.as_bool
|
||||||
|
|
||||||
|
if is_listed.nil?
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return is_listed
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_upcoming
|
||||||
|
is_upcoming = player_response["videoDetails"].try &.["isUpcoming"]?.try &.as_bool
|
||||||
|
|
||||||
|
if is_upcoming.nil?
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return is_upcoming
|
||||||
|
end
|
||||||
|
|
||||||
|
def premiere_timestamp
|
||||||
|
if self.is_upcoming
|
||||||
|
premiere_timestamp = player_response["playabilityStatus"]?
|
||||||
|
.try &.["liveStreamability"]?
|
||||||
|
.try &.["liveStreamabilityRenderer"]?
|
||||||
|
.try &.["offlineSlate"]?
|
||||||
|
.try &.["liveStreamOfflineSlateRenderer"]?
|
||||||
|
.try &.["scheduledStartTime"].as_s.to_i64
|
||||||
|
end
|
||||||
|
|
||||||
|
if premiere_timestamp
|
||||||
|
premiere_timestamp = Time.unix(premiere_timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
return premiere_timestamp
|
||||||
|
end
|
||||||
|
|
||||||
def keywords
|
def keywords
|
||||||
keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
|
keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
|
||||||
keywords ||= [] of String
|
keywords ||= [] of String
|
||||||
@ -329,6 +374,7 @@ class Video
|
|||||||
end
|
end
|
||||||
|
|
||||||
streams.each do |fmt|
|
streams.each do |fmt|
|
||||||
|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
|
||||||
fmt["url"] += decrypt_signature(fmt, decrypt_function)
|
fmt["url"] += decrypt_signature(fmt, decrypt_function)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -396,6 +442,7 @@ class Video
|
|||||||
end
|
end
|
||||||
|
|
||||||
adaptive_fmts.each do |fmt|
|
adaptive_fmts.each do |fmt|
|
||||||
|
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
|
||||||
fmt["url"] += decrypt_signature(fmt, decrypt_function)
|
fmt["url"] += decrypt_signature(fmt, decrypt_function)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -654,6 +701,10 @@ def fetch_video(id, proxies, region)
|
|||||||
raise "Video unavailable."
|
raise "Video unavailable."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if !info["title"]?
|
||||||
|
raise "Video unavailable."
|
||||||
|
end
|
||||||
|
|
||||||
title = info["title"]
|
title = info["title"]
|
||||||
author = info["author"]
|
author = info["author"]
|
||||||
ucid = info["ucid"]
|
ucid = info["ucid"]
|
||||||
@ -743,11 +794,12 @@ end
|
|||||||
def process_video_params(query, preferences)
|
def process_video_params(query, preferences)
|
||||||
autoplay = query["autoplay"]?.try &.to_i?
|
autoplay = query["autoplay"]?.try &.to_i?
|
||||||
continue = query["continue"]?.try &.to_i?
|
continue = query["continue"]?.try &.to_i?
|
||||||
related_videos = query["related_videos"]?
|
|
||||||
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
|
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
|
||||||
|
local = query["local"]? && (query["local"] == "true").to_unsafe
|
||||||
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
|
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
|
||||||
quality = query["quality"]?
|
quality = query["quality"]?
|
||||||
region = query["region"]?
|
region = query["region"]?
|
||||||
|
related_videos = query["related_videos"]?
|
||||||
speed = query["speed"]?.try &.to_f?
|
speed = query["speed"]?.try &.to_f?
|
||||||
video_loop = query["loop"]?.try &.to_i?
|
video_loop = query["loop"]?.try &.to_i?
|
||||||
volume = query["volume"]?.try &.to_i?
|
volume = query["volume"]?.try &.to_i?
|
||||||
@ -756,10 +808,11 @@ def process_video_params(query, preferences)
|
|||||||
# region ||= preferences.region
|
# region ||= preferences.region
|
||||||
autoplay ||= preferences.autoplay.to_unsafe
|
autoplay ||= preferences.autoplay.to_unsafe
|
||||||
continue ||= preferences.continue.to_unsafe
|
continue ||= preferences.continue.to_unsafe
|
||||||
related_videos ||= preferences.related_videos.to_unsafe
|
|
||||||
listen ||= preferences.listen.to_unsafe
|
listen ||= preferences.listen.to_unsafe
|
||||||
|
local ||= preferences.local.to_unsafe
|
||||||
preferred_captions ||= preferences.captions
|
preferred_captions ||= preferences.captions
|
||||||
quality ||= preferences.quality
|
quality ||= preferences.quality
|
||||||
|
related_videos ||= preferences.related_videos.to_unsafe
|
||||||
speed ||= preferences.speed
|
speed ||= preferences.speed
|
||||||
video_loop ||= preferences.video_loop.to_unsafe
|
video_loop ||= preferences.video_loop.to_unsafe
|
||||||
volume ||= preferences.volume
|
volume ||= preferences.volume
|
||||||
@ -767,18 +820,20 @@ def process_video_params(query, preferences)
|
|||||||
|
|
||||||
autoplay ||= DEFAULT_USER_PREFERENCES.autoplay.to_unsafe
|
autoplay ||= DEFAULT_USER_PREFERENCES.autoplay.to_unsafe
|
||||||
continue ||= DEFAULT_USER_PREFERENCES.continue.to_unsafe
|
continue ||= DEFAULT_USER_PREFERENCES.continue.to_unsafe
|
||||||
related_videos ||= DEFAULT_USER_PREFERENCES.related_videos.to_unsafe
|
|
||||||
listen ||= DEFAULT_USER_PREFERENCES.listen.to_unsafe
|
listen ||= DEFAULT_USER_PREFERENCES.listen.to_unsafe
|
||||||
|
local ||= DEFAULT_USER_PREFERENCES.local.to_unsafe
|
||||||
preferred_captions ||= DEFAULT_USER_PREFERENCES.captions
|
preferred_captions ||= DEFAULT_USER_PREFERENCES.captions
|
||||||
quality ||= DEFAULT_USER_PREFERENCES.quality
|
quality ||= DEFAULT_USER_PREFERENCES.quality
|
||||||
|
related_videos ||= DEFAULT_USER_PREFERENCES.related_videos.to_unsafe
|
||||||
speed ||= DEFAULT_USER_PREFERENCES.speed
|
speed ||= DEFAULT_USER_PREFERENCES.speed
|
||||||
video_loop ||= DEFAULT_USER_PREFERENCES.video_loop.to_unsafe
|
video_loop ||= DEFAULT_USER_PREFERENCES.video_loop.to_unsafe
|
||||||
volume ||= DEFAULT_USER_PREFERENCES.volume
|
volume ||= DEFAULT_USER_PREFERENCES.volume
|
||||||
|
|
||||||
autoplay = autoplay == 1
|
autoplay = autoplay == 1
|
||||||
continue = continue == 1
|
continue = continue == 1
|
||||||
related_videos = related_videos == 1
|
|
||||||
listen = listen == 1
|
listen = listen == 1
|
||||||
|
local = local == 1
|
||||||
|
related_videos = related_videos == 1
|
||||||
video_loop = video_loop == 1
|
video_loop = video_loop == 1
|
||||||
|
|
||||||
if query["t"]?
|
if query["t"]?
|
||||||
@ -811,6 +866,7 @@ def process_video_params(query, preferences)
|
|||||||
continue: continue,
|
continue: continue,
|
||||||
controls: controls,
|
controls: controls,
|
||||||
listen: listen,
|
listen: listen,
|
||||||
|
local: local,
|
||||||
preferred_captions: preferred_captions,
|
preferred_captions: preferred_captions,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
raw: raw,
|
raw: raw,
|
||||||
@ -826,12 +882,26 @@ def process_video_params(query, preferences)
|
|||||||
return params
|
return params
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_thumbnails(json, id)
|
def build_thumbnails(id, config, kemal_config)
|
||||||
|
return {
|
||||||
|
{name: "maxres", host: "#{make_host_url(config, kemal_config)}", url: "maxres", height: 720, width: 1280},
|
||||||
|
{name: "maxresdefault", host: "https://i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
|
||||||
|
{name: "sddefault", host: "https://i.ytimg.com", url: "sddefault", height: 480, width: 640},
|
||||||
|
{name: "high", host: "https://i.ytimg.com", url: "hqdefault", height: 360, width: 480},
|
||||||
|
{name: "medium", host: "https://i.ytimg.com", url: "mqdefault", height: 180, width: 320},
|
||||||
|
{name: "default", host: "https://i.ytimg.com", url: "default", height: 90, width: 120},
|
||||||
|
{name: "start", host: "https://i.ytimg.com", url: "1", height: 90, width: 120},
|
||||||
|
{name: "middle", host: "https://i.ytimg.com", url: "2", height: 90, width: 120},
|
||||||
|
{name: "end", host: "https://i.ytimg.com", url: "3", height: 90, width: 120},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_thumbnails(json, id, config, kemal_config)
|
||||||
json.array do
|
json.array do
|
||||||
VIDEO_THUMBNAILS.each do |thumbnail|
|
build_thumbnails(id, config, kemal_config).each do |thumbnail|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "quality", thumbnail[:name]
|
json.field "quality", thumbnail[:name]
|
||||||
json.field "url", "https://#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
|
json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
|
||||||
json.field "width", thumbnail[:width]
|
json.field "width", thumbnail[:width]
|
||||||
json.field "height", thumbnail[:height]
|
json.field "height", thumbnail[:height]
|
||||||
end
|
end
|
||||||
|
Loading…
Reference in New Issue
Block a user