mirror of
https://github.com/iv-org/invidious.git
synced 2025-03-27 16:08:18 -04:00
Merge branch 'master' into api-only
This commit is contained in:
commit
4dc95b18d2
126
CHANGELOG.md
126
CHANGELOG.md
@ -1,3 +1,129 @@
|
|||||||
|
# 0.19.0 (2019-07-13)
|
||||||
|
|
||||||
|
# Version 0.19.0: Communities
|
||||||
|
|
||||||
|
Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
|
||||||
|
|
||||||
|
This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
|
||||||
|
|
||||||
|
I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
|
||||||
|
|
||||||
|
This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
|
||||||
|
|
||||||
|
Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
|
||||||
|
|
||||||
|
## Communities
|
||||||
|
|
||||||
|
Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
|
||||||
|
|
||||||
|
It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
|
||||||
|
|
||||||
|
An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
|
||||||
|
|
||||||
|
An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
|
||||||
|
|
||||||
|
## For Administrators
|
||||||
|
|
||||||
|
It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
|
||||||
|
|
||||||
|
## Finances
|
||||||
|
|
||||||
|
### Donations
|
||||||
|
|
||||||
|
- [Patreon](https://www.patreon.com/omarroth) : \$38.39
|
||||||
|
- [Liberapay](https://liberapay.com/omarroth) : \$84.85
|
||||||
|
- Crypto : ~\$0.00 (converted from BCH, BTC)
|
||||||
|
- Total : \$123.24
|
||||||
|
|
||||||
|
### 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-node5 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
|
||||||
|
- Total : \$105.00
|
||||||
|
|
||||||
|
The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
|
||||||
|
|
||||||
|
As always I'm grateful for everyone's support and feedback. I'll see you all next month.
|
||||||
|
|
||||||
|
# 0.18.0 (2019-06-06)
|
||||||
|
|
||||||
|
# Version 0.18.0: Native Notifications and Optimizations
|
||||||
|
|
||||||
|
Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
|
||||||
|
|
||||||
|
I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
|
||||||
|
|
||||||
|
Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
|
||||||
|
|
||||||
|
Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
|
||||||
|
|
||||||
|
This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
|
||||||
|
|
||||||
|
An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
|
||||||
|
|
||||||
|
A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
|
||||||
|
|
||||||
|
## For Administrators
|
||||||
|
|
||||||
|
There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
|
||||||
|
|
||||||
|
As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
|
||||||
|
|
||||||
|
## Native Notifications
|
||||||
|
|
||||||
|
[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
|
||||||
|
|
||||||
|
It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
|
||||||
|
|
||||||
|
You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
|
||||||
|
|
||||||
|
Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
|
||||||
|
|
||||||
|
Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
|
||||||
|
|
||||||
|
## Finances
|
||||||
|
|
||||||
|
### Donations
|
||||||
|
|
||||||
|
- [Patreon](https://www.patreon.com/omarroth) : \$49.73
|
||||||
|
- [Liberapay](https://liberapay.com/omarroth) : \$100.57
|
||||||
|
- Crypto : ~\$11.12 (converted from BCH, BTC)
|
||||||
|
- Total : \$161.42
|
||||||
|
|
||||||
|
### 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-node5 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
|
||||||
|
- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
|
||||||
|
- Total : \$85.00
|
||||||
|
|
||||||
|
See you all next month!
|
||||||
|
|
||||||
# 0.17.0 (2019-05-06)
|
# 0.17.0 (2019-05-06)
|
||||||
|
|
||||||
# Version 0.17.0: Player and Authentication API
|
# Version 0.17.0: Player and Authentication API
|
||||||
|
12
README.md
12
README.md
@ -27,12 +27,16 @@ Patreon: https://patreon.com/omarroth
|
|||||||
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
|
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
|
||||||
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
|
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
|
||||||
|
|
||||||
Onion links:
|
## Invidious Instances
|
||||||
|
|
||||||
- kgg2m7yk5aybusll.onion
|
See [Invidious Instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) for a full list of publicly available instances.
|
||||||
- axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
|
|
||||||
|
|
||||||
[Alternative Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances)
|
### Official Instances
|
||||||
|
|
||||||
|
- [invidio.us](https://invidio.us) 🇺🇸
|
||||||
|
Issuer: Let's Encrypt, [SSLLabs Verification](https://www.ssllabs.com/ssltest/analyze.html?d=invidio.us)
|
||||||
|
- [kgg2m7yk5aybusll.onion](http://kgg2m7yk5aybusll.onion)
|
||||||
|
- [axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion](http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
3
config/migrate-scripts/migrate-db-701b5ea.sh
Executable file
3
config/migrate-scripts/migrate-db-701b5ea.sh
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"
|
@ -13,7 +13,7 @@ services:
|
|||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "إلغاء الإشتراك",
|
"Unsubscribe": "إلغاء الإشتراك",
|
||||||
"Subscribe": "إشتراك",
|
"Subscribe": "إشتراك",
|
||||||
"View channel on YouTube": "زيارة القناة على موقع يوتيوب",
|
"View channel on YouTube": "زيارة القناة على موقع يوتيوب",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب",
|
||||||
"newest": "الأجدد",
|
"newest": "الأجدد",
|
||||||
"oldest": "الأقدم",
|
"oldest": "الأقدم",
|
||||||
"popular": "الاكثر شعبية",
|
"popular": "الاكثر شعبية",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "شغل الفيديو التالى تلقائيا",
|
"Play next by default: ": "شغل الفيديو التالى تلقائيا",
|
||||||
"Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)",
|
"Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)",
|
||||||
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
|
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
|
||||||
"Proxy videos? ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟",
|
"Proxy videos: ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟",
|
||||||
"Default speed: ": "السرعة الإفتراضية: ",
|
"Default speed: ": "السرعة الإفتراضية: ",
|
||||||
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
|
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
|
||||||
"Player volume: ": "صوت المشغل: ",
|
"Player volume: ": "صوت المشغل: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "الترجمات الإفتراضية: ",
|
"Default captions: ": "الترجمات الإفتراضية: ",
|
||||||
"Fallback captions: ": "الترجمات المصاحبة: ",
|
"Fallback captions: ": "الترجمات المصاحبة: ",
|
||||||
"Show related videos? ": "عرض مقاطع الفيديو ذات الصلة؟",
|
"Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟",
|
||||||
"Show annotations by default? ": "عرض الملاحظات فى الفيديو تلقائيا ؟",
|
"Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟",
|
||||||
"Visual preferences": "التفضيلات المرئية",
|
"Visual preferences": "التفضيلات المرئية",
|
||||||
"Dark mode: ": "الوضع الليلى: ",
|
"Dark mode: ": "الوضع الليلى: ",
|
||||||
"Thin mode: ": "الوضع الخفيف: ",
|
"Thin mode: ": "الوضع الخفيف: ",
|
||||||
"Subscription preferences": "تفضيلات الإشتراك",
|
"Subscription preferences": "تفضيلات الإشتراك",
|
||||||
"Show annotations by default for subscribed channels? ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
|
"Show annotations by default for subscribed channels: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
|
||||||
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
|
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
|
||||||
"Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
|
"Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
|
||||||
"Sort videos by: ": "ترتيب الفيديو بـ: ",
|
"Sort videos by: ": "ترتيب الفيديو بـ: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
|
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
|
||||||
"Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ",
|
"Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ",
|
||||||
"Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
|
"Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
|
||||||
|
"Enable web notifications": "تفعيل إشعارات المتصفح",
|
||||||
|
"`x` uploaded a video": "`x` رفع فيديو",
|
||||||
|
"`x` is live": "`x` فى بث مباشر",
|
||||||
"Data preferences": "إعدادات التفضيلات",
|
"Data preferences": "إعدادات التفضيلات",
|
||||||
"Clear watch history": "حذف سجل المشاهدة",
|
"Clear watch history": "حذف سجل المشاهدة",
|
||||||
"Import/export data": "إضافة\\إستخراج البيانات",
|
"Import/export data": "إضافة\\إستخراج البيانات",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "إعدادات المدير",
|
"Administrator preferences": "إعدادات المدير",
|
||||||
"Default homepage: ": "الصفحة الرئيسية الافتراضية ",
|
"Default homepage: ": "الصفحة الرئيسية الافتراضية ",
|
||||||
"Feed menu: ": "قائمة التغذية",
|
"Feed menu: ": "قائمة التغذية",
|
||||||
"Top enabled? ": "تفعيل 'الأفضل' ؟ ",
|
"Top enabled: ": "تفعيل 'الأفضل' ؟ ",
|
||||||
"CAPTCHA enabled? ": "تفعيل الكابتشا ؟",
|
"CAPTCHA enabled: ": "تفعيل الكابتشا ؟",
|
||||||
"Login enabled? ": "تفعيل تسجيل الدخول ؟",
|
"Login enabled: ": "تفعيل تسجيل الدخول ؟",
|
||||||
"Registration enabled? ": "تفعيل التسجيل ؟",
|
"Registration enabled: ": "تفعيل التسجيل ؟",
|
||||||
"Report statistics? ": "إبلاغ الإحصائيات",
|
"Report statistics: ": "إبلاغ الإحصائيات",
|
||||||
"Save preferences": "حفظ التفضيلات",
|
"Save preferences": "حفظ التفضيلات",
|
||||||
"Subscription manager": "مدير الإشتراكات",
|
"Subscription manager": "مدير الإشتراكات",
|
||||||
"Token manager": "إداره الرمز",
|
"Token manager": "إداره الرمز",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "شارك منذ `x`",
|
"Shared `x`": "شارك منذ `x`",
|
||||||
"`x` views": "`x` مشاهدون",
|
"`x` views": "`x` مشاهدون",
|
||||||
"Premieres in `x`": "يعرض فى `x`",
|
"Premieres in `x`": "يعرض فى `x`",
|
||||||
|
"Premieres `x`": "يعرض `x`",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.",
|
||||||
"View YouTube comments": "عرض تعليقات اليوتيوب",
|
"View YouTube comments": "عرض تعليقات اليوتيوب",
|
||||||
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
|
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "(تم تعديلة)",
|
"(edited)": "(تم تعديلة)",
|
||||||
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
|
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
|
||||||
|
"permalink": "الرابط",
|
||||||
"`x` marked it with a ❤": "`x` اعجب بهذا",
|
"`x` marked it with a ❤": "`x` اعجب بهذا",
|
||||||
"Audio mode": "الوضع الصوتى",
|
"Audio mode": "الوضع الصوتى",
|
||||||
"Video mode": "وضع الفيديو",
|
"Video mode": "وضع الفيديو",
|
||||||
"Videos": "الفيديوهات",
|
"Videos": "الفيديوهات",
|
||||||
"Playlists": "قوائم التشغيل",
|
"Playlists": "قوائم التشغيل",
|
||||||
|
"Community": "المجتمع",
|
||||||
"Current version: ": "الإصدار الحالى"
|
"Current version: ": "الإصدار الحالى"
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "Abbestellen",
|
"Unsubscribe": "Abbestellen",
|
||||||
"Subscribe": "Abonnieren",
|
"Subscribe": "Abonnieren",
|
||||||
"View channel on YouTube": "Kanal auf YouTube anzeigen",
|
"View channel on YouTube": "Kanal auf YouTube anzeigen",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen",
|
||||||
"newest": "neueste",
|
"newest": "neueste",
|
||||||
"oldest": "älteste",
|
"oldest": "älteste",
|
||||||
"popular": "beliebt",
|
"popular": "beliebt",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Standardmäßig als nächstes abspielen: ",
|
"Play next by default: ": "Standardmäßig als nächstes 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? ": "Proxy-Videos? ",
|
"Proxy videos: ": "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: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "reddit",
|
"reddit": "reddit",
|
||||||
"Default captions: ": "Standarduntertitel: ",
|
"Default captions: ": "Standarduntertitel: ",
|
||||||
"Fallback captions: ": "Ersatzuntertitel: ",
|
"Fallback captions: ": "Ersatzuntertitel: ",
|
||||||
"Show related videos? ": "Ähnliche Videos anzeigen? ",
|
"Show related videos: ": "Ähnliche Videos anzeigen? ",
|
||||||
"Show annotations by default? ": "Standardmäßig Anmerkungen anzeigen? ",
|
"Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
|
||||||
"Visual preferences": "Anzeigeeinstellungen",
|
"Visual preferences": "Anzeigeeinstellungen",
|
||||||
"Dark mode: ": "Nachtmodus: ",
|
"Dark mode: ": "Nachtmodus: ",
|
||||||
"Thin mode: ": "Schlanker Modus: ",
|
"Thin mode: ": "Schlanker Modus: ",
|
||||||
"Subscription preferences": "Abonnementeinstellungen",
|
"Subscription preferences": "Abonnementeinstellungen",
|
||||||
"Show annotations by default for subscribed channels? ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
|
"Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
|
||||||
"Redirect homepage to feed: ": "Startseite zu Feed umleiten: ",
|
"Redirect homepage to feed: ": "Startseite zu Feed umleiten: ",
|
||||||
"Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ",
|
"Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ",
|
||||||
"Sort videos by: ": "Videos sortieren nach: ",
|
"Sort videos by: ": "Videos sortieren nach: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ",
|
"Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ",
|
||||||
"Only show unwatched: ": "Nur ungesehene anzeigen: ",
|
"Only show unwatched: ": "Nur ungesehene anzeigen: ",
|
||||||
"Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
|
"Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
|
||||||
|
"Enable web notifications": "Webbenachrichtigungen aktivieren",
|
||||||
|
"`x` uploaded a video": "`x` hat ein Video hochgeladen",
|
||||||
|
"`x` is live": "`x` ist live",
|
||||||
"Data preferences": "Dateneinstellungen",
|
"Data preferences": "Dateneinstellungen",
|
||||||
"Clear watch history": "Verlauf löschen",
|
"Clear watch history": "Verlauf löschen",
|
||||||
"Import/export data": "Daten im- exportieren",
|
"Import/export data": "Daten im- exportieren",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Administratoreinstellungen",
|
"Administrator preferences": "Administratoreinstellungen",
|
||||||
"Default homepage: ": "Standard-Homepage: ",
|
"Default homepage: ": "Standard-Homepage: ",
|
||||||
"Feed menu: ": "Feed-Menü: ",
|
"Feed menu: ": "Feed-Menü: ",
|
||||||
"Top enabled? ": "Top aktiviert? ",
|
"Top enabled: ": "Top aktiviert? ",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA aktiviert? ",
|
"CAPTCHA enabled: ": "CAPTCHA aktiviert? ",
|
||||||
"Login enabled? ": "Login aktiviert? ",
|
"Login enabled: ": "Login aktiviert? ",
|
||||||
"Registration enabled? ": "Registrierung aktiviert? ",
|
"Registration enabled: ": "Registrierung aktiviert? ",
|
||||||
"Report statistics? ": "Statistiken berichten? ",
|
"Report statistics: ": "Statistiken berichten? ",
|
||||||
"Save preferences": "Einstellungen speichern",
|
"Save preferences": "Einstellungen speichern",
|
||||||
"Subscription manager": "Abonnementverwaltung",
|
"Subscription manager": "Abonnementverwaltung",
|
||||||
"Token manager": "Token-Manager",
|
"Token manager": "Token-Manager",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Geteilt `x`",
|
"Shared `x`": "Geteilt `x`",
|
||||||
"`x` views": "`x` Ansichten",
|
"`x` views": "`x` Ansichten",
|
||||||
"Premieres in `x`": "Premieren in `x`",
|
"Premieres in `x`": "Premieren in `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.",
|
||||||
"View YouTube comments": "YouTube Kommentare anzeigen",
|
"View YouTube comments": "YouTube Kommentare anzeigen",
|
||||||
"View more comments on Reddit": "Mehr Kommentare auf Reddit anzeigen",
|
"View more comments on Reddit": "Mehr Kommentare auf Reddit anzeigen",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
"(edited)": "(editiert)",
|
"(edited)": "(editiert)",
|
||||||
"YouTube comment permalink": "YouTube-Kommentar Permalink",
|
"YouTube comment permalink": "YouTube-Kommentar Permalink",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "`x` markierte es mit einem ❤",
|
"`x` marked it with a ❤": "`x` markierte es mit einem ❤",
|
||||||
"Audio mode": "Audiomodus",
|
"Audio mode": "Audiomodus",
|
||||||
"Video mode": "Videomodus",
|
"Video mode": "Videomodus",
|
||||||
"Videos": "Videos",
|
"Videos": "Videos",
|
||||||
"Playlists": "Wiedergabelisten",
|
"Playlists": "Wiedergabelisten",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Aktuelle Version: "
|
"Current version: ": "Aktuelle Version: "
|
||||||
}
|
}
|
@ -62,7 +62,7 @@
|
|||||||
"Play next by default: ": "Αναπαραγωγή επόμενου: ",
|
"Play next by default: ": "Αναπαραγωγή επόμενου: ",
|
||||||
"Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ",
|
"Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ",
|
||||||
"Listen by default: ": "Φόρτωση μόνο ήχου: ",
|
"Listen by default: ": "Φόρτωση μόνο ήχου: ",
|
||||||
"Proxy videos? ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
|
"Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
|
||||||
"Default speed: ": "Προεπιλεγμένη ταχύτητα: ",
|
"Default speed: ": "Προεπιλεγμένη ταχύτητα: ",
|
||||||
"Preferred video quality: ": "Προτιμώμενη ανάλυση: ",
|
"Preferred video quality: ": "Προτιμώμενη ανάλυση: ",
|
||||||
"Player volume: ": "Ένταση αναπαραγωγής: ",
|
"Player volume: ": "Ένταση αναπαραγωγής: ",
|
||||||
@ -71,13 +71,13 @@
|
|||||||
"reddit": "reddit",
|
"reddit": "reddit",
|
||||||
"Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ",
|
"Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ",
|
||||||
"Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ",
|
"Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ",
|
||||||
"Show related videos? ": "Προβολή σχετικών βίντεο; ",
|
"Show related videos: ": "Προβολή σχετικών βίντεο; ",
|
||||||
"Show annotations by default? ": "Αυτόματη προβολή σημειώσεων; :",
|
"Show annotations by default: ": "Αυτόματη προβολή σημειώσεων; :",
|
||||||
"Visual preferences": "Προτιμήσεις εμφάνισης",
|
"Visual preferences": "Προτιμήσεις εμφάνισης",
|
||||||
"Dark mode: ": "Σκοτεινή λειτουργία: ",
|
"Dark mode: ": "Σκοτεινή λειτουργία: ",
|
||||||
"Thin mode: ": "Ελαφριά λειτουργία: ",
|
"Thin mode: ": "Ελαφριά λειτουργία: ",
|
||||||
"Subscription preferences": "Προτιμήσεις συνδρομών",
|
"Subscription preferences": "Προτιμήσεις συνδρομών",
|
||||||
"Show annotations by default for subscribed channels? ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
|
"Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
|
||||||
"Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ",
|
"Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ",
|
||||||
"Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
|
"Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
|
||||||
"Sort videos by: ": "Ταξινόμηση ανά: ",
|
"Sort videos by: ": "Ταξινόμηση ανά: ",
|
||||||
@ -91,6 +91,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ",
|
"Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ",
|
||||||
"Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ",
|
"Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ",
|
||||||
"Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
|
"Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "Προτιμήσεις δεδομένων",
|
"Data preferences": "Προτιμήσεις δεδομένων",
|
||||||
"Clear watch history": "Εκκαθάριση ιστορικού προβολής",
|
"Clear watch history": "Εκκαθάριση ιστορικού προβολής",
|
||||||
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",
|
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",
|
||||||
@ -102,11 +105,11 @@
|
|||||||
"Administrator preferences": "Προτιμήσεις διαχειριστή",
|
"Administrator preferences": "Προτιμήσεις διαχειριστή",
|
||||||
"Default homepage: ": "Προεπιλεγμένη αρχική: ",
|
"Default homepage: ": "Προεπιλεγμένη αρχική: ",
|
||||||
"Feed menu: ": "Μενού ροής συνδρομών: ",
|
"Feed menu: ": "Μενού ροής συνδρομών: ",
|
||||||
"Top enabled? ": "Ενεργοποίηση κορυφαίων; ",
|
"Top enabled: ": "Ενεργοποίηση κορυφαίων; ",
|
||||||
"CAPTCHA enabled? ": "Ενεργοποίηση CAPTCHA; ",
|
"CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ",
|
||||||
"Login enabled? ": "Ενεργοποίηση σύνδεσης; ",
|
"Login enabled: ": "Ενεργοποίηση σύνδεσης; ",
|
||||||
"Registration enabled? ": "Ενεργοποίηση εγγραφής; ",
|
"Registration enabled: ": "Ενεργοποίηση εγγραφής; ",
|
||||||
"Report statistics? ": "Αναφορά στατιστικών; ",
|
"Report statistics: ": "Αναφορά στατιστικών; ",
|
||||||
"Save preferences": "Αποθήκευση προτιμήσεων",
|
"Save preferences": "Αποθήκευση προτιμήσεων",
|
||||||
"Subscription manager": "Διαχειριστής συνδρομών",
|
"Subscription manager": "Διαχειριστής συνδρομών",
|
||||||
"Token manager": "Διαχειριστής διασυνδέσεων",
|
"Token manager": "Διαχειριστής διασυνδέσεων",
|
||||||
@ -151,6 +154,7 @@
|
|||||||
"": "`x` προβολές"
|
"": "`x` προβολές"
|
||||||
},
|
},
|
||||||
"Premieres in `x`": "Πρώτη προβολή σε `x`",
|
"Premieres in `x`": "Πρώτη προβολή σε `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ",
|
||||||
"View YouTube comments": "Προβολή σχολίων από το YouTube",
|
"View YouTube comments": "Προβολή σχολίων από το YouTube",
|
||||||
"View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit",
|
"View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit",
|
||||||
@ -351,10 +355,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
"(edited)": "(τροποποιημένο)",
|
"(edited)": "(τροποποιημένο)",
|
||||||
"YouTube comment permalink": "Σύνδεσμος YouTube σχολίου",
|
"YouTube comment permalink": "Σύνδεσμος YouTube σχολίου",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤",
|
"`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤",
|
||||||
"Audio mode": "Λειτουργία ήχου",
|
"Audio mode": "Λειτουργία ήχου",
|
||||||
"Video mode": "Λειτουργία βίντεο",
|
"Video mode": "Λειτουργία βίντεο",
|
||||||
"Videos": "Βίντεο",
|
"Videos": "Βίντεο",
|
||||||
"Playlists": "Λίστες Αναπαραγωγής",
|
"Playlists": "Λίστες Αναπαραγωγής",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Τρέχουσα έκδοση: "
|
"Current version: ": "Τρέχουσα έκδοση: "
|
||||||
}
|
}
|
@ -62,7 +62,7 @@
|
|||||||
"Play next by default: ": "Play next by default: ",
|
"Play next by default: ": "Play next by default: ",
|
||||||
"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? ",
|
"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: ",
|
||||||
@ -71,13 +71,13 @@
|
|||||||
"reddit": "reddit",
|
"reddit": "reddit",
|
||||||
"Default captions: ": "Default captions: ",
|
"Default captions: ": "Default captions: ",
|
||||||
"Fallback captions: ": "Fallback captions: ",
|
"Fallback captions: ": "Fallback captions: ",
|
||||||
"Show related videos? ": "Show related videos? ",
|
"Show related videos: ": "Show related videos: ",
|
||||||
"Show annotations by default? ": "Show annotations by default? ",
|
"Show annotations by default: ": "Show annotations by default: ",
|
||||||
"Visual preferences": "Visual preferences",
|
"Visual preferences": "Visual preferences",
|
||||||
"Dark mode: ": "Dark mode: ",
|
"Dark mode: ": "Dark mode: ",
|
||||||
"Thin mode: ": "Thin mode: ",
|
"Thin mode: ": "Thin mode: ",
|
||||||
"Subscription preferences": "Subscription preferences",
|
"Subscription preferences": "Subscription preferences",
|
||||||
"Show annotations by default for subscribed channels? ": "Show annotations by default for subscribed channels? ",
|
"Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",
|
||||||
"Redirect homepage to feed: ": "Redirect homepage to feed: ",
|
"Redirect homepage to feed: ": "Redirect homepage to feed: ",
|
||||||
"Number of videos shown in feed: ": "Number of videos shown in feed: ",
|
"Number of videos shown in feed: ": "Number of videos shown in feed: ",
|
||||||
"Sort videos by: ": "Sort videos by: ",
|
"Sort videos by: ": "Sort videos by: ",
|
||||||
@ -91,6 +91,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
|
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
|
||||||
"Only show unwatched: ": "Only show unwatched: ",
|
"Only show unwatched: ": "Only show unwatched: ",
|
||||||
"Only show notifications (if there are any): ": "Only show notifications (if there are any): ",
|
"Only show notifications (if there are any): ": "Only show notifications (if there are any): ",
|
||||||
|
"Enable web notifications": "Enable web notifications",
|
||||||
|
"`x` uploaded a video": "`x` uploaded a video",
|
||||||
|
"`x` is live": "`x` is live",
|
||||||
"Data preferences": "Data preferences",
|
"Data preferences": "Data preferences",
|
||||||
"Clear watch history": "Clear watch history",
|
"Clear watch history": "Clear watch history",
|
||||||
"Import/export data": "Import/export data",
|
"Import/export data": "Import/export data",
|
||||||
@ -102,11 +105,11 @@
|
|||||||
"Administrator preferences": "Administrator preferences",
|
"Administrator preferences": "Administrator preferences",
|
||||||
"Default homepage: ": "Default homepage: ",
|
"Default homepage: ": "Default homepage: ",
|
||||||
"Feed menu: ": "Feed menu: ",
|
"Feed menu: ": "Feed menu: ",
|
||||||
"Top enabled? ": "Top enabled? ",
|
"Top enabled: ": "Top enabled: ",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA enabled? ",
|
"CAPTCHA enabled: ": "CAPTCHA enabled: ",
|
||||||
"Login enabled? ": "Login enabled? ",
|
"Login enabled: ": "Login enabled? ",
|
||||||
"Registration enabled? ": "Registration enabled? ",
|
"Registration enabled: ": "Registration enabled? ",
|
||||||
"Report statistics? ": "Report statistics? ",
|
"Report statistics: ": "Report statistics? ",
|
||||||
"Save preferences": "Save preferences",
|
"Save preferences": "Save preferences",
|
||||||
"Subscription manager": "Subscription manager",
|
"Subscription manager": "Subscription manager",
|
||||||
"Token manager": "Token manager",
|
"Token manager": "Token manager",
|
||||||
@ -151,6 +154,7 @@
|
|||||||
"": "`x` views"
|
"": "`x` views"
|
||||||
},
|
},
|
||||||
"Premieres in `x`": "Premieres in `x`",
|
"Premieres in `x`": "Premieres in `x`",
|
||||||
|
"Premieres `x`": "Premieres `x`",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.",
|
||||||
"View YouTube comments": "View YouTube comments",
|
"View YouTube comments": "View YouTube comments",
|
||||||
"View more comments on Reddit": "View more comments on Reddit",
|
"View more comments on Reddit": "View more comments on Reddit",
|
||||||
@ -351,10 +355,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
"(edited)": "(edited)",
|
"(edited)": "(edited)",
|
||||||
"YouTube comment permalink": "YouTube comment permalink",
|
"YouTube comment permalink": "YouTube comment permalink",
|
||||||
|
"permalink": "",
|
||||||
"`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",
|
"Videos": "Videos",
|
||||||
"Playlists": "Playlists",
|
"Playlists": "Playlists",
|
||||||
|
"Community": "Community",
|
||||||
"Current version: ": "Current version: "
|
"Current version: ": "Current version: "
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "Malaboni",
|
"Unsubscribe": "Malaboni",
|
||||||
"Subscribe": "Aboni",
|
"Subscribe": "Aboni",
|
||||||
"View channel on YouTube": "Vidi kanalon en YouTube",
|
"View channel on YouTube": "Vidi kanalon en YouTube",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "Vidi ludliston en YouTube",
|
||||||
"newest": "pli novaj",
|
"newest": "pli novaj",
|
||||||
"oldest": "pli malnovaj",
|
"oldest": "pli malnovaj",
|
||||||
"popular": "popularaj",
|
"popular": "popularaj",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Ludi sekvan defaŭlte: ",
|
"Play next by default: ": "Ludi sekvan defaŭlte: ",
|
||||||
"Autoplay next video: ": "Aŭtomate ludi sekvan videon: ",
|
"Autoplay next video: ": "Aŭtomate ludi sekvan videon: ",
|
||||||
"Listen by default: ": "Aŭskulti defaŭlte: ",
|
"Listen by default: ": "Aŭskulti defaŭlte: ",
|
||||||
"Proxy videos? ": "Ĉu uzi prokuran servilon por videoj? ",
|
"Proxy videos: ": "Ĉu uzi prokuran servilon por videoj? ",
|
||||||
"Default speed: ": "Defaŭlta rapido: ",
|
"Default speed: ": "Defaŭlta rapido: ",
|
||||||
"Preferred video quality: ": "Preferita videkvalito: ",
|
"Preferred video quality: ": "Preferita videkvalito: ",
|
||||||
"Player volume: ": "Ludila sonforteco: ",
|
"Player volume: ": "Ludila sonforteco: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "reddit",
|
"reddit": "reddit",
|
||||||
"Default captions: ": "Defaŭltaj subtekstoj: ",
|
"Default captions: ": "Defaŭltaj subtekstoj: ",
|
||||||
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
|
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
|
||||||
"Show related videos? ": "Ĉu montri rilatajn videojn? ",
|
"Show related videos: ": "Ĉu montri rilatajn videojn? ",
|
||||||
"Show annotations by default? ": "Ĉu montri prinotojn defaŭlte? ",
|
"Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
|
||||||
"Visual preferences": "Vidaj preferoj",
|
"Visual preferences": "Vidaj preferoj",
|
||||||
"Dark mode: ": "Malhela reĝimo: ",
|
"Dark mode: ": "Malhela reĝimo: ",
|
||||||
"Thin mode: ": "Maldika reĝimo: ",
|
"Thin mode: ": "Maldika reĝimo: ",
|
||||||
"Subscription preferences": "Abonaj agordoj",
|
"Subscription preferences": "Abonaj agordoj",
|
||||||
"Show annotations by default for subscribed channels? ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
|
"Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
|
||||||
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
|
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
|
||||||
"Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ",
|
"Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ",
|
||||||
"Sort videos by: ": "Ordi videojn laŭ: ",
|
"Sort videos by: ": "Ordi videojn laŭ: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ",
|
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ",
|
||||||
"Only show unwatched: ": "Nur montri malviditajn: ",
|
"Only show unwatched: ": "Nur montri malviditajn: ",
|
||||||
"Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
|
"Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
|
||||||
|
"Enable web notifications": "Ebligi retejajn sciigojn",
|
||||||
|
"`x` uploaded a video": "`x` alŝutis videon",
|
||||||
|
"`x` is live": "`x` estas nuna",
|
||||||
"Data preferences": "Datumagordoj",
|
"Data preferences": "Datumagordoj",
|
||||||
"Clear watch history": "Forigi vidohistorion",
|
"Clear watch history": "Forigi vidohistorion",
|
||||||
"Import/export data": "Importi/Eksporti datumojn",
|
"Import/export data": "Importi/Eksporti datumojn",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Agordoj de administranto",
|
"Administrator preferences": "Agordoj de administranto",
|
||||||
"Default homepage: ": "Defaŭlta hejmpaĝo: ",
|
"Default homepage: ": "Defaŭlta hejmpaĝo: ",
|
||||||
"Feed menu: ": "Flua menuo: ",
|
"Feed menu: ": "Flua menuo: ",
|
||||||
"Top enabled? ": "Ĉu pli bonaj ŝaltitaj? ",
|
"Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ",
|
||||||
"CAPTCHA enabled? ": "Ĉu CAPTCHA ŝaltita? ",
|
"CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ",
|
||||||
"Login enabled? ": "Ĉu ensaluto aktivita? ",
|
"Login enabled: ": "Ĉu ensaluto aktivita? ",
|
||||||
"Registration enabled? ": "Ĉu registriĝo aktivita? ",
|
"Registration enabled: ": "Ĉu registriĝo aktivita? ",
|
||||||
"Report statistics? ": "Ĉu raporti statistikojn? ",
|
"Report statistics: ": "Ĉu raporti statistikojn? ",
|
||||||
"Save preferences": "Konservi agordojn",
|
"Save preferences": "Konservi agordojn",
|
||||||
"Subscription manager": "Administrilo de abonoj",
|
"Subscription manager": "Administrilo de abonoj",
|
||||||
"Token manager": "Ĵetona administrilo",
|
"Token manager": "Ĵetona administrilo",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Konigita `x`",
|
"Shared `x`": "Konigita `x`",
|
||||||
"`x` views": "`x` spektaĵoj",
|
"`x` views": "`x` spektaĵoj",
|
||||||
"Premieres in `x`": "Premieras en `x`",
|
"Premieres in `x`": "Premieras en `x`",
|
||||||
|
"Premieres `x`": "Premieras `x`",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.",
|
||||||
"View YouTube comments": "Vidi komentojn de YouTube",
|
"View YouTube comments": "Vidi komentojn de YouTube",
|
||||||
"View more comments on Reddit": "Vidi pli komentoj en Reddit",
|
"View more comments on Reddit": "Vidi pli komentoj en Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %-d de %B %Y",
|
"%A %B %-d, %Y": "%A %-d de %B %Y",
|
||||||
"(edited)": "(redaktita)",
|
"(edited)": "(redaktita)",
|
||||||
"YouTube comment permalink": "Fiksligilo de la komento en YouTube",
|
"YouTube comment permalink": "Fiksligilo de la komento en YouTube",
|
||||||
|
"permalink": "konstanta ligilo",
|
||||||
"`x` marked it with a ❤": "`x` markis ĝin per ❤",
|
"`x` marked it with a ❤": "`x` markis ĝin per ❤",
|
||||||
"Audio mode": "Aŭda reĝimo",
|
"Audio mode": "Aŭda reĝimo",
|
||||||
"Video mode": "Videa reĝimo",
|
"Video mode": "Videa reĝimo",
|
||||||
"Videos": "Videoj",
|
"Videos": "Videoj",
|
||||||
"Playlists": "Ludlistoj",
|
"Playlists": "Ludlistoj",
|
||||||
|
"Community": "Komunumo",
|
||||||
"Current version: ": "Nuna versio: "
|
"Current version: ": "Nuna versio: "
|
||||||
}
|
}
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Reproducir siguiente por defecto: ",
|
"Play next by default: ": "Reproducir siguiente por defecto: ",
|
||||||
"Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ",
|
"Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ",
|
||||||
"Listen by default: ": "Activar el sonido por defecto: ",
|
"Listen by default: ": "Activar el sonido por defecto: ",
|
||||||
"Proxy videos? ": "¿Usar un proxy para los vídeos? ",
|
"Proxy videos: ": "¿Usar un proxy para los vídeos? ",
|
||||||
"Default speed: ": "Velocidad por defecto: ",
|
"Default speed: ": "Velocidad por defecto: ",
|
||||||
"Preferred video quality: ": "Calidad de vídeo preferida: ",
|
"Preferred video quality: ": "Calidad de vídeo preferida: ",
|
||||||
"Player volume: ": "Volumen del reproductor: ",
|
"Player volume: ": "Volumen del reproductor: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Subtítulos por defecto: ",
|
"Default captions: ": "Subtítulos por defecto: ",
|
||||||
"Fallback captions: ": "Subtítulos alternativos: ",
|
"Fallback captions: ": "Subtítulos alternativos: ",
|
||||||
"Show related videos? ": "¿Mostrar vídeos relacionados? ",
|
"Show related videos: ": "¿Mostrar vídeos relacionados? ",
|
||||||
"Show annotations by default? ": "¿Mostrar anotaciones por defecto? ",
|
"Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
|
||||||
"Visual preferences": "Preferencias visuales",
|
"Visual preferences": "Preferencias visuales",
|
||||||
"Dark mode: ": "Modo oscuro: ",
|
"Dark mode: ": "Modo oscuro: ",
|
||||||
"Thin mode: ": "Modo compacto: ",
|
"Thin mode: ": "Modo compacto: ",
|
||||||
"Subscription preferences": "Preferencias de la suscripción",
|
"Subscription preferences": "Preferencias de la suscripción",
|
||||||
"Show annotations by default for subscribed channels? ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
|
"Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
|
||||||
"Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ",
|
"Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ",
|
||||||
"Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ",
|
"Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ",
|
||||||
"Sort videos by: ": "Ordenar los vídeos por: ",
|
"Sort videos by: ": "Ordenar los vídeos por: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
|
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
|
||||||
"Only show unwatched: ": "Mostrar solo los no vistos: ",
|
"Only show unwatched: ": "Mostrar solo los no vistos: ",
|
||||||
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
|
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "Preferencias de los datos",
|
"Data preferences": "Preferencias de los datos",
|
||||||
"Clear watch history": "Borrar el historial de reproducción",
|
"Clear watch history": "Borrar el historial de reproducción",
|
||||||
"Import/export data": "Importar/Exportar datos",
|
"Import/export data": "Importar/Exportar datos",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Preferencias de administrador",
|
"Administrator preferences": "Preferencias de administrador",
|
||||||
"Default homepage: ": "Página de inicio por defecto: ",
|
"Default homepage: ": "Página de inicio por defecto: ",
|
||||||
"Feed menu: ": "Menú de fuentes: ",
|
"Feed menu: ": "Menú de fuentes: ",
|
||||||
"Top enabled? ": "¿Habilitar los destacados? ",
|
"Top enabled: ": "¿Habilitar los destacados? ",
|
||||||
"CAPTCHA enabled? ": "¿Habilitar los CAPTCHA? ",
|
"CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ",
|
||||||
"Login enabled? ": "¿Habilitar el inicio de sesión? ",
|
"Login enabled: ": "¿Habilitar el inicio de sesión? ",
|
||||||
"Registration enabled? ": "¿Habilitar el registro? ",
|
"Registration enabled: ": "¿Habilitar el registro? ",
|
||||||
"Report statistics? ": "¿Enviar estadísticas? ",
|
"Report statistics: ": "¿Enviar estadísticas? ",
|
||||||
"Save preferences": "Guardar las preferencias",
|
"Save preferences": "Guardar las preferencias",
|
||||||
"Subscription manager": "Gestor de suscripciones",
|
"Subscription manager": "Gestor de suscripciones",
|
||||||
"Token manager": "Gestor de tokens",
|
"Token manager": "Gestor de tokens",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Compartido `x`",
|
"Shared `x`": "Compartido `x`",
|
||||||
"`x` views": "`x` visualizaciones",
|
"`x` views": "`x` visualizaciones",
|
||||||
"Premieres in `x`": "Se estrena en `x`",
|
"Premieres in `x`": "Se estrena en `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.",
|
||||||
"View YouTube comments": "Ver los comentarios de YouTube",
|
"View YouTube comments": "Ver los comentarios de YouTube",
|
||||||
"View more comments on Reddit": "Ver más comentarios en Reddit",
|
"View more comments on Reddit": "Ver más comentarios en Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
"(edited)": "(editado)",
|
"(edited)": "(editado)",
|
||||||
"YouTube comment permalink": "Enlace permanente de YouTube del comentario",
|
"YouTube comment permalink": "Enlace permanente de YouTube del comentario",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
|
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
|
||||||
"Audio mode": "Modo de audio",
|
"Audio mode": "Modo de audio",
|
||||||
"Video mode": "Modo de vídeo",
|
"Video mode": "Modo de vídeo",
|
||||||
"Videos": "Vídeos",
|
"Videos": "Vídeos",
|
||||||
"Playlists": "Listas de reproducción",
|
"Playlists": "Listas de reproducción",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Versión actual: "
|
"Current version: ": "Versión actual: "
|
||||||
}
|
}
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "",
|
"Play next by default: ": "",
|
||||||
"Autoplay next video: ": "",
|
"Autoplay next video: ": "",
|
||||||
"Listen by default: ": "",
|
"Listen by default: ": "",
|
||||||
"Proxy videos? ": "",
|
"Proxy videos: ": "",
|
||||||
"Default speed: ": "",
|
"Default speed: ": "",
|
||||||
"Preferred video quality: ": "",
|
"Preferred video quality: ": "",
|
||||||
"Player volume: ": "",
|
"Player volume: ": "",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "",
|
"reddit": "",
|
||||||
"Default captions: ": "",
|
"Default captions: ": "",
|
||||||
"Fallback captions: ": "",
|
"Fallback captions: ": "",
|
||||||
"Show related videos? ": "",
|
"Show related videos: ": "",
|
||||||
"Show annotations by default? ": "",
|
"Show annotations by default: ": "",
|
||||||
"Visual preferences": "",
|
"Visual preferences": "",
|
||||||
"Dark mode: ": "",
|
"Dark mode: ": "",
|
||||||
"Thin mode: ": "",
|
"Thin mode: ": "",
|
||||||
"Subscription preferences": "",
|
"Subscription preferences": "",
|
||||||
"Show annotations by default for subscribed channels? ": "",
|
"Show annotations by default for subscribed channels: ": "",
|
||||||
"Redirect homepage to feed: ": "",
|
"Redirect homepage to feed: ": "",
|
||||||
"Number of videos shown in feed: ": "",
|
"Number of videos shown in feed: ": "",
|
||||||
"Sort videos by: ": "",
|
"Sort videos by: ": "",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "",
|
"Only show latest unwatched video from channel: ": "",
|
||||||
"Only show unwatched: ": "",
|
"Only show unwatched: ": "",
|
||||||
"Only show notifications (if there are any): ": "",
|
"Only show notifications (if there are any): ": "",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "",
|
"Data preferences": "",
|
||||||
"Clear watch history": "",
|
"Clear watch history": "",
|
||||||
"Import/export data": "",
|
"Import/export data": "",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "",
|
"Administrator preferences": "",
|
||||||
"Default homepage: ": "",
|
"Default homepage: ": "",
|
||||||
"Feed menu: ": "",
|
"Feed menu: ": "",
|
||||||
"Top enabled? ": "",
|
"Top enabled: ": "",
|
||||||
"CAPTCHA enabled? ": "",
|
"CAPTCHA enabled: ": "",
|
||||||
"Login enabled? ": "",
|
"Login enabled: ": "",
|
||||||
"Registration enabled? ": "",
|
"Registration enabled: ": "",
|
||||||
"Report statistics? ": "",
|
"Report statistics: ": "",
|
||||||
"Save preferences": "",
|
"Save preferences": "",
|
||||||
"Subscription manager": "",
|
"Subscription manager": "",
|
||||||
"Token manager": "",
|
"Token manager": "",
|
||||||
@ -306,6 +309,7 @@
|
|||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "",
|
"(edited)": "",
|
||||||
"YouTube comment permalink": "",
|
"YouTube comment permalink": "",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "",
|
"`x` marked it with a ❤": "",
|
||||||
"Audio mode": "",
|
"Audio mode": "",
|
||||||
"Video mode": "",
|
"Video mode": "",
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Jouer suirvante par défaut : ",
|
"Play next by default: ": "Jouer suirvante par défaut : ",
|
||||||
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
||||||
"Listen by default: ": "Audio uniquement : ",
|
"Listen by default: ": "Audio uniquement : ",
|
||||||
"Proxy videos? ": "Charger les vidéos à travers un proxy ? ",
|
"Proxy videos: ": "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 : ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Sous-titres par défaut : ",
|
"Default captions: ": "Sous-titres par défaut : ",
|
||||||
"Fallback captions: ": "Sous-titres de repli : ",
|
"Fallback captions: ": "Sous-titres de repli : ",
|
||||||
"Show related videos? ": "Voir les vidéos liées ? ",
|
"Show related videos: ": "Voir les vidéos liées ? ",
|
||||||
"Show annotations by default? ": "Voir les annotations par défaut ? ",
|
"Show annotations by default: ": "Voir les annotations par défaut ? ",
|
||||||
"Visual preferences": "Préférences du site",
|
"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",
|
||||||
"Show annotations by default for subscribed channels? ": "Voir les annotations par défaut sur les chaînes suivies ? ",
|
"Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies ? ",
|
||||||
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
|
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
|
||||||
"Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
|
"Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
|
||||||
"Sort videos by: ": "Trier les vidéos par : ",
|
"Sort videos by: ": "Trier les vidéos par : ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
|
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
|
||||||
"Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ",
|
"Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ",
|
||||||
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
|
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "Préférences liées aux données",
|
"Data preferences": "Préférences liées aux données",
|
||||||
"Clear watch history": "Supprimer l'historique des vidéos regardées",
|
"Clear watch history": "Supprimer l'historique des vidéos regardées",
|
||||||
"Import/export data": "Importer/exporter les données",
|
"Import/export data": "Importer/exporter les données",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Préferences d'Administrateur",
|
"Administrator preferences": "Préferences d'Administrateur",
|
||||||
"Default homepage: ": "Page d'accueil par défaut : ",
|
"Default homepage: ": "Page d'accueil par défaut : ",
|
||||||
"Feed menu: ": "Menu des Flux : ",
|
"Feed menu: ": "Menu des Flux : ",
|
||||||
"Top enabled? ": "Top activé ? ",
|
"Top enabled: ": "Top activé ? ",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA activé ? ",
|
"CAPTCHA enabled: ": "CAPTCHA activé ? ",
|
||||||
"Login enabled? ": "Connexion activé ? ",
|
"Login enabled: ": "Connexion activé ? ",
|
||||||
"Registration enabled? ": "Inscription activée ? ",
|
"Registration enabled: ": "Inscription activée ? ",
|
||||||
"Report statistics? ": "Télémétrie activé ? ",
|
"Report statistics: ": "Télémétrie 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",
|
||||||
"Token manager": "Gestionnaire de tokens",
|
"Token manager": "Gestionnaire de tokens",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Ajoutée le `x`",
|
"Shared `x`": "Ajoutée le `x`",
|
||||||
"`x` views": "`x` vues",
|
"`x` views": "`x` vues",
|
||||||
"Premieres in `x`": "Première dans `x`",
|
"Premieres in `x`": "Première dans `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires sans. Gardez à l'esprit que le chargement peut prendre plus de temps.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires sans. Gardez à l'esprit que le chargement peut prendre plus de temps.",
|
||||||
"View YouTube comments": "Voir les commentaires YouTube",
|
"View YouTube comments": "Voir les commentaires YouTube",
|
||||||
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
|
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||||
"(edited)": "(modifié)",
|
"(edited)": "(modifié)",
|
||||||
"YouTube comment permalink": "Lien YouTube permanent vers le commentaire",
|
"YouTube comment permalink": "Lien YouTube permanent vers le commentaire",
|
||||||
|
"permalink": "",
|
||||||
"`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",
|
"Videos": "Vidéos",
|
||||||
"Playlists": "Liste de lecture",
|
"Playlists": "Liste de lecture",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Version actuelle : "
|
"Current version: ": "Version actuelle : "
|
||||||
}
|
}
|
319
locales/is.json
Normal file
319
locales/is.json
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
{
|
||||||
|
"`x` subscribers.": "`x` áskrifandar.",
|
||||||
|
"`x` videos.": "`x` myndbönd.",
|
||||||
|
"LIVE": "BEINT",
|
||||||
|
"Shared `x` ago": "Deilt `x` síðan",
|
||||||
|
"Unsubscribe": "Afskrá",
|
||||||
|
"Subscribe": "Áskrifa",
|
||||||
|
"View channel on YouTube": "Skoða rás á YouTube",
|
||||||
|
"View playlist on YouTube": "Skoða spilunarlisti á YouTube",
|
||||||
|
"newest": "nýjasta",
|
||||||
|
"oldest": "elsta",
|
||||||
|
"popular": "vinsællt",
|
||||||
|
"last": "síðast",
|
||||||
|
"Next page": "Næsta síða",
|
||||||
|
"Previous page": "Fyrri síða",
|
||||||
|
"Clear watch history?": "Hreinsa áhorfssögu?",
|
||||||
|
"New password": "Nýtt lykilorð",
|
||||||
|
"New passwords must match": "Nýtt lykilorð verður að passa",
|
||||||
|
"Cannot change password for Google accounts": "Ekki er hægt að breyta lykilorði fyrir Google reikninga",
|
||||||
|
"Authorize token?": "Leyfa tákn?",
|
||||||
|
"Authorize token for `x`?": "Leyfa tákn fyrir `x`?",
|
||||||
|
"Yes": "Já",
|
||||||
|
"No": "Nei",
|
||||||
|
"Import and Export Data": "Innflutningur og Útflutningur Gagna",
|
||||||
|
"Import": "Flytja inn",
|
||||||
|
"Import Invidious data": "Flytja inn Invidious gögn",
|
||||||
|
"Import YouTube subscriptions": "Flytja inn YouTube áskriftir",
|
||||||
|
"Import FreeTube subscriptions (.db)": "Flytja inn FreeTube áskriftir (.db)",
|
||||||
|
"Import NewPipe subscriptions (.json)": "Flytja inn NewPipe áskriftir (.json)",
|
||||||
|
"Import NewPipe data (.zip)": "Flytja inn NewPipe gögn (.zip)",
|
||||||
|
"Export": "Flytja út",
|
||||||
|
"Export subscriptions as OPML": "Flytja út áskriftir sem OPML",
|
||||||
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "Flytja út áskriftir sem OPML (fyrir NewPipe & FreeTube)",
|
||||||
|
"Export data as JSON": "Flytja út gögn sem JSON",
|
||||||
|
"Delete account?": "Eyða reikningi?",
|
||||||
|
"History": "Saga",
|
||||||
|
"An alternative front-end to YouTube": "Önnur framhlið fyrir YouTube",
|
||||||
|
"JavaScript license information": "JavaScript leyfi upplýsingar",
|
||||||
|
"source": "uppspretta",
|
||||||
|
"Log in": "Skrá inn",
|
||||||
|
"Log in/register": "Innskráning/nýskráning",
|
||||||
|
"Log in with Google": "Skrá inn með Google",
|
||||||
|
"User ID": "Notandakenni",
|
||||||
|
"Password": "Lykilorð",
|
||||||
|
"Time (h:mm:ss):": "Tími (h:mm: ss):",
|
||||||
|
"Text CAPTCHA": "Texta CAPTCHA",
|
||||||
|
"Image CAPTCHA": "Mynd CAPTCHA",
|
||||||
|
"Sign In": "Skrá inn",
|
||||||
|
"Register": "Nýskrá",
|
||||||
|
"E-mail": "Tölvupóstur",
|
||||||
|
"Google verification code": "Google staðfestingarkóði",
|
||||||
|
"Preferences": "Kjörstillingar",
|
||||||
|
"Player preferences": "Kjörstillingar spilara",
|
||||||
|
"Always loop: ": "Alltaf lykkja: ",
|
||||||
|
"Autoplay: ": "Spila sjálfkrafa: ",
|
||||||
|
"Play next by default: ": "Spila næst sjálfgefið: ",
|
||||||
|
"Autoplay next video: ": "Spila næst sjálfkrafa: ",
|
||||||
|
"Listen by default: ": "Hlusta sjálfgefið: ",
|
||||||
|
"Proxy videos: ": "Proxy myndbönd? ",
|
||||||
|
"Default speed: ": "Sjálfgefinn hraði: ",
|
||||||
|
"Preferred video quality: ": "Æskilegt myndbands gæði: ",
|
||||||
|
"Player volume: ": "Spilara bindi: ",
|
||||||
|
"Default comments: ": "Sjálfgefin ummæli: ",
|
||||||
|
"youtube": "youtube",
|
||||||
|
"reddit": "reddit",
|
||||||
|
"Default captions: ": "Sjálfgefin texti: ",
|
||||||
|
"Fallback captions: ": "Varatextar: ",
|
||||||
|
"Show related videos: ": "Sýna tengd myndbönd? ",
|
||||||
|
"Show annotations by default: ": "Á að sýna glósur sjálfgefið? ",
|
||||||
|
"Visual preferences": "Sjónrænar stillingar",
|
||||||
|
"Dark mode: ": "Myrkur ham: ",
|
||||||
|
"Thin mode: ": "Þunnt ham: ",
|
||||||
|
"Subscription preferences": "Áskriftarstillingar",
|
||||||
|
"Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ",
|
||||||
|
"Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ",
|
||||||
|
"Number of videos shown in feed: ": "Fjöldi myndbanda sem sýndir eru í straumi: ",
|
||||||
|
"Sort videos by: ": "Raða myndbönd eftir: ",
|
||||||
|
"published": "birt",
|
||||||
|
"published - reverse": "birt - afturábak",
|
||||||
|
"alphabetically": "í stafrófsröð",
|
||||||
|
"alphabetically - reverse": "stafrófsröð - afturábak",
|
||||||
|
"channel name": "heiti rásar",
|
||||||
|
"channel name - reverse": "heiti rásar - afturábak",
|
||||||
|
"Only show latest video from channel: ": "Sýna aðeins nýjasta myndband frá rás: ",
|
||||||
|
"Only show latest unwatched video from channel: ": "Sýna aðeins nýjasta óséð myndband frá rás: ",
|
||||||
|
"Only show unwatched: ": "Sýna aðeins óséð: ",
|
||||||
|
"Only show notifications (if there are any): ": "Sýna aðeins tilkynningar (ef einhverjar eru): ",
|
||||||
|
"Enable web notifications": "Virkja veftilkynningar",
|
||||||
|
"`x` uploaded a video": "`x` hlóð upp myndband",
|
||||||
|
"`x` is live": "`x` er í beinni",
|
||||||
|
"Data preferences": "Gagnastillingar",
|
||||||
|
"Clear watch history": "Hreinsa áhorfssögu",
|
||||||
|
"Import/export data": "Flytja inn/út gögn",
|
||||||
|
"Change password": "Breyta lykilorði",
|
||||||
|
"Manage subscriptions": "Stjórna áskriftum",
|
||||||
|
"Manage tokens": "Stjórna tákn",
|
||||||
|
"Watch history": "Áhorfssögu",
|
||||||
|
"Delete account": "Eyða reikningi",
|
||||||
|
"Administrator preferences": "Kjörstillingar stjórnanda",
|
||||||
|
"Default homepage: ": "Sjálfgefin heimasíða: ",
|
||||||
|
"Feed menu: ": "Straum valmynd: ",
|
||||||
|
"Top enabled: ": "Toppur virkur? ",
|
||||||
|
"CAPTCHA enabled: ": "CAPTCHA virk? ",
|
||||||
|
"Login enabled: ": "Innskráning virk? ",
|
||||||
|
"Registration enabled: ": "Nýskráning virkjuð? ",
|
||||||
|
"Report statistics: ": "Skrá talnagögn? ",
|
||||||
|
"Save preferences": "Vista stillingar",
|
||||||
|
"Subscription manager": "Áskriftarstjóri",
|
||||||
|
"Token manager": "Táknstjóri",
|
||||||
|
"Token": "Tákn",
|
||||||
|
"`x` subscriptions.": "`x` áskriftir.",
|
||||||
|
"`x` tokens.": "`x` tákn.",
|
||||||
|
"Import/export": "Flytja inn/út",
|
||||||
|
"unsubscribe": "afskrá",
|
||||||
|
"revoke": "afturkalla",
|
||||||
|
"Subscriptions": "Áskriftir",
|
||||||
|
"`x` unseen notifications.": "`x` óséðar tilkynningar.",
|
||||||
|
"search": "leita",
|
||||||
|
"Log out": "Útskrá",
|
||||||
|
"Released under the AGPLv3 by Omar Roth.": "Útgefið undir AGPLv3 eftir Omar Roth.",
|
||||||
|
"Source available here.": "Frumkóði aðgengilegur hér.",
|
||||||
|
"View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.",
|
||||||
|
"View privacy policy.": "Skoða meðferð persónuupplýsinga.",
|
||||||
|
"Trending": "Vinsælt",
|
||||||
|
"Unlisted": "Óskráð",
|
||||||
|
"Watch on YouTube": "Horfa á YouTube",
|
||||||
|
"Hide annotations": "Fela glósur",
|
||||||
|
"Show annotations": "Sýna glósur",
|
||||||
|
"Genre: ": "Tegund: ",
|
||||||
|
"License: ": "Notkunarleyfi: ",
|
||||||
|
"Family friendly? ": "Fjölskylduvænt? ",
|
||||||
|
"Wilson score: ": "Wilson stig: ",
|
||||||
|
"Engagement: ": "Þátttöku: ",
|
||||||
|
"Whitelisted regions: ": "Svæði á hvítum lista: ",
|
||||||
|
"Blacklisted regions: ": "Svæði á svörtum lista: ",
|
||||||
|
"Shared `x`": "Deilt `x`",
|
||||||
|
"`x` views.": "`x` áhorf.",
|
||||||
|
"Premieres in `x`": "Frumflutt eftir `x`",
|
||||||
|
"Premieres `x`": "Frumflutt `x`",
|
||||||
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hæ! Lítur út eins og þú hafir slökkt á JavaScript. Smelltu hér til að skoða ummæli, hafðu í huga að þær geta tekið aðeins lengri tíma að hlaða.",
|
||||||
|
"View YouTube comments": "Skoða YouTube ummæli",
|
||||||
|
"View more comments on Reddit": "Skoða fleiri ummæli á Reddit",
|
||||||
|
"View `x` comments": "Skoða `x` ummæli",
|
||||||
|
"View Reddit comments": "Skoða Reddit ummæli",
|
||||||
|
"Hide replies": "Fela svör",
|
||||||
|
"Show replies": "Sýna svör",
|
||||||
|
"Incorrect password": "Rangt lykilorð",
|
||||||
|
"Quota exceeded, try again in a few hours": "Kvóti fór yfir, reyndu aftur eftir nokkrar klukkustundir",
|
||||||
|
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ekki er hægt að skrá þig inn, vertu viss um að tvíþætt staðfesting (Authenticator eða SMS) sé kveikt á.",
|
||||||
|
"Invalid TFA code": "Ógildur TFA kóði",
|
||||||
|
"Login failed. This may be because two-factor authentication is not turned on for your account.": "Innskráning mistókst. Þetta gæti verið vegna þess að tvíþátta staðfesting er ekki kveikt á reikningnum þínum.",
|
||||||
|
"Wrong answer": "Rangt svar",
|
||||||
|
"Erroneous CAPTCHA": "Rangt CAPTCHA",
|
||||||
|
"CAPTCHA is a required field": "CAPTCHA er nauðsynlegur reitur",
|
||||||
|
"User ID is a required field": "Notandakenni er nauðsynlegur reitur",
|
||||||
|
"Password is a required field": "Lykilorð er nauðsynlegur reitur",
|
||||||
|
"Wrong username or password": "Rangt notandanafn eða lykilorð",
|
||||||
|
"Please sign in using 'Log in with Google'": "Vinsamlegast skráðu þig inn með því að nota 'Innskráning með Google'",
|
||||||
|
"Password cannot be empty": "Lykilorð má ekki vera autt",
|
||||||
|
"Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir",
|
||||||
|
"Please log in": "Vinsamlegast skráðu þig inn",
|
||||||
|
"Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`",
|
||||||
|
"channel:`x`": "rás:`x`",
|
||||||
|
"Deleted or invalid channel": "Eytt eða ógild rás",
|
||||||
|
"This channel does not exist.": "Þessi rás er ekki til.",
|
||||||
|
"Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.",
|
||||||
|
"Could not fetch comments": "Ekki tókst að sækja ummæli",
|
||||||
|
"View `x` replies.": "Skoða `x` svör.",
|
||||||
|
"`x` ago": "' x ' síðan",
|
||||||
|
"Load more": "Hlaða meira",
|
||||||
|
"`x` points.": "`x` stig.",
|
||||||
|
"Could not create mix.": "Ekki tókst að búa til blöndu.",
|
||||||
|
"Empty playlist": "Tómur spilunarlisti",
|
||||||
|
"Not a playlist.": "Ekki spilunarlisti.",
|
||||||
|
"Playlist does not exist.": "Spilunarlisti er ekki til.",
|
||||||
|
"Could not pull trending pages.": "Ekki tókst að draga vinsællar síður.",
|
||||||
|
"Hidden field \"challenge\" is a required field": "Falinn reitur \"áskorun\" er nauðsynlegur reitur",
|
||||||
|
"Hidden field \"token\" is a required field": "Falinn reitur \"tákn\" er nauðsynlegur reitur",
|
||||||
|
"Erroneous challenge": "Röng áskorun",
|
||||||
|
"Erroneous token": "Rangt tákn",
|
||||||
|
"No such user": "Enginn slíkur notandi",
|
||||||
|
"Token is expired, please try again": "Tákn er útrunnið, vinsamlegast reyndu aftur",
|
||||||
|
"English": "Enska",
|
||||||
|
"English (auto-generated)": "Enska (sjálfkrafa)",
|
||||||
|
"Afrikaans": "Afríkanska",
|
||||||
|
"Albanian": "Albanska",
|
||||||
|
"Amharic": "Amharíska",
|
||||||
|
"Arabic": "Arabíska",
|
||||||
|
"Armenian": "Armenska",
|
||||||
|
"Azerbaijani": "Aserbaídsjanska",
|
||||||
|
"Bangla": "Bangla",
|
||||||
|
"Basque": "Baskneska",
|
||||||
|
"Belarusian": "Hvítrússneska",
|
||||||
|
"Bosnian": "Bosníska",
|
||||||
|
"Bulgarian": "Búlgarska",
|
||||||
|
"Burmese": "Búrmíska",
|
||||||
|
"Catalan": "Katalónska",
|
||||||
|
"Cebuano": "Cebúanó",
|
||||||
|
"Chinese (Simplified)": "Kínverska (Einfölduð)",
|
||||||
|
"Chinese (Traditional)": "Kínverska (Hefðbundin)",
|
||||||
|
"Corsican": "Korsíska",
|
||||||
|
"Croatian": "Króatíska",
|
||||||
|
"Czech": "Tékkneska",
|
||||||
|
"Danish": "Danska",
|
||||||
|
"Dutch": "Hollenska",
|
||||||
|
"Esperanto": "Esperantó",
|
||||||
|
"Estonian": "Eistneska",
|
||||||
|
"Filipino": "Filippínska",
|
||||||
|
"Finnish": "Finnska",
|
||||||
|
"French": "Franska",
|
||||||
|
"Galician": "Galisíska",
|
||||||
|
"Georgian": "Georgíska",
|
||||||
|
"German": "Þýska",
|
||||||
|
"Greek": "Gríska",
|
||||||
|
"Gujarati": "Gújaratí",
|
||||||
|
"Haitian Creole": "Haítískt Kreólamál",
|
||||||
|
"Hausa": "Hausa",
|
||||||
|
"Hawaiian": "Havaíska",
|
||||||
|
"Hebrew": "Hebreska",
|
||||||
|
"Hindi": "Hindí",
|
||||||
|
"Hmong": "Hmong",
|
||||||
|
"Hungarian": "Ungverska",
|
||||||
|
"Icelandic": "Íslenska",
|
||||||
|
"Igbo": "Igbo",
|
||||||
|
"Indonesian": "Indónesíska",
|
||||||
|
"Irish": "Írska",
|
||||||
|
"Italian": "Ítalska",
|
||||||
|
"Japanese": "Japanska",
|
||||||
|
"Javanese": "Javanska",
|
||||||
|
"Kannada": "Kanaríska",
|
||||||
|
"Kazakh": "Kasakíska",
|
||||||
|
"Khmer": "Khmeríska",
|
||||||
|
"Korean": "Kóreska",
|
||||||
|
"Kurdish": "Kúrdíska",
|
||||||
|
"Kyrgyz": "Kirgisíska",
|
||||||
|
"Lao": "Laó",
|
||||||
|
"Latin": "Latína",
|
||||||
|
"Latvian": "Lettneska",
|
||||||
|
"Lithuanian": "Litháíska",
|
||||||
|
"Luxembourgish": "Lúxemborgíska",
|
||||||
|
"Macedonian": "Makedóníska",
|
||||||
|
"Malagasy": "Malagasíska",
|
||||||
|
"Malay": "Malaíska",
|
||||||
|
"Malayalam": "Malaíalam",
|
||||||
|
"Maltese": "Maltneska",
|
||||||
|
"Maori": "Maórí",
|
||||||
|
"Marathi": "Marathi",
|
||||||
|
"Mongolian": "Mongólska",
|
||||||
|
"Nepali": "Nepalska",
|
||||||
|
"Norwegian Bokmål": "Norskt bókmál",
|
||||||
|
"Nyanja": "Nyanja",
|
||||||
|
"Pashto": "Pashto",
|
||||||
|
"Persian": "Persneska",
|
||||||
|
"Polish": "Pólska",
|
||||||
|
"Portuguese": "Portúgalska",
|
||||||
|
"Punjabi": "Punjabi",
|
||||||
|
"Romanian": "Rúmenska",
|
||||||
|
"Russian": "Rússneska",
|
||||||
|
"Samoan": "Samóíska",
|
||||||
|
"Scottish Gaelic": "Skosk Gelíska",
|
||||||
|
"Serbian": "Serbneska",
|
||||||
|
"Shona": "Shona",
|
||||||
|
"Sindhi": "Sindí",
|
||||||
|
"Sinhala": "Sinhala",
|
||||||
|
"Slovak": "Slóvakíska",
|
||||||
|
"Slovenian": "Slóvenska",
|
||||||
|
"Somali": "Sómalska",
|
||||||
|
"Southern Sotho": "Suður Sótó",
|
||||||
|
"Spanish": "Spænska",
|
||||||
|
"Spanish (Latin America)": "Spænska (Rómönsku Ameríka)",
|
||||||
|
"Sundanese": "Sundaneska",
|
||||||
|
"Swahili": "Svahílí",
|
||||||
|
"Swedish": "Sænska",
|
||||||
|
"Tajik": "Tadsikíska",
|
||||||
|
"Tamil": "Tamílska",
|
||||||
|
"Telugu": "Telúgú",
|
||||||
|
"Thai": "Taílenska",
|
||||||
|
"Turkish": "Tyrkneska",
|
||||||
|
"Ukrainian": "Úkraníska",
|
||||||
|
"Urdu": "Úrdú",
|
||||||
|
"Uzbek": "Úsbekíska",
|
||||||
|
"Vietnamese": "Víetnamska",
|
||||||
|
"Welsh": "Velska",
|
||||||
|
"Western Frisian": "Vestur Frísneska",
|
||||||
|
"Xhosa": "Xhosa",
|
||||||
|
"Yiddish": "Jiddíska",
|
||||||
|
"Yoruba": "Jórúba",
|
||||||
|
"Zulu": "Zúlú",
|
||||||
|
"`x` years.": "' x ' ár.",
|
||||||
|
"`x` months.": "' x ' mánuði.",
|
||||||
|
"`x` weeks.": "`x` vikur.",
|
||||||
|
"`x` days.": "' x ' dagar.",
|
||||||
|
"`x` hours.": "`x` klukkustundir.",
|
||||||
|
"`x` minutes.": "`x` mínútur.",
|
||||||
|
"`x` seconds.": "`x` sekúndur.",
|
||||||
|
"Fallback comments: ": "Vara ummæli: ",
|
||||||
|
"Popular": "Vinsællt",
|
||||||
|
"Top": "Topp",
|
||||||
|
"About": "Um",
|
||||||
|
"Rating: ": "Einkunn: ",
|
||||||
|
"Language: ": "Tungumál: ",
|
||||||
|
"View as playlist": "Skoða sem spilunarlista",
|
||||||
|
"Default": "Sjálfgefið",
|
||||||
|
"Music": "Tónlist",
|
||||||
|
"Gaming": "Tólvuleikja",
|
||||||
|
"News": "Fréttir",
|
||||||
|
"Movies": "Kvikmyndir",
|
||||||
|
"Download": "Niðurhal",
|
||||||
|
"Download as: ": "Niðurhala sem: ",
|
||||||
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
|
"(edited)": "(breytt)",
|
||||||
|
"YouTube comment permalink": "YouTube ummæli varanlegur tengill",
|
||||||
|
"`x` marked it with a ❤": "`x` merkti það með ❤",
|
||||||
|
"Audio mode": "Hljóð ham",
|
||||||
|
"Video mode": "Myndband ham",
|
||||||
|
"Videos": "Myndbönd",
|
||||||
|
"Playlists": "Spilunarlistar",
|
||||||
|
"Current version: ": "Núverandi útgáfa: "
|
||||||
|
}
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Riproduzione successiva per impostazione predefinita: ",
|
"Play next by default: ": "Riproduzione successiva per impostazione predefinita: ",
|
||||||
"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? ": "",
|
"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: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "",
|
"reddit": "",
|
||||||
"Default captions: ": "Sottotitoli predefiniti: ",
|
"Default captions: ": "Sottotitoli predefiniti: ",
|
||||||
"Fallback captions: ": "Sottotitoli alternativi: ",
|
"Fallback captions: ": "Sottotitoli alternativi: ",
|
||||||
"Show related videos? ": "Mostra video correlati? ",
|
"Show related videos: ": "Mostra video correlati? ",
|
||||||
"Show annotations by default? ": "Mostra le annotazioni per impostazione predefinita? ",
|
"Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ",
|
||||||
"Visual preferences": "Preferenze grafiche",
|
"Visual preferences": "Preferenze grafiche",
|
||||||
"Dark mode: ": "Tema scuro: ",
|
"Dark mode: ": "Tema scuro: ",
|
||||||
"Thin mode: ": "Modalità per connessioni lente: ",
|
"Thin mode: ": "Modalità per connessioni lente: ",
|
||||||
"Subscription preferences": "Preferenze iscrizioni",
|
"Subscription preferences": "Preferenze iscrizioni",
|
||||||
"Show annotations by default for subscribed channels? ": "",
|
"Show annotations by default for subscribed channels: ": "",
|
||||||
"Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ",
|
"Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ",
|
||||||
"Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ",
|
"Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ",
|
||||||
"Sort videos by: ": "Ordinare i video per: ",
|
"Sort videos by: ": "Ordinare i video per: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ",
|
"Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ",
|
||||||
"Only show unwatched: ": "Mostra solo i video non guardati: ",
|
"Only show unwatched: ": "Mostra solo i video non guardati: ",
|
||||||
"Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ",
|
"Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "Preferenze dati",
|
"Data preferences": "Preferenze dati",
|
||||||
"Clear watch history": "Cancella la cronologia dei video guardati",
|
"Clear watch history": "Cancella la cronologia dei video guardati",
|
||||||
"Import/export data": "Importazione/esportazione dati",
|
"Import/export data": "Importazione/esportazione dati",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "",
|
"Administrator preferences": "",
|
||||||
"Default homepage: ": "",
|
"Default homepage: ": "",
|
||||||
"Feed menu: ": "",
|
"Feed menu: ": "",
|
||||||
"Top enabled? ": "",
|
"Top enabled: ": "",
|
||||||
"CAPTCHA enabled? ": "",
|
"CAPTCHA enabled: ": "",
|
||||||
"Login enabled? ": "",
|
"Login enabled: ": "",
|
||||||
"Registration enabled? ": "",
|
"Registration enabled: ": "",
|
||||||
"Report statistics? ": "",
|
"Report statistics: ": "",
|
||||||
"Save preferences": "Salva le preferenze",
|
"Save preferences": "Salva le preferenze",
|
||||||
"Subscription manager": "Gestisci le iscrizioni",
|
"Subscription manager": "Gestisci le iscrizioni",
|
||||||
"Token manager": "",
|
"Token manager": "",
|
||||||
@ -306,10 +309,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||||
"(edited)": "(modificato)",
|
"(edited)": "(modificato)",
|
||||||
"YouTube comment permalink": "Link permanente al commento di YouTube",
|
"YouTube comment permalink": "Link permanente al commento di YouTube",
|
||||||
|
"permalink": "",
|
||||||
"`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": "",
|
"Videos": "",
|
||||||
"Playlists": "",
|
"Playlists": "",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": ""
|
"Current version: ": ""
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "Opphev abonnement",
|
"Unsubscribe": "Opphev abonnement",
|
||||||
"Subscribe": "Abonner",
|
"Subscribe": "Abonner",
|
||||||
"View channel on YouTube": "Vis kanal på YouTube",
|
"View channel on YouTube": "Vis kanal på YouTube",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "Vis spilleliste på YouTube",
|
||||||
"newest": "nyeste",
|
"newest": "nyeste",
|
||||||
"oldest": "eldste",
|
"oldest": "eldste",
|
||||||
"popular": "populært",
|
"popular": "populært",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Spill neste som forvalg: ",
|
"Play next by default: ": "Spill neste som forvalg: ",
|
||||||
"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? ": "Mellomtjen videoer? ",
|
"Proxy videos: ": "Mellomtjen videoer? ",
|
||||||
"Default speed: ": "Forvalgt hastighet: ",
|
"Default speed: ": "Forvalgt hastighet: ",
|
||||||
"Preferred video quality: ": "Foretrukket videokvalitet: ",
|
"Preferred video quality: ": "Foretrukket videokvalitet: ",
|
||||||
"Player volume: ": "Avspillerlydstyrke: ",
|
"Player volume: ": "Avspillerlydstyrke: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Forvalgte undertitler: ",
|
"Default captions: ": "Forvalgte undertitler: ",
|
||||||
"Fallback captions: ": "Tilbakefallsundertitler: ",
|
"Fallback captions: ": "Tilbakefallsundertitler: ",
|
||||||
"Show related videos? ": "Vis relaterte videoer? ",
|
"Show related videos: ": "Vis relaterte videoer? ",
|
||||||
"Show annotations by default? ": "Vis merknader som forvalg? ",
|
"Show annotations by default: ": "Vis merknader som forvalg? ",
|
||||||
"Visual preferences": "Visuelle innstillinger",
|
"Visual preferences": "Visuelle innstillinger",
|
||||||
"Dark mode: ": "Mørk drakt: ",
|
"Dark mode: ": "Mørk drakt: ",
|
||||||
"Thin mode: ": "Tynt modus: ",
|
"Thin mode: ": "Tynt modus: ",
|
||||||
"Subscription preferences": "Abonnementsinnstillinger",
|
"Subscription preferences": "Abonnementsinnstillinger",
|
||||||
"Show annotations by default for subscribed channels? ": "Vis merknader som forvalg for kanaler det abonneres på? ",
|
"Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ",
|
||||||
"Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ",
|
"Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ",
|
||||||
"Number of videos shown in feed: ": "Antall videoer å vise i flyt: ",
|
"Number of videos shown in feed: ": "Antall videoer å vise i flyt: ",
|
||||||
"Sort videos by: ": "Sorter videoer etter: ",
|
"Sort videos by: ": "Sorter videoer etter: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
|
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
|
||||||
"Only show unwatched: ": "Kun vis usette: ",
|
"Only show unwatched: ": "Kun vis usette: ",
|
||||||
"Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
|
"Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
|
||||||
|
"Enable web notifications": "Skru på nettmerknader",
|
||||||
|
"`x` uploaded a video": "`x` lastet opp en video",
|
||||||
|
"`x` is live": "`x` er pålogget",
|
||||||
"Data preferences": "Datainnstillinger",
|
"Data preferences": "Datainnstillinger",
|
||||||
"Clear watch history": "Tøm visningshistorikk",
|
"Clear watch history": "Tøm visningshistorikk",
|
||||||
"Import/export data": "Importer/eksporter data",
|
"Import/export data": "Importer/eksporter data",
|
||||||
@ -96,11 +99,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? ": "Topp påskrudd? ",
|
"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? ": "Innrapporter statistikk? ",
|
"Report statistics: ": "Innrapporter statistikk? ",
|
||||||
"Save preferences": "Lagre innstillinger",
|
"Save preferences": "Lagre innstillinger",
|
||||||
"Subscription manager": "Abonnementsbehandler",
|
"Subscription manager": "Abonnementsbehandler",
|
||||||
"Token manager": "Symbolbehandler",
|
"Token manager": "Symbolbehandler",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Delt `x`",
|
"Shared `x`": "Delt `x`",
|
||||||
"`x` views": "`x` visninger",
|
"`x` views": "`x` visninger",
|
||||||
"Premieres in `x`": "Premiere om `x`",
|
"Premieres in `x`": "Premiere om `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.",
|
||||||
"View YouTube comments": "Vis YouTube-kommentarer",
|
"View YouTube comments": "Vis YouTube-kommentarer",
|
||||||
"View more comments on Reddit": "Vis flere kommenterer på Reddit",
|
"View more comments on Reddit": "Vis flere kommenterer på Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "(redigert)",
|
"(edited)": "(redigert)",
|
||||||
"YouTube comment permalink": "Permanent YouTube-lenke til innholdet",
|
"YouTube comment permalink": "Permanent YouTube-lenke til innholdet",
|
||||||
|
"permalink": "permanent lenke",
|
||||||
"`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",
|
"Videos": "Videoer",
|
||||||
"Playlists": "Spillelister",
|
"Playlists": "Spillelister",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Nåværende versjon: "
|
"Current version: ": "Nåværende versjon: "
|
||||||
}
|
}
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Standaard volgende video afspelen: ",
|
"Play next by default: ": "Standaard volgende video afspelen: ",
|
||||||
"Autoplay next video: ": "Volgende video automatisch afspelen: ",
|
"Autoplay next video: ": "Volgende video automatisch afspelen: ",
|
||||||
"Listen by default: ": "Standaard luisteren: ",
|
"Listen by default: ": "Standaard luisteren: ",
|
||||||
"Proxy videos? ": "Video's afspelen via proxy? ",
|
"Proxy videos: ": "Video's afspelen via proxy? ",
|
||||||
"Default speed: ": "Standaard afspeelsnelheid: ",
|
"Default speed: ": "Standaard afspeelsnelheid: ",
|
||||||
"Preferred video quality: ": "Voorkeurskwaliteit: ",
|
"Preferred video quality: ": "Voorkeurskwaliteit: ",
|
||||||
"Player volume: ": "Spelervolume: ",
|
"Player volume: ": "Spelervolume: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Standaard ondertiteling: ",
|
"Default captions: ": "Standaard ondertiteling: ",
|
||||||
"Fallback captions: ": "Alternatieve ondertiteling: ",
|
"Fallback captions: ": "Alternatieve ondertiteling: ",
|
||||||
"Show related videos? ": "Gerelateerde video's tonen? ",
|
"Show related videos: ": "Gerelateerde video's tonen? ",
|
||||||
"Show annotations by default? ": "Standaard annotaties tonen? ",
|
"Show annotations by default: ": "Standaard annotaties tonen? ",
|
||||||
"Visual preferences": "Visuele instellingen",
|
"Visual preferences": "Visuele instellingen",
|
||||||
"Dark mode: ": "Donkere modus: ",
|
"Dark mode: ": "Donkere modus: ",
|
||||||
"Thin mode: ": "Smalle modus: ",
|
"Thin mode: ": "Smalle modus: ",
|
||||||
"Subscription preferences": "Abonnementsinstellingen",
|
"Subscription preferences": "Abonnementsinstellingen",
|
||||||
"Show annotations by default for subscribed channels? ": "Standaard annotaties tonen voor geabonneerde kanalen? ",
|
"Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ",
|
||||||
"Redirect homepage to feed: ": "Startpagina omleiden naar feed: ",
|
"Redirect homepage to feed: ": "Startpagina omleiden naar feed: ",
|
||||||
"Number of videos shown in feed: ": "Aantal te tonen video's in feed: ",
|
"Number of videos shown in feed: ": "Aantal te tonen video's in feed: ",
|
||||||
"Sort videos by: ": "Video's sorteren op: ",
|
"Sort videos by: ": "Video's sorteren op: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
|
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
|
||||||
"Only show unwatched: ": "Alleen niet-bekeken videos tonen: ",
|
"Only show unwatched: ": "Alleen niet-bekeken videos tonen: ",
|
||||||
"Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ",
|
"Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ",
|
||||||
|
"Enable web notifications": "Systemmeldingen inschakelen",
|
||||||
|
"`x` uploaded a video": "`x` heeft een video geüpload",
|
||||||
|
"`x` is live": "`x` zendt nu live uit",
|
||||||
"Data preferences": "Gegevensinstellingen",
|
"Data preferences": "Gegevensinstellingen",
|
||||||
"Clear watch history": "Kijkgeschiedenis wissen",
|
"Clear watch history": "Kijkgeschiedenis wissen",
|
||||||
"Import/export data": "Gegevens im-/exporteren",
|
"Import/export data": "Gegevens im-/exporteren",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Beheerdersinstellingen",
|
"Administrator preferences": "Beheerdersinstellingen",
|
||||||
"Default homepage: ": "Standaard startpagina: ",
|
"Default homepage: ": "Standaard startpagina: ",
|
||||||
"Feed menu: ": "Feedmenu:",
|
"Feed menu: ": "Feedmenu:",
|
||||||
"Top enabled? ": "Bovenkant inschakelen? ",
|
"Top enabled: ": "Bovenkant inschakelen? ",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA gebruiken? ",
|
"CAPTCHA enabled: ": "CAPTCHA gebruiken? ",
|
||||||
"Login enabled? ": "Inloggen toestaan? ",
|
"Login enabled: ": "Inloggen toestaan? ",
|
||||||
"Registration enabled? ": "Registratie toestaan? ",
|
"Registration enabled: ": "Registratie toestaan? ",
|
||||||
"Report statistics? ": "Statistieken bijhouden? ",
|
"Report statistics: ": "Statistieken bijhouden? ",
|
||||||
"Save preferences": "Instellingen opslaan",
|
"Save preferences": "Instellingen opslaan",
|
||||||
"Subscription manager": "Abonnementen beheren",
|
"Subscription manager": "Abonnementen beheren",
|
||||||
"Token manager": "Toegangssleutels beheren",
|
"Token manager": "Toegangssleutels beheren",
|
||||||
@ -114,13 +117,13 @@
|
|||||||
"`x` unseen notifications": "`x` ongelezen meldingen",
|
"`x` unseen notifications": "`x` ongelezen meldingen",
|
||||||
"search": "zoeken",
|
"search": "zoeken",
|
||||||
"Log out": "Uitloggen",
|
"Log out": "Uitloggen",
|
||||||
"Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder de AGPLv3-licentie door Omar Roth.",
|
"Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.",
|
||||||
"Source available here.": "De broncode is hier beschikbaar.",
|
"Source available here.": "De broncode is hier beschikbaar.",
|
||||||
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
|
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
|
||||||
"View privacy policy.": "Privacybeleid tonen",
|
"View privacy policy.": "Privacybeleid tonen",
|
||||||
"Trending": "Uitgelicht",
|
"Trending": "Uitgelicht",
|
||||||
"Unlisted": "Verborgen",
|
"Unlisted": "Verborgen",
|
||||||
"Watch on YouTube": "Bekijk video op YouTube",
|
"Watch on YouTube": "Video bekijken op YouTube",
|
||||||
"Hide annotations": "Annotaties verbergen",
|
"Hide annotations": "Annotaties verbergen",
|
||||||
"Show annotations": "Annotaties tonen",
|
"Show annotations": "Annotaties tonen",
|
||||||
"Genre: ": "Genre: ",
|
"Genre: ": "Genre: ",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "`x` gedeeld",
|
"Shared `x`": "`x` gedeeld",
|
||||||
"`x` views": "`x` weergaven",
|
"`x` views": "`x` weergaven",
|
||||||
"Premieres in `x`": "Verschijnt over `x`",
|
"Premieres in `x`": "Verschijnt over `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.",
|
||||||
"View YouTube comments": "YouTube-reacties tonen",
|
"View YouTube comments": "YouTube-reacties tonen",
|
||||||
"View more comments on Reddit": "Meer reacties bekijken op Reddit",
|
"View more comments on Reddit": "Meer reacties bekijken op Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||||
"(edited)": "(bewerkt)",
|
"(edited)": "(bewerkt)",
|
||||||
"YouTube comment permalink": "Link naar YouTube-reactie",
|
"YouTube comment permalink": "Link naar YouTube-reactie",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
|
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
|
||||||
"Audio mode": "Audiomodus",
|
"Audio mode": "Audiomodus",
|
||||||
"Video mode": "Videomodus",
|
"Video mode": "Videomodus",
|
||||||
"Videos": "Video's",
|
"Videos": "Video's",
|
||||||
"Playlists": "Afspeellijsten",
|
"Playlists": "Afspeellijsten",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Huidige versie: "
|
"Current version: ": "Huidige versie: "
|
||||||
}
|
}
|
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "",
|
"Play next by default: ": "",
|
||||||
"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? ": "Filmy przez proxy? ",
|
"Proxy videos: ": "Filmy przez proxy? ",
|
||||||
"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: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "",
|
"reddit": "",
|
||||||
"Default captions: ": "Domyślne napisy: ",
|
"Default captions: ": "Domyślne napisy: ",
|
||||||
"Fallback captions: ": "Zastępcze napisy: ",
|
"Fallback captions: ": "Zastępcze napisy: ",
|
||||||
"Show related videos? ": "Pokaż powiązane filmy? ",
|
"Show related videos: ": "Pokaż powiązane filmy? ",
|
||||||
"Show annotations by default? ": "",
|
"Show annotations by default: ": "",
|
||||||
"Visual preferences": "Preferencje Wizualne",
|
"Visual preferences": "Preferencje Wizualne",
|
||||||
"Dark mode: ": "Ciemny motyw: ",
|
"Dark mode: ": "Ciemny motyw: ",
|
||||||
"Thin mode: ": "Tryb minimalny: ",
|
"Thin mode: ": "Tryb minimalny: ",
|
||||||
"Subscription preferences": "Preferencje subskrybcji",
|
"Subscription preferences": "Preferencje subskrybcji",
|
||||||
"Show annotations by default for subscribed channels? ": "",
|
"Show annotations by default for subscribed channels: ": "",
|
||||||
"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: ",
|
"Sort videos by: ": "Sortuj filmy: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"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: ",
|
||||||
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
|
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
|
||||||
|
"Enable web notifications": "",
|
||||||
|
"`x` uploaded a video": "",
|
||||||
|
"`x` is live": "",
|
||||||
"Data preferences": "Preferencje danych",
|
"Data preferences": "Preferencje danych",
|
||||||
"Clear watch history": "Wyczyść historię",
|
"Clear watch history": "Wyczyść historię",
|
||||||
"Import/export data": "Import/Eksport danych",
|
"Import/export data": "Import/Eksport danych",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Preferencje administratora",
|
"Administrator preferences": "Preferencje administratora",
|
||||||
"Default homepage: ": "Domyślna strona główna: ",
|
"Default homepage: ": "Domyślna strona główna: ",
|
||||||
"Feed menu: ": "",
|
"Feed menu: ": "",
|
||||||
"Top enabled? ": "",
|
"Top enabled: ": "",
|
||||||
"CAPTCHA enabled? ": "CAPTCHA aktywna? ",
|
"CAPTCHA enabled: ": "CAPTCHA aktywna? ",
|
||||||
"Login enabled? ": "Logowanie włączone? ",
|
"Login enabled: ": "Logowanie włączone? ",
|
||||||
"Registration enabled? ": "Rejestracja włączona? ",
|
"Registration enabled: ": "Rejestracja włączona? ",
|
||||||
"Report statistics? ": "Raportować statystyki? ",
|
"Report statistics: ": "Raportować statystyki? ",
|
||||||
"Save preferences": "Zapisz preferencje",
|
"Save preferences": "Zapisz preferencje",
|
||||||
"Subscription manager": "Manager subskrybcji",
|
"Subscription manager": "Manager subskrybcji",
|
||||||
"Token manager": "",
|
"Token manager": "",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Udostępniono `x`",
|
"Shared `x`": "Udostępniono `x`",
|
||||||
"`x` views": "`x` wyświetleń",
|
"`x` views": "`x` wyświetleń",
|
||||||
"Premieres in `x`": "Publikacja za `x`",
|
"Premieres in `x`": "Publikacja za `x`",
|
||||||
|
"Premieres `x`": "",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.",
|
||||||
"View YouTube comments": "Wyświetl komentarze z YouTube",
|
"View YouTube comments": "Wyświetl komentarze z YouTube",
|
||||||
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
|
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "",
|
"%A %B %-d, %Y": "",
|
||||||
"(edited)": "(edytowany)",
|
"(edited)": "(edytowany)",
|
||||||
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
|
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "`x` oznaczonych ❤",
|
"`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",
|
"Videos": "Filmy",
|
||||||
"Playlists": "Playlisty",
|
"Playlists": "Playlisty",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Aktualna wersja: "
|
"Current version: ": "Aktualna wersja: "
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "Отписаться",
|
"Unsubscribe": "Отписаться",
|
||||||
"Subscribe": "Подписаться",
|
"Subscribe": "Подписаться",
|
||||||
"View channel on YouTube": "Смотреть канал на YouTube",
|
"View channel on YouTube": "Смотреть канал на YouTube",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "Посмотреть плейлист на YouTube",
|
||||||
"newest": "самые свежие",
|
"newest": "самые свежие",
|
||||||
"oldest": "самые старые",
|
"oldest": "самые старые",
|
||||||
"popular": "популярные",
|
"popular": "популярные",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Всегда включать следующее видео? ",
|
"Play next by default: ": "Всегда включать следующее видео? ",
|
||||||
"Autoplay next video: ": "Автопроигрывание следующего видео: ",
|
"Autoplay next video: ": "Автопроигрывание следующего видео: ",
|
||||||
"Listen by default: ": "Режим «только аудио» по умолчанию: ",
|
"Listen by default: ": "Режим «только аудио» по умолчанию: ",
|
||||||
"Proxy videos? ": "Проигрывать видео через прокси? ",
|
"Proxy videos: ": "Проигрывать видео через прокси? ",
|
||||||
"Default speed: ": "Скорость видео по умолчанию: ",
|
"Default speed: ": "Скорость видео по умолчанию: ",
|
||||||
"Preferred video quality: ": "Предпочтительное качество видео: ",
|
"Preferred video quality: ": "Предпочтительное качество видео: ",
|
||||||
"Player volume: ": "Громкость видео: ",
|
"Player volume: ": "Громкость видео: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Основной язык субтитров: ",
|
"Default captions: ": "Основной язык субтитров: ",
|
||||||
"Fallback captions: ": "Дополнительный язык субтитров: ",
|
"Fallback captions: ": "Дополнительный язык субтитров: ",
|
||||||
"Show related videos? ": "Показывать похожие видео? ",
|
"Show related videos: ": "Показывать похожие видео? ",
|
||||||
"Show annotations by default? ": "Всегда показывать аннотации? ",
|
"Show annotations by default: ": "Всегда показывать аннотации? ",
|
||||||
"Visual preferences": "Настройки сайта",
|
"Visual preferences": "Настройки сайта",
|
||||||
"Dark mode: ": "Тёмное оформление: ",
|
"Dark mode: ": "Тёмное оформление: ",
|
||||||
"Thin mode: ": "Облегчённое оформление: ",
|
"Thin mode: ": "Облегчённое оформление: ",
|
||||||
"Subscription preferences": "Настройки подписок",
|
"Subscription preferences": "Настройки подписок",
|
||||||
"Show annotations by default for subscribed channels? ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
|
"Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
|
||||||
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
|
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
|
||||||
"Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ",
|
"Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ",
|
||||||
"Sort videos by: ": "Сортировать видео: ",
|
"Sort videos by: ": "Сортировать видео: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ",
|
"Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ",
|
||||||
"Only show unwatched: ": "Показывать только непросмотренные видео: ",
|
"Only show unwatched: ": "Показывать только непросмотренные видео: ",
|
||||||
"Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ",
|
"Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ",
|
||||||
|
"Enable web notifications": "Включить уведомления в браузере",
|
||||||
|
"`x` uploaded a video": "`x` разместил видео",
|
||||||
|
"`x` is live": "`x` в прямом эфире",
|
||||||
"Data preferences": "Настройки данных",
|
"Data preferences": "Настройки данных",
|
||||||
"Clear watch history": "Очистить историю просмотров",
|
"Clear watch history": "Очистить историю просмотров",
|
||||||
"Import/export data": "Импорт/Экспорт данных",
|
"Import/export data": "Импорт/Экспорт данных",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Администраторские настройки",
|
"Administrator preferences": "Администраторские настройки",
|
||||||
"Default homepage: ": "Главная страница по умолчанию: ",
|
"Default homepage: ": "Главная страница по умолчанию: ",
|
||||||
"Feed menu: ": "Меню ленты видео: ",
|
"Feed menu: ": "Меню ленты видео: ",
|
||||||
"Top enabled? ": "Включить топ видео? ",
|
"Top enabled: ": "Включить топ видео? ",
|
||||||
"CAPTCHA enabled? ": "Включить капчу? ",
|
"CAPTCHA enabled: ": "Включить капчу? ",
|
||||||
"Login enabled? ": "Включить авторизацию? ",
|
"Login enabled: ": "Включить авторизацию? ",
|
||||||
"Registration enabled? ": "Включить регистрацию? ",
|
"Registration enabled: ": "Включить регистрацию? ",
|
||||||
"Report statistics? ": "Сообщать статистику? ",
|
"Report statistics: ": "Сообщать статистику? ",
|
||||||
"Save preferences": "Сохранить настройки",
|
"Save preferences": "Сохранить настройки",
|
||||||
"Subscription manager": "Менеджер подписок",
|
"Subscription manager": "Менеджер подписок",
|
||||||
"Token manager": "Менеджер токенов",
|
"Token manager": "Менеджер токенов",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Опубликовано `x`",
|
"Shared `x`": "Опубликовано `x`",
|
||||||
"`x` views": "`x` просмотров",
|
"`x` views": "`x` просмотров",
|
||||||
"Premieres in `x`": "Премьера через `x`",
|
"Premieres in `x`": "Премьера через `x`",
|
||||||
|
"Premieres `x`": "Премьера `x`",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.",
|
||||||
"View YouTube comments": "Смотреть комментарии с YouTube",
|
"View YouTube comments": "Смотреть комментарии с YouTube",
|
||||||
"View more comments on Reddit": "Посмотреть больше комментариев на Reddit",
|
"View more comments on Reddit": "Посмотреть больше комментариев на Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||||
"(edited)": "(изменено)",
|
"(edited)": "(изменено)",
|
||||||
"YouTube comment permalink": "Прямая ссылка на YouTube",
|
"YouTube comment permalink": "Прямая ссылка на YouTube",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
|
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
|
||||||
"Audio mode": "Аудио режим",
|
"Audio mode": "Аудио режим",
|
||||||
"Video mode": "Видео режим",
|
"Video mode": "Видео режим",
|
||||||
"Videos": "Видео",
|
"Videos": "Видео",
|
||||||
"Playlists": "Плейлисты",
|
"Playlists": "Плейлисты",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Текущая версия: "
|
"Current version: ": "Текущая версия: "
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
"Unsubscribe": "Відписатися",
|
"Unsubscribe": "Відписатися",
|
||||||
"Subscribe": "Підписатися",
|
"Subscribe": "Підписатися",
|
||||||
"View channel on YouTube": "Подивитися канал на YouTube",
|
"View channel on YouTube": "Подивитися канал на YouTube",
|
||||||
"View playlist on YouTube": "",
|
"View playlist on YouTube": "Подивитися плейлист на YouTube",
|
||||||
"newest": "найновіше",
|
"newest": "найновіше",
|
||||||
"oldest": "найстаріше",
|
"oldest": "найстаріше",
|
||||||
"popular": "популярне",
|
"popular": "популярне",
|
||||||
@ -56,7 +56,7 @@
|
|||||||
"Play next by default: ": "Завжди вмикати наступне відео: ",
|
"Play next by default: ": "Завжди вмикати наступне відео: ",
|
||||||
"Autoplay next video: ": "Автовідтворення наступного відео: ",
|
"Autoplay next video: ": "Автовідтворення наступного відео: ",
|
||||||
"Listen by default: ": "Режим «тільки звук» як усталений: ",
|
"Listen by default: ": "Режим «тільки звук» як усталений: ",
|
||||||
"Proxy videos? ": "Програвати відео через проксі? ",
|
"Proxy videos: ": "Програвати відео через проксі? ",
|
||||||
"Default speed: ": "Усталена швидкість відео: ",
|
"Default speed: ": "Усталена швидкість відео: ",
|
||||||
"Preferred video quality: ": "Пріорітетна якість відео: ",
|
"Preferred video quality: ": "Пріорітетна якість відео: ",
|
||||||
"Player volume: ": "Гучність відео: ",
|
"Player volume: ": "Гучність відео: ",
|
||||||
@ -65,13 +65,13 @@
|
|||||||
"reddit": "Reddit",
|
"reddit": "Reddit",
|
||||||
"Default captions: ": "Основна мова субтитрів: ",
|
"Default captions: ": "Основна мова субтитрів: ",
|
||||||
"Fallback captions: ": "Запасна мова субтитрів: ",
|
"Fallback captions: ": "Запасна мова субтитрів: ",
|
||||||
"Show related videos? ": "Показувати схожі відео? ",
|
"Show related videos: ": "Показувати схожі відео? ",
|
||||||
"Show annotations by default? ": "Завжди показувати анотації? ",
|
"Show annotations by default: ": "Завжди показувати анотації? ",
|
||||||
"Visual preferences": "Налаштування сайту",
|
"Visual preferences": "Налаштування сайту",
|
||||||
"Dark mode: ": "Темне оформлення: ",
|
"Dark mode: ": "Темне оформлення: ",
|
||||||
"Thin mode: ": "Полегшене оформлення: ",
|
"Thin mode: ": "Полегшене оформлення: ",
|
||||||
"Subscription preferences": "Налаштування підписок",
|
"Subscription preferences": "Налаштування підписок",
|
||||||
"Show annotations by default for subscribed channels? ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
|
"Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
|
||||||
"Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ",
|
"Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ",
|
||||||
"Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ",
|
"Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ",
|
||||||
"Sort videos by: ": "Сортувати відео: ",
|
"Sort videos by: ": "Сортувати відео: ",
|
||||||
@ -85,6 +85,9 @@
|
|||||||
"Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ",
|
"Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ",
|
||||||
"Only show unwatched: ": "Показувати тільки непереглянуті відео: ",
|
"Only show unwatched: ": "Показувати тільки непереглянуті відео: ",
|
||||||
"Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ",
|
"Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ",
|
||||||
|
"Enable web notifications": "Ввімкнути сповіщення в браузері",
|
||||||
|
"`x` uploaded a video": "`x` розмістив відео",
|
||||||
|
"`x` is live": "`x` у прямому ефірі",
|
||||||
"Data preferences": "Налаштування даних",
|
"Data preferences": "Налаштування даних",
|
||||||
"Clear watch history": "Очистити історію переглядів",
|
"Clear watch history": "Очистити історію переглядів",
|
||||||
"Import/export data": "Імпорт і експорт даних",
|
"Import/export data": "Імпорт і експорт даних",
|
||||||
@ -96,11 +99,11 @@
|
|||||||
"Administrator preferences": "Адміністраторські налаштування",
|
"Administrator preferences": "Адміністраторські налаштування",
|
||||||
"Default homepage: ": "Усталена домашня сторінка: ",
|
"Default homepage: ": "Усталена домашня сторінка: ",
|
||||||
"Feed menu: ": "Меню потоку з відео: ",
|
"Feed menu: ": "Меню потоку з відео: ",
|
||||||
"Top enabled? ": "Увімкнути топ відео? ",
|
"Top enabled: ": "Увімкнути топ відео? ",
|
||||||
"CAPTCHA enabled? ": "Увімкнути капчу? ",
|
"CAPTCHA enabled: ": "Увімкнути капчу? ",
|
||||||
"Login enabled? ": "Увімкнути авторизацію? ",
|
"Login enabled: ": "Увімкнути авторизацію? ",
|
||||||
"Registration enabled? ": "Увімкнути реєстрацію? ",
|
"Registration enabled: ": "Увімкнути реєстрацію? ",
|
||||||
"Report statistics? ": "Повідомляти статистику? ",
|
"Report statistics: ": "Повідомляти статистику? ",
|
||||||
"Save preferences": "Зберегти налаштування",
|
"Save preferences": "Зберегти налаштування",
|
||||||
"Subscription manager": "Менеджер підписок",
|
"Subscription manager": "Менеджер підписок",
|
||||||
"Token manager": "Менеджер токенів",
|
"Token manager": "Менеджер токенів",
|
||||||
@ -133,6 +136,7 @@
|
|||||||
"Shared `x`": "Розміщено `x`",
|
"Shared `x`": "Розміщено `x`",
|
||||||
"`x` views": "`x` переглядів",
|
"`x` views": "`x` переглядів",
|
||||||
"Premieres in `x`": "Прем’єра через `x`",
|
"Premieres in `x`": "Прем’єра через `x`",
|
||||||
|
"Premieres `x`": "Прем’єра `x`",
|
||||||
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.",
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.",
|
||||||
"View YouTube comments": "Переглянути коментарі з YouTube",
|
"View YouTube comments": "Переглянути коментарі з YouTube",
|
||||||
"View more comments on Reddit": "Переглянути більше коментарів на Reddit",
|
"View more comments on Reddit": "Переглянути більше коментарів на Reddit",
|
||||||
@ -306,10 +310,12 @@
|
|||||||
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||||
"(edited)": "(змінено)",
|
"(edited)": "(змінено)",
|
||||||
"YouTube comment permalink": "Пряме посилання на коментар в YouTube",
|
"YouTube comment permalink": "Пряме посилання на коментар в YouTube",
|
||||||
|
"permalink": "",
|
||||||
"`x` marked it with a ❤": "❤ цьому від каналу `x`",
|
"`x` marked it with a ❤": "❤ цьому від каналу `x`",
|
||||||
"Audio mode": "Аудіорежим",
|
"Audio mode": "Аудіорежим",
|
||||||
"Video mode": "Відеорежим",
|
"Video mode": "Відеорежим",
|
||||||
"Videos": "Відео",
|
"Videos": "Відео",
|
||||||
"Playlists": "Плейлисти",
|
"Playlists": "Плейлисти",
|
||||||
|
"Community": "",
|
||||||
"Current version: ": "Поточна версія: "
|
"Current version: ": "Поточна версія: "
|
||||||
}
|
}
|
321
locales/zh-CN.json
Normal file
321
locales/zh-CN.json
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
{
|
||||||
|
"`x` subscribers": "`x` 订阅者",
|
||||||
|
"`x` videos": "`x` 视频",
|
||||||
|
"LIVE": "直播",
|
||||||
|
"Shared `x` ago": "`x` 前分享",
|
||||||
|
"Unsubscribe": "取消订阅",
|
||||||
|
"Subscribe": "订阅",
|
||||||
|
"View channel on YouTube": "在 YouTube 查看频道",
|
||||||
|
"View playlist on YouTube": "在 YouTube 查看播放列表",
|
||||||
|
"newest": "最新",
|
||||||
|
"oldest": "最老",
|
||||||
|
"popular": "时下流行",
|
||||||
|
"last": "last",
|
||||||
|
"Next page": "下一页",
|
||||||
|
"Previous page": "上一页",
|
||||||
|
"Clear watch history?": "清除观看历史?",
|
||||||
|
"New password": "新密码",
|
||||||
|
"New passwords must match": "新密码必须匹配",
|
||||||
|
"Cannot change password for Google accounts": "无法为 Google 账户更改密码",
|
||||||
|
"Authorize token?": "授权令牌?",
|
||||||
|
"Authorize token for `x`?": "`x` 的授权令牌?",
|
||||||
|
"Yes": "是",
|
||||||
|
"No": "否",
|
||||||
|
"Import and Export Data": "导入与导出数据",
|
||||||
|
"Import": "导入",
|
||||||
|
"Import Invidious data": "导入 Invidious 数据",
|
||||||
|
"Import YouTube subscriptions": "导入 YouTube 订阅",
|
||||||
|
"Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)",
|
||||||
|
"Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)",
|
||||||
|
"Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)",
|
||||||
|
"Export": "导出",
|
||||||
|
"Export subscriptions as OPML": "导出订阅到 OPML 格式",
|
||||||
|
"Export subscriptions as OPML (for NewPipe & FreeTube)": "导出订阅到 OPML 格式(用于 NewPipe 及 FreeTube)",
|
||||||
|
"Export data as JSON": "导出数据为 JSON 格式",
|
||||||
|
"Delete account?": "删除账户?",
|
||||||
|
"History": "历史",
|
||||||
|
"An alternative front-end to YouTube": "另一个 YouTube 前端",
|
||||||
|
"JavaScript license information": "JavaScript 授权信息",
|
||||||
|
"source": "source",
|
||||||
|
"Log in": "登录",
|
||||||
|
"Log in/register": "登录/注册",
|
||||||
|
"Log in with Google": "使用 Google 账户登录",
|
||||||
|
"User ID": "用户 ID",
|
||||||
|
"Password": "密码",
|
||||||
|
"Time (h:mm:ss):": "时间 (h:mm:ss):",
|
||||||
|
"Text CAPTCHA": "文本验证码",
|
||||||
|
"Image CAPTCHA": "图片验证码",
|
||||||
|
"Sign In": "登录",
|
||||||
|
"Register": "注册",
|
||||||
|
"E-mail": "E-mail",
|
||||||
|
"Google verification code": "Google 验证代码",
|
||||||
|
"Preferences": "偏好设置",
|
||||||
|
"Player preferences": "播放器偏好设置",
|
||||||
|
"Always loop: ": "循环:",
|
||||||
|
"Autoplay: ": "自动播放:",
|
||||||
|
"Play next by default: ": "默认自动播放下一个视频:",
|
||||||
|
"Autoplay next video: ": "自动播放下一个视频:",
|
||||||
|
"Listen by default: ": "默认只聆听声音:",
|
||||||
|
"Proxy videos: ": "代理视频?",
|
||||||
|
"Default speed: ": "默认速度:",
|
||||||
|
"Preferred video quality: ": "视频质量偏好:",
|
||||||
|
"Player volume: ": "播放器音量:",
|
||||||
|
"Default comments: ": "默认评论源:",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"reddit": "Reddit",
|
||||||
|
"Default captions: ": "默认字幕语言:",
|
||||||
|
"Fallback captions: ": "后备字幕语言:",
|
||||||
|
"Show related videos: ": "显示相关视频?",
|
||||||
|
"Show annotations by default: ": "默认显示视频注释?",
|
||||||
|
"Visual preferences": "视觉选项",
|
||||||
|
"Dark mode: ": "暗色模式:",
|
||||||
|
"Thin mode: ": "窄页模式:",
|
||||||
|
"Subscription preferences": "订阅设置",
|
||||||
|
"Show annotations by default for subscribed channels: ": "在订阅频道的视频默认显示注释?",
|
||||||
|
"Redirect homepage to feed: ": "跳转主页到 feed: ",
|
||||||
|
"Number of videos shown in feed: ": "Feed 中显示的视频数量:",
|
||||||
|
"Sort videos by: ": "视频排序方式:",
|
||||||
|
"published": "发布时间",
|
||||||
|
"published - reverse": "发布时间(反向)",
|
||||||
|
"alphabetically": "字母序",
|
||||||
|
"alphabetically - reverse": "字母序(反向)",
|
||||||
|
"channel name": "频道名称",
|
||||||
|
"channel name - reverse": "频道名称(反向)",
|
||||||
|
"Only show latest video from channel: ": "只显示订阅频道的最新一条视频:",
|
||||||
|
"Only show latest unwatched video from channel: ": "只显示订阅频道的最新未看过视频:",
|
||||||
|
"Only show unwatched: ": "只显示未看过的视频:",
|
||||||
|
"Only show notifications (if there are any): ": "只显示通知(如有):",
|
||||||
|
"Enable web notifications": "启用浏览器通知",
|
||||||
|
"`x` uploaded a video": "`x` 上传了视频",
|
||||||
|
"`x` is live": "`x` 正在直播",
|
||||||
|
"Data preferences": "数据选项",
|
||||||
|
"Clear watch history": "清除观看历史",
|
||||||
|
"Import/export data": "导入/导出数据",
|
||||||
|
"Change password": "更改密码",
|
||||||
|
"Manage subscriptions": "管理订阅",
|
||||||
|
"Manage tokens": "管理令牌",
|
||||||
|
"Watch history": "观看历史",
|
||||||
|
"Delete account": "删除账户",
|
||||||
|
"Administrator preferences": "管理员选项",
|
||||||
|
"Default homepage: ": "默认主页:",
|
||||||
|
"Feed menu: ": "Feed 菜单:",
|
||||||
|
"Top enabled: ": "启用“热门视频”页?",
|
||||||
|
"CAPTCHA enabled: ": "启用验证码?",
|
||||||
|
"Login enabled: ": "启用登录?",
|
||||||
|
"Registration enabled: ": "启用注册?",
|
||||||
|
"Report statistics: ": "报告统计信息?",
|
||||||
|
"Save preferences": "保存选项",
|
||||||
|
"Subscription manager": "订阅管理器",
|
||||||
|
"Token manager": "令牌管理器",
|
||||||
|
"Token": "令牌",
|
||||||
|
"`x` subscriptions": "`x` 个订阅",
|
||||||
|
"`x` tokens": "`x` 个令牌",
|
||||||
|
"Import/export": "导入/导出",
|
||||||
|
"unsubscribe": "取消订阅",
|
||||||
|
"revoke": "吊销",
|
||||||
|
"Subscriptions": "订阅",
|
||||||
|
"`x` unseen notifications": "`x` 条未读通知",
|
||||||
|
"search": "搜索",
|
||||||
|
"Log out": "登出",
|
||||||
|
"Released under the AGPLv3 by Omar Roth.": "由 Omar Roth 开发,以 AGPLv3 授权。",
|
||||||
|
"Source available here.": "源码可在此查看。",
|
||||||
|
"View JavaScript license information.": "查看 JavaScript 协议信息。",
|
||||||
|
"View privacy policy.": "查看隐私政策。",
|
||||||
|
"Trending": "时下流行",
|
||||||
|
"Unlisted": "不公开",
|
||||||
|
"Watch on YouTube": "在 YouTube 观看",
|
||||||
|
"Hide annotations": "隐藏注释",
|
||||||
|
"Show annotations": "显示注释",
|
||||||
|
"Genre: ": "风格:",
|
||||||
|
"License: ": "协议:",
|
||||||
|
"Family friendly? ": "家庭友好?",
|
||||||
|
"Wilson score: ": "威尔逊得分:",
|
||||||
|
"Engagement: ": "参与度:",
|
||||||
|
"Whitelisted regions: ": "白名单区域:",
|
||||||
|
"Blacklisted regions: ": "黑名单区域:",
|
||||||
|
"Shared `x`": "`x`发布",
|
||||||
|
"`x` views": "`x` 播放",
|
||||||
|
"Premieres in `x`": "首映于 `x` 后",
|
||||||
|
"Premieres `x`": "首映于 `x`",
|
||||||
|
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "你好!看起来你关闭了 JavaScript。点击这里阅读评论。注意它们加载的时间可能会稍长。",
|
||||||
|
"View YouTube comments": "查看 YouTube 评论",
|
||||||
|
"View more comments on Reddit": "在 Reddit 查看更多评论",
|
||||||
|
"View `x` comments": "查看 `x` 条评论",
|
||||||
|
"View Reddit comments": "查看 Reddit 评论",
|
||||||
|
"Hide replies": "隐藏回复",
|
||||||
|
"Show replies": "显示回复",
|
||||||
|
"Incorrect password": "密码错误",
|
||||||
|
"Quota exceeded, try again in a few hours": "已超出限额,请于几小时后重试",
|
||||||
|
"Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "无法登录。请确认你的短信或验证器的二步验证已打开。",
|
||||||
|
"Invalid TFA code": "无效的二步验证码",
|
||||||
|
"Login failed. This may be because two-factor authentication is not turned on for your account.": "登录失败。可能是因为二步验证未打开。",
|
||||||
|
"Wrong answer": "错误的回复",
|
||||||
|
"Erroneous CAPTCHA": "验证码错误",
|
||||||
|
"CAPTCHA is a required field": "验证码必填",
|
||||||
|
"User ID is a required field": "用户名必填",
|
||||||
|
"Password is a required field": "密码必填",
|
||||||
|
"Wrong username or password": "用户名或密码错误",
|
||||||
|
"Please sign in using 'Log in with Google'": "请通过谷歌账户登录",
|
||||||
|
"Password cannot be empty": "密码不能为空",
|
||||||
|
"Password cannot be longer than 55 characters": "密码长度不能大于 55",
|
||||||
|
"Please log in": "请登录",
|
||||||
|
"Invidious Private Feed for `x`": "`x` 的 Invidious 私人 feed",
|
||||||
|
"channel:`x`": "频道:`x`",
|
||||||
|
"Deleted or invalid channel": "已删除或无效频道",
|
||||||
|
"This channel does not exist.": "频道不存在。",
|
||||||
|
"Could not get channel info.": "无法获取频道信息。",
|
||||||
|
"Could not fetch comments": "无法获取评论",
|
||||||
|
"View `x` replies": "查看 `x` 条回复",
|
||||||
|
"`x` ago": "`x` 前",
|
||||||
|
"Load more": "加载更多",
|
||||||
|
"`x` points": "`x` 分",
|
||||||
|
"Could not create mix.": "无法创建合集。",
|
||||||
|
"Empty playlist": "空播放列表",
|
||||||
|
"Not a playlist.": "非播放列表。",
|
||||||
|
"Playlist does not exist.": "播放列表不存在。",
|
||||||
|
"Could not pull trending pages.": "无法获取“时下流行”页面。",
|
||||||
|
"Hidden field \"challenge\" is a required field": "隐藏表单项 \"challenge\" 为必填",
|
||||||
|
"Hidden field \"token\" is a required field": "隐藏表单项 \"token\" 为必填",
|
||||||
|
"Erroneous challenge": "错误的验证回复(challenge)",
|
||||||
|
"Erroneous token": "错误的令牌",
|
||||||
|
"No such user": "用户不存在",
|
||||||
|
"Token is expired, please try again": "令牌过期,请重试",
|
||||||
|
"English": "英语",
|
||||||
|
"English (auto-generated)": "英语(自动生成)",
|
||||||
|
"Afrikaans": "南非荷兰语",
|
||||||
|
"Albanian": "阿尔巴尼亚语",
|
||||||
|
"Amharic": "阿姆哈拉语",
|
||||||
|
"Arabic": "阿拉伯语",
|
||||||
|
"Armenian": "亚美尼亚语",
|
||||||
|
"Azerbaijani": "阿塞拜疆语",
|
||||||
|
"Bangla": "孟加拉语",
|
||||||
|
"Basque": "巴斯克语",
|
||||||
|
"Belarusian": "白俄罗斯语",
|
||||||
|
"Bosnian": "波黑语",
|
||||||
|
"Bulgarian": "保加利亚语",
|
||||||
|
"Burmese": "缅甸语",
|
||||||
|
"Catalan": "加泰罗尼亚语",
|
||||||
|
"Cebuano": "宿雾语",
|
||||||
|
"Chinese (Simplified)": "中文(简体)",
|
||||||
|
"Chinese (Traditional)": "中文(繁体)",
|
||||||
|
"Corsican": "科西嘉语",
|
||||||
|
"Croatian": "克罗地亚语",
|
||||||
|
"Czech": "捷克语",
|
||||||
|
"Danish": "丹麦语",
|
||||||
|
"Dutch": "荷兰语",
|
||||||
|
"Esperanto": "世界语",
|
||||||
|
"Estonian": "爱沙尼亚语",
|
||||||
|
"Filipino": "菲律宾语",
|
||||||
|
"Finnish": "芬兰语",
|
||||||
|
"French": "法语",
|
||||||
|
"Galician": "加利西亚语",
|
||||||
|
"Georgian": "格鲁吉亚语",
|
||||||
|
"German": "德语",
|
||||||
|
"Greek": "希腊语",
|
||||||
|
"Gujarati": "古吉拉特语",
|
||||||
|
"Haitian Creole": "海地克里奥尔语",
|
||||||
|
"Hausa": "豪萨语",
|
||||||
|
"Hawaiian": "夏威夷语",
|
||||||
|
"Hebrew": "希伯来语",
|
||||||
|
"Hindi": "印地语",
|
||||||
|
"Hmong": "苗语",
|
||||||
|
"Hungarian": "匈牙利语",
|
||||||
|
"Icelandic": "冰岛语",
|
||||||
|
"Igbo": "伊博语",
|
||||||
|
"Indonesian": "印度尼西亚语",
|
||||||
|
"Irish": "爱尔兰语",
|
||||||
|
"Italian": "意大利语",
|
||||||
|
"Japanese": "日语",
|
||||||
|
"Javanese": "爪哇语",
|
||||||
|
"Kannada": "卡纳达语",
|
||||||
|
"Kazakh": "哈萨克语",
|
||||||
|
"Khmer": "高棉语",
|
||||||
|
"Korean": "韩语",
|
||||||
|
"Kurdish": "库尔德语",
|
||||||
|
"Kyrgyz": "柯尔克孜语",
|
||||||
|
"Lao": "老挝语",
|
||||||
|
"Latin": "拉丁语",
|
||||||
|
"Latvian": "拉脱维亚语",
|
||||||
|
"Lithuanian": "立陶宛语",
|
||||||
|
"Luxembourgish": "卢森堡语",
|
||||||
|
"Macedonian": "马其顿语",
|
||||||
|
"Malagasy": "马尔加什语",
|
||||||
|
"Malay": "马来语",
|
||||||
|
"Malayalam": "马拉雅拉姆语",
|
||||||
|
"Maltese": "马耳他语",
|
||||||
|
"Maori": "毛利语",
|
||||||
|
"Marathi": "马拉语",
|
||||||
|
"Mongolian": "蒙古语",
|
||||||
|
"Nepali": "尼泊尔语",
|
||||||
|
"Norwegian Bokmål": "书面挪威语",
|
||||||
|
"Nyanja": "尼昂加语",
|
||||||
|
"Pashto": "普什图语",
|
||||||
|
"Persian": "波斯语",
|
||||||
|
"Polish": "抛光",
|
||||||
|
"Portuguese": "葡萄牙语",
|
||||||
|
"Punjabi": "旁遮普语",
|
||||||
|
"Romanian": "罗马尼亚语",
|
||||||
|
"Russian": "俄语",
|
||||||
|
"Samoan": "萨摩亚语",
|
||||||
|
"Scottish Gaelic": "苏格兰盖尔语",
|
||||||
|
"Serbian": "塞尔维亚语",
|
||||||
|
"Shona": "绍纳语",
|
||||||
|
"Sindhi": "信德语",
|
||||||
|
"Sinhala": "僧伽罗语",
|
||||||
|
"Slovak": "斯洛伐克语",
|
||||||
|
"Slovenian": "斯洛文尼亚语",
|
||||||
|
"Somali": "索马里语",
|
||||||
|
"Southern Sotho": "南索托语",
|
||||||
|
"Spanish": "西班牙语",
|
||||||
|
"Spanish (Latin America)": "西班牙语(拉丁美洲)",
|
||||||
|
"Sundanese": "巽丹语",
|
||||||
|
"Swahili": "斯瓦希里语",
|
||||||
|
"Swedish": "瑞典语",
|
||||||
|
"Tajik": "塔吉克语",
|
||||||
|
"Tamil": "泰米尔语",
|
||||||
|
"Telugu": "泰卢固语",
|
||||||
|
"Thai": "泰语",
|
||||||
|
"Turkish": "土耳其语",
|
||||||
|
"Ukrainian": "乌克兰语",
|
||||||
|
"Urdu": "乌尔都语",
|
||||||
|
"Uzbek": "乌兹别克",
|
||||||
|
"Vietnamese": "越南语",
|
||||||
|
"Welsh": "威尔士语",
|
||||||
|
"Western Frisian": "西弗里西亚语",
|
||||||
|
"Xhosa": "科萨语",
|
||||||
|
"Yiddish": "意第绪语",
|
||||||
|
"Yoruba": "约鲁巴语",
|
||||||
|
"Zulu": "祖鲁语",
|
||||||
|
"`x` years": "`x` 年",
|
||||||
|
"`x` months": "`x` 月",
|
||||||
|
"`x` weeks": "`x` 周",
|
||||||
|
"`x` days": "`x` 天",
|
||||||
|
"`x` hours": "`x` 小时",
|
||||||
|
"`x` minutes": "`x` 分钟",
|
||||||
|
"`x` seconds": "`x` 秒",
|
||||||
|
"Fallback comments: ": "后备评论:",
|
||||||
|
"Popular": "热门频道",
|
||||||
|
"Top": "热门视频",
|
||||||
|
"About": "关于",
|
||||||
|
"Rating: ": "评分:",
|
||||||
|
"Language: ": "语言:",
|
||||||
|
"View as playlist": "作为播放列表查看",
|
||||||
|
"Default": "默认",
|
||||||
|
"Music": "音乐",
|
||||||
|
"Gaming": "游戏",
|
||||||
|
"News": "新闻",
|
||||||
|
"Movies": "电影",
|
||||||
|
"Download": "下载",
|
||||||
|
"Download as: ": "下载为:",
|
||||||
|
"%A %B %-d, %Y": "%Y年%-m月%-d日 %a",
|
||||||
|
"(edited)": "(已编辑)",
|
||||||
|
"YouTube comment permalink": "YouTube 评论永久链接",
|
||||||
|
"permalink": "",
|
||||||
|
"`x` marked it with a ❤": "`x` 为此加 ❤",
|
||||||
|
"Audio mode": "音频模式",
|
||||||
|
"Video mode": "视频模式",
|
||||||
|
"Videos": "视频",
|
||||||
|
"Playlists": "播放列表",
|
||||||
|
"Community": "",
|
||||||
|
"Current version: ": "当前版本:"
|
||||||
|
}
|
BIN
screenshots/native_notification.png
Normal file
BIN
screenshots/native_notification.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
@ -1,5 +1,5 @@
|
|||||||
name: invidious
|
name: invidious
|
||||||
version: 0.17.0
|
version: 0.19.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Omar Roth <omarroth@protonmail.com>
|
- Omar Roth <omarroth@protonmail.com>
|
||||||
@ -9,13 +9,13 @@ targets:
|
|||||||
main: src/invidious.cr
|
main: src/invidious.cr
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
kemal:
|
|
||||||
github: kemalcr/kemal
|
|
||||||
pg:
|
pg:
|
||||||
github: will/crystal-pg
|
github: will/crystal-pg
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
|
kemal:
|
||||||
|
github: kemalcr/kemal
|
||||||
|
|
||||||
crystal: 0.28.0
|
crystal: 0.29.0
|
||||||
|
|
||||||
license: AGPLv3
|
license: AGPLv3
|
||||||
|
File diff suppressed because one or more lines are too long
843
src/invidious.cr
843
src/invidious.cr
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,8 @@ end
|
|||||||
struct ChannelVideo
|
struct ChannelVideo
|
||||||
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
|
json.field "type", "shortVideo"
|
||||||
|
|
||||||
json.field "title", self.title
|
json.field "title", self.title
|
||||||
json.field "videoId", self.id
|
json.field "videoId", self.id
|
||||||
json.field "videoThumbnails" do
|
json.field "videoThumbnails" do
|
||||||
@ -39,6 +41,48 @@ struct ChannelVideo
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_xml(locale, host_url, xml : XML::Builder)
|
||||||
|
xml.element("entry") do
|
||||||
|
xml.element("id") { xml.text "yt:video:#{self.id}" }
|
||||||
|
xml.element("yt:videoId") { xml.text self.id }
|
||||||
|
xml.element("yt:channelId") { xml.text self.ucid }
|
||||||
|
xml.element("title") { xml.text self.title }
|
||||||
|
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}")
|
||||||
|
|
||||||
|
xml.element("author") do
|
||||||
|
xml.element("name") { xml.text self.author }
|
||||||
|
xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("content", type: "xhtml") do
|
||||||
|
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
|
||||||
|
xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do
|
||||||
|
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
|
||||||
|
xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") }
|
||||||
|
|
||||||
|
xml.element("media:group") do
|
||||||
|
xml.element("media:title") { xml.text self.title }
|
||||||
|
xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg",
|
||||||
|
width: "320", height: "180")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_xml(locale, config, kemal_config, xml : XML::Builder | Nil = nil)
|
||||||
|
if xml
|
||||||
|
to_xml(locale, config, kemal_config, xml)
|
||||||
|
else
|
||||||
|
XML.build do |xml|
|
||||||
|
to_xml(locale, config, kemal_config, xml)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
@ -53,6 +97,36 @@ struct ChannelVideo
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
struct AboutRelatedChannel
|
||||||
|
db_mapping({
|
||||||
|
ucid: String,
|
||||||
|
author: String,
|
||||||
|
author_url: String,
|
||||||
|
author_thumbnail: String,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Refactor into either SearchChannel or InvidiousChannel
|
||||||
|
struct AboutChannel
|
||||||
|
db_mapping({
|
||||||
|
ucid: String,
|
||||||
|
author: String,
|
||||||
|
auto_generated: Bool,
|
||||||
|
author_url: String,
|
||||||
|
author_thumbnail: String,
|
||||||
|
banner: String?,
|
||||||
|
description_html: String,
|
||||||
|
paid: Bool,
|
||||||
|
total_views: Int64,
|
||||||
|
sub_count: Int64,
|
||||||
|
joined: Time,
|
||||||
|
is_family_friendly: Bool,
|
||||||
|
allowed_regions: Array(String),
|
||||||
|
related_channels: Array(AboutRelatedChannel),
|
||||||
|
tabs: Array(String),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
|
def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
|
||||||
finished_channel = Channel(String | Nil).new
|
finished_channel = Channel(String | Nil).new
|
||||||
|
|
||||||
@ -91,10 +165,8 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel(id, db, refresh = true, pull_all_videos = true)
|
def get_channel(id, db, refresh = true, pull_all_videos = true)
|
||||||
if db.query_one?("SELECT EXISTS (SELECT true FROM channels WHERE id = $1)", id, as: Bool)
|
if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
|
||||||
channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
|
if refresh && Time.utc - channel.updated > 10.minutes
|
||||||
|
|
||||||
if refresh && Time.now - channel.updated > 10.minutes
|
|
||||||
channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
|
channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
|
||||||
channel_array = channel.to_a
|
channel_array = channel.to_a
|
||||||
args = arg_array(channel_array)
|
args = arg_array(channel_array)
|
||||||
@ -175,7 +247,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
id: video_id,
|
id: video_id,
|
||||||
title: title,
|
title: title,
|
||||||
published: published,
|
published: published,
|
||||||
updated: Time.now,
|
updated: Time.utc,
|
||||||
ucid: ucid,
|
ucid: ucid,
|
||||||
author: author,
|
author: author,
|
||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
@ -184,7 +256,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
views: views,
|
views: views,
|
||||||
)
|
)
|
||||||
|
|
||||||
users = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
emails = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
||||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
|
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
|
||||||
video.id, video.published, ucid, as: String)
|
video.id, video.published, ucid, as: String)
|
||||||
|
|
||||||
@ -198,13 +270,14 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
|
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
|
||||||
live_now = $8, views = $10", video_array)
|
live_now = $8, views = $10", video_array)
|
||||||
|
|
||||||
users.each do |user|
|
# Update all users affected by insert
|
||||||
payload = {
|
if emails.empty?
|
||||||
"email" => user,
|
values = "'{}'"
|
||||||
"action" => "refresh",
|
else
|
||||||
}.to_json
|
values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
|
||||||
end
|
end
|
||||||
|
|
||||||
if pull_all_videos
|
if pull_all_videos
|
||||||
@ -237,7 +310,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
id: video.id,
|
id: video.id,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
published: video.published,
|
published: video.published,
|
||||||
updated: Time.now,
|
updated: Time.utc,
|
||||||
ucid: video.ucid,
|
ucid: video.ucid,
|
||||||
author: video.author,
|
author: video.author,
|
||||||
length_seconds: video.length_seconds,
|
length_seconds: video.length_seconds,
|
||||||
@ -251,8 +324,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
|
|
||||||
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
|
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
|
||||||
# so since they don't provide a published date here we can safely ignore them.
|
# so since they don't provide a published date here we can safely ignore them.
|
||||||
if Time.now - video.published > 1.minute
|
if Time.utc - video.published > 1.minute
|
||||||
users = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
emails = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
||||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
|
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
|
||||||
video.id, video.published, video.ucid, as: String)
|
video.id, video.published, video.ucid, as: String)
|
||||||
|
|
||||||
@ -266,13 +339,13 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
live_now = $8, views = $10", video_array)
|
live_now = $8, views = $10", video_array)
|
||||||
|
|
||||||
# Update all users affected by insert
|
# Update all users affected by insert
|
||||||
users.each do |user|
|
if emails.empty?
|
||||||
payload = {
|
values = "'{}'"
|
||||||
"email" => user,
|
else
|
||||||
"action" => "refresh",
|
values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
}.to_json
|
|
||||||
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -287,31 +360,11 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
|||||||
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
|
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
|
||||||
end
|
end
|
||||||
|
|
||||||
channel = InvidiousChannel.new(ucid, author, Time.now, false, nil)
|
channel = InvidiousChannel.new(ucid, author, Time.utc, false, nil)
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscribe_pubsub(ucid, key, config)
|
|
||||||
client = make_client(PUBSUB_URL)
|
|
||||||
time = Time.now.to_unix.to_s
|
|
||||||
nonce = Random::Secure.hex(4)
|
|
||||||
signature = "#{time}:#{nonce}"
|
|
||||||
|
|
||||||
host_url = make_host_url(config, Kemal.config)
|
|
||||||
|
|
||||||
body = {
|
|
||||||
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
|
||||||
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?channel_id=#{ucid}",
|
|
||||||
"hub.verify" => "async",
|
|
||||||
"hub.mode" => "subscribe",
|
|
||||||
"hub.lease_seconds" => "432000",
|
|
||||||
"hub.secret" => key.to_s,
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.post("/subscribe", form: body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
|
def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
|
|
||||||
@ -322,13 +375,14 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
|
|||||||
json = JSON.parse(response.body)
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
if json["load_more_widget_html"].as_s.empty?
|
if json["load_more_widget_html"].as_s.empty?
|
||||||
return [] of SearchItem, nil
|
continuation = nil
|
||||||
end
|
else
|
||||||
|
continuation = XML.parse_html(json["load_more_widget_html"].as_s)
|
||||||
|
continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href]))
|
||||||
|
|
||||||
continuation = XML.parse_html(json["load_more_widget_html"].as_s)
|
if continuation
|
||||||
continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href]))
|
continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
|
||||||
if continuation
|
end
|
||||||
continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
html = XML.parse_html(json["content_html"].as_s)
|
html = XML.parse_html(json["content_html"].as_s)
|
||||||
@ -375,7 +429,7 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
|
|||||||
if auto_generated
|
if auto_generated
|
||||||
seed = Time.unix(1525757349)
|
seed = Time.unix(1525757349)
|
||||||
|
|
||||||
until seed >= Time.now
|
until seed >= Time.utc
|
||||||
seed += 1.month
|
seed += 1.month
|
||||||
end
|
end
|
||||||
timestamp = seed - (page - 1).months
|
timestamp = seed - (page - 1).months
|
||||||
@ -387,53 +441,57 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
|
|||||||
switch = 0x00
|
switch = 0x00
|
||||||
end
|
end
|
||||||
|
|
||||||
meta = IO::Memory.new
|
data = IO::Memory.new
|
||||||
meta.write(Bytes[0x12, 0x06])
|
data.write_byte 0x12
|
||||||
meta.print("videos")
|
data.write_byte 0x06
|
||||||
|
data.print "videos"
|
||||||
|
|
||||||
meta.write(Bytes[0x30, 0x02])
|
data.write Bytes[0x30, 0x02]
|
||||||
meta.write(Bytes[0x38, 0x01])
|
data.write Bytes[0x38, 0x01]
|
||||||
meta.write(Bytes[0x60, 0x01])
|
data.write Bytes[0x60, 0x01]
|
||||||
meta.write(Bytes[0x6a, 0x00])
|
data.write Bytes[0x6a, 0x00]
|
||||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
data.write Bytes[0xb8, 0x01, 0x00]
|
||||||
|
|
||||||
meta.write(Bytes[0x20, switch])
|
data.write Bytes[0x20, switch]
|
||||||
meta.write(Bytes[0x7a, page.size])
|
data.write_byte 0x7a
|
||||||
meta.print(page)
|
VarInt.to_io(data, page.bytesize)
|
||||||
|
data.print page
|
||||||
|
|
||||||
case sort_by
|
case sort_by
|
||||||
when "newest"
|
when "newest"
|
||||||
# Empty tags can be omitted
|
# Empty tags can be omitted
|
||||||
# meta.write(Bytes[0x18,0x00])
|
# meta.write(Bytes[0x18,0x00])
|
||||||
when "popular"
|
when "popular"
|
||||||
meta.write(Bytes[0x18, 0x01])
|
data.write Bytes[0x18, 0x01]
|
||||||
when "oldest"
|
when "oldest"
|
||||||
meta.write(Bytes[0x18, 0x02])
|
data.write Bytes[0x18, 0x02]
|
||||||
end
|
end
|
||||||
|
|
||||||
meta.rewind
|
data = Base64.urlsafe_encode(data)
|
||||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
cursor = URI.escape(data)
|
||||||
meta = URI.escape(meta)
|
|
||||||
|
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
continuation.write(Bytes[0x12, ucid.size])
|
|
||||||
continuation.print(ucid)
|
|
||||||
|
|
||||||
continuation.write(Bytes[0x1a, meta.size])
|
data.write_byte 0x12
|
||||||
continuation.print(meta)
|
VarInt.to_io(data, ucid.bytesize)
|
||||||
|
data.print ucid
|
||||||
|
|
||||||
continuation.rewind
|
data.write_byte 0x1a
|
||||||
continuation = continuation.gets_to_end
|
VarInt.to_io(data, cursor.bytesize)
|
||||||
|
data.print cursor
|
||||||
|
|
||||||
wrapper = IO::Memory.new
|
data.rewind
|
||||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
|
||||||
wrapper.print(continuation)
|
|
||||||
wrapper.rewind
|
|
||||||
|
|
||||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
buffer = IO::Memory.new
|
||||||
wrapper = URI.escape(wrapper)
|
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
|
||||||
|
VarInt.to_io(buffer, data.bytesize)
|
||||||
|
|
||||||
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
|
IO.copy data, buffer
|
||||||
|
|
||||||
|
continuation = Base64.urlsafe_encode(buffer)
|
||||||
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
|
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
|
||||||
|
|
||||||
return url
|
return url
|
||||||
end
|
end
|
||||||
@ -443,117 +501,108 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
|
|||||||
cursor = Base64.urlsafe_encode(cursor, false)
|
cursor = Base64.urlsafe_encode(cursor, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
meta = IO::Memory.new
|
data = IO::Memory.new
|
||||||
|
|
||||||
if auto_generated
|
if auto_generated
|
||||||
meta.write(Bytes[0x08, 0x0a])
|
data.write Bytes[0x08, 0x0a]
|
||||||
end
|
end
|
||||||
|
|
||||||
meta.write(Bytes[0x12, 0x09])
|
data.write Bytes[0x12, 0x09]
|
||||||
meta.print("playlists")
|
data.print "playlists"
|
||||||
|
|
||||||
if auto_generated
|
if auto_generated
|
||||||
meta.write(Bytes[0x20, 0x32])
|
data.write Bytes[0x20, 0x32]
|
||||||
else
|
else
|
||||||
# TODO: Look at 0x01, 0x00
|
# TODO: Look at 0x01, 0x00
|
||||||
case sort
|
case sort
|
||||||
when "oldest", "oldest_created"
|
when "oldest", "oldest_created"
|
||||||
meta.write(Bytes[0x18, 0x02])
|
data.write Bytes[0x18, 0x02]
|
||||||
when "newest", "newest_created"
|
when "newest", "newest_created"
|
||||||
meta.write(Bytes[0x18, 0x03])
|
data.write Bytes[0x18, 0x03]
|
||||||
when "last", "last_added"
|
when "last", "last_added"
|
||||||
meta.write(Bytes[0x18, 0x04])
|
data.write Bytes[0x18, 0x04]
|
||||||
end
|
end
|
||||||
|
|
||||||
meta.write(Bytes[0x20, 0x01])
|
data.write Bytes[0x20, 0x01]
|
||||||
end
|
end
|
||||||
|
|
||||||
meta.write(Bytes[0x30, 0x02])
|
data.write Bytes[0x30, 0x02]
|
||||||
meta.write(Bytes[0x38, 0x01])
|
data.write Bytes[0x38, 0x01]
|
||||||
meta.write(Bytes[0x60, 0x01])
|
data.write Bytes[0x60, 0x01]
|
||||||
meta.write(Bytes[0x6a, 0x00])
|
data.write Bytes[0x6a, 0x00]
|
||||||
|
|
||||||
meta.write(Bytes[0x7a, cursor.size])
|
data.write_byte 0x7a
|
||||||
meta.print(cursor)
|
VarInt.to_io(data, cursor.bytesize)
|
||||||
|
data.print cursor
|
||||||
|
|
||||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
data.write Bytes[0xb8, 0x01, 0x00]
|
||||||
|
|
||||||
meta.rewind
|
data.rewind
|
||||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
data = Base64.urlsafe_encode(data)
|
||||||
meta = URI.escape(meta)
|
continuation = URI.escape(data)
|
||||||
|
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
continuation.write(Bytes[0x12, ucid.size])
|
|
||||||
continuation.print(ucid)
|
|
||||||
|
|
||||||
continuation.write(Bytes[0x1a])
|
data.write_byte 0x12
|
||||||
continuation.write(write_var_int(meta.size))
|
VarInt.to_io(data, ucid.bytesize)
|
||||||
continuation.print(meta)
|
data.print ucid
|
||||||
|
|
||||||
continuation.rewind
|
data.write_byte 0x1a
|
||||||
continuation = continuation.gets_to_end
|
VarInt.to_io(data, continuation.bytesize)
|
||||||
|
data.print continuation
|
||||||
|
|
||||||
wrapper = IO::Memory.new
|
data.rewind
|
||||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
|
|
||||||
wrapper.write(write_var_int(continuation.size))
|
|
||||||
wrapper.print(continuation)
|
|
||||||
wrapper.rewind
|
|
||||||
|
|
||||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
buffer = IO::Memory.new
|
||||||
wrapper = URI.escape(wrapper)
|
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
|
||||||
|
VarInt.to_io(buffer, data.bytesize)
|
||||||
|
|
||||||
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
|
IO.copy data, buffer
|
||||||
|
|
||||||
|
continuation = Base64.urlsafe_encode(buffer)
|
||||||
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
|
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
|
||||||
|
|
||||||
return url
|
return url
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_channel_playlists_cursor(url, auto_generated)
|
def extract_channel_playlists_cursor(url, auto_generated)
|
||||||
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
|
continuation = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
|
||||||
|
|
||||||
wrapper = URI.unescape(wrapper)
|
continuation = URI.unescape(continuation)
|
||||||
wrapper = Base64.decode(wrapper)
|
data = IO::Memory.new(Base64.decode(continuation))
|
||||||
|
|
||||||
# 0xe2 0xa9 0x85 0xb2 0x02
|
# 0xe2 0xa9 0x85 0xb2 0x02
|
||||||
wrapper += 5
|
data.pos += 5
|
||||||
|
|
||||||
continuation_size = read_var_int(wrapper[0, 4])
|
continuation = Bytes.new(data.read_bytes(VarInt))
|
||||||
wrapper += write_var_int(continuation_size).size
|
data.read continuation
|
||||||
continuation = wrapper[0, continuation_size]
|
data = IO::Memory.new(continuation)
|
||||||
|
|
||||||
# 0x12
|
data.read_byte # => 0x12
|
||||||
continuation += 1
|
ucid = Bytes.new(data.read_bytes(VarInt))
|
||||||
ucid_size = continuation[0]
|
data.read ucid
|
||||||
continuation += 1
|
|
||||||
ucid = continuation[0, ucid_size]
|
|
||||||
continuation += ucid_size
|
|
||||||
|
|
||||||
# 0x1a
|
data.read_byte # => 0x1a
|
||||||
continuation += 1
|
inner_continuation = Bytes.new(data.read_bytes(VarInt))
|
||||||
meta_size = read_var_int(continuation[0, 4])
|
data.read inner_continuation
|
||||||
continuation += write_var_int(meta_size).size
|
|
||||||
meta = continuation[0, meta_size]
|
|
||||||
continuation += meta_size
|
|
||||||
|
|
||||||
meta = String.new(meta)
|
continuation = String.new(inner_continuation)
|
||||||
meta = URI.unescape(meta)
|
continuation = URI.unescape(continuation)
|
||||||
meta = Base64.decode(meta)
|
data = IO::Memory.new(Base64.decode(continuation))
|
||||||
|
|
||||||
# 0x12 0x09 playlists
|
# 0x12 0x09 playlists
|
||||||
meta += 11
|
data.pos += 11
|
||||||
|
|
||||||
until meta[0] == 0x7a
|
until data.peek[0] == 0x7a
|
||||||
tag = read_var_int(meta[0, 4])
|
key = data.read_bytes(VarInt)
|
||||||
meta += write_var_int(tag).size
|
value = data.read_bytes(VarInt)
|
||||||
value = meta[0]
|
|
||||||
meta += 1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# 0x7a
|
data.pos += 1 # => 0x7a
|
||||||
meta += 1
|
cursor = Bytes.new(data.read_bytes(VarInt))
|
||||||
cursor_size = meta[0]
|
data.read cursor
|
||||||
meta += 1
|
|
||||||
cursor = meta[0, cursor_size]
|
|
||||||
|
|
||||||
cursor = String.new(cursor)
|
cursor = String.new(cursor)
|
||||||
|
|
||||||
if !auto_generated
|
if !auto_generated
|
||||||
@ -564,6 +613,310 @@ def extract_channel_playlists_cursor(url, auto_generated)
|
|||||||
return cursor
|
return cursor
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: Add "sort_by"
|
||||||
|
def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode)
|
||||||
|
client = make_client(YT_URL)
|
||||||
|
headers = HTTP::Headers.new
|
||||||
|
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
||||||
|
|
||||||
|
response = client.get("/channel/#{ucid}/community?gl=US&hl=en", headers)
|
||||||
|
if response.status_code == 404
|
||||||
|
response = client.get("/user/#{ucid}/community?gl=US&hl=en", headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.status_code == 404
|
||||||
|
error_message = translate(locale, "This channel does not exist.")
|
||||||
|
raise error_message
|
||||||
|
end
|
||||||
|
|
||||||
|
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
|
||||||
|
|
||||||
|
if !continuation || continuation.empty?
|
||||||
|
initial_data = extract_initial_data(response.body)
|
||||||
|
body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
|
||||||
|
|
||||||
|
if !body
|
||||||
|
raise "Could not extract community tab."
|
||||||
|
end
|
||||||
|
|
||||||
|
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
|
||||||
|
else
|
||||||
|
continuation = produce_channel_community_continuation(ucid, continuation)
|
||||||
|
|
||||||
|
headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"]
|
||||||
|
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
headers["x-client-data"] = "CIi2yQEIpbbJAQipncoBCNedygEIqKPKAQ=="
|
||||||
|
headers["x-spf-previous"] = ""
|
||||||
|
headers["x-spf-referer"] = ""
|
||||||
|
|
||||||
|
headers["x-youtube-client-name"] = "1"
|
||||||
|
headers["x-youtube-client-version"] = "2.20180719"
|
||||||
|
|
||||||
|
session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[A-Za-z0-9\_\-\=]+)"/).try &.["session_token"]? || ""
|
||||||
|
post_req = {
|
||||||
|
session_token: session_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
|
||||||
|
body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
|
||||||
|
body["response"]["continuationContents"]["backstageCommentsContinuation"]?
|
||||||
|
|
||||||
|
if !body
|
||||||
|
raise "Could not extract continuation."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s
|
||||||
|
posts = body["contents"].as_a
|
||||||
|
|
||||||
|
if message = posts[0]["messageRenderer"]?
|
||||||
|
error_message = (message["text"]["simpleText"]? ||
|
||||||
|
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||||
|
.try &.as_s || ""
|
||||||
|
raise error_message
|
||||||
|
end
|
||||||
|
|
||||||
|
response = JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "authorId", ucid
|
||||||
|
json.field "comments" do
|
||||||
|
json.array do
|
||||||
|
posts.each do |post|
|
||||||
|
comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
|
||||||
|
post["backstageCommentsContinuation"]?
|
||||||
|
|
||||||
|
post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
|
||||||
|
post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
|
||||||
|
|
||||||
|
if !post
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if !post["contentText"]?
|
||||||
|
content_html = ""
|
||||||
|
else
|
||||||
|
content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
|
||||||
|
content_to_comment_html(post["contentText"]["runs"].as_a).try &.to_s || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
author = post["authorText"]?.try &.["simpleText"]? || ""
|
||||||
|
|
||||||
|
json.object do
|
||||||
|
json.field "author", author
|
||||||
|
json.field "authorThumbnails" do
|
||||||
|
json.array do
|
||||||
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if post["authorEndpoint"]?
|
||||||
|
json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
|
||||||
|
json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
|
||||||
|
else
|
||||||
|
json.field "authorId", ""
|
||||||
|
json.field "authorUrl", ""
|
||||||
|
end
|
||||||
|
|
||||||
|
published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
|
||||||
|
published = decode_date(published_text.rchop(" (edited)"))
|
||||||
|
|
||||||
|
if published_text.includes?(" (edited)")
|
||||||
|
json.field "isEdited", true
|
||||||
|
else
|
||||||
|
json.field "isEdited", false
|
||||||
|
end
|
||||||
|
|
||||||
|
like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
|
||||||
|
.try &.as_s.gsub(/\D/, "").to_i? || 0
|
||||||
|
|
||||||
|
json.field "content", html_to_content(content_html)
|
||||||
|
json.field "contentHtml", content_html
|
||||||
|
|
||||||
|
json.field "published", published.to_unix
|
||||||
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
|
||||||
|
|
||||||
|
json.field "likeCount", like_count
|
||||||
|
json.field "commentId", post["postId"]? || post["commentId"]? || ""
|
||||||
|
json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
|
||||||
|
|
||||||
|
if attachment = post["backstageAttachment"]?
|
||||||
|
json.field "attachment" do
|
||||||
|
json.object do
|
||||||
|
case attachment.as_h
|
||||||
|
when .has_key?("videoRenderer")
|
||||||
|
attachment = attachment["videoRenderer"]
|
||||||
|
json.field "type", "video"
|
||||||
|
|
||||||
|
if !attachment["videoId"]?
|
||||||
|
error_message = (attachment["title"]["simpleText"]? ||
|
||||||
|
attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||||
|
|
||||||
|
json.field "error", error_message
|
||||||
|
else
|
||||||
|
video_id = attachment["videoId"].as_s
|
||||||
|
|
||||||
|
json.field "title", attachment["title"]["simpleText"].as_s
|
||||||
|
json.field "videoId", video_id
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, video_id, config, kemal_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)
|
||||||
|
|
||||||
|
author_info = attachment["ownerText"]["runs"][0].as_h
|
||||||
|
|
||||||
|
json.field "author", author_info["text"].as_s
|
||||||
|
json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"]
|
||||||
|
json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
|
||||||
|
|
||||||
|
# TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers"
|
||||||
|
# TODO: json.field "authorVerified", "ownerBadges"
|
||||||
|
|
||||||
|
published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s)
|
||||||
|
|
||||||
|
json.field "published", published.to_unix
|
||||||
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
|
||||||
|
|
||||||
|
view_count = attachment["viewCountText"]["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
|
||||||
|
|
||||||
|
json.field "viewCount", view_count
|
||||||
|
json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
|
||||||
|
end
|
||||||
|
when .has_key?("backstageImageRenderer")
|
||||||
|
attachment = attachment["backstageImageRenderer"]
|
||||||
|
json.field "type", "image"
|
||||||
|
|
||||||
|
json.field "imageThumbnails" do
|
||||||
|
json.array do
|
||||||
|
thumbnail = attachment["image"]["thumbnails"][0].as_h
|
||||||
|
width = thumbnail["width"].as_i
|
||||||
|
height = thumbnail["height"].as_i
|
||||||
|
aspect_ratio = (width.to_f / height.to_f)
|
||||||
|
|
||||||
|
qualities = {320, 560, 640, 1280, 2000}
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", thumbnail["url"].as_s.gsub("=s640-", "=s#{quality}-")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", (quality / aspect_ratio).ceil.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# TODO
|
||||||
|
# when .has_key?("pollRenderer")
|
||||||
|
# attachment = attachment["pollRenderer"]
|
||||||
|
# json.field "type", "poll"
|
||||||
|
else
|
||||||
|
json.field "type", "unknown"
|
||||||
|
json.field "error", "Unrecognized attachment type."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
|
||||||
|
comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||||
|
.try &.as_s.gsub(/\D/, "").to_i?)
|
||||||
|
continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
|
||||||
|
continuation ||= ""
|
||||||
|
|
||||||
|
json.field "replies" do
|
||||||
|
json.object do
|
||||||
|
json.field "replyCount", reply_count
|
||||||
|
json.field "continuation", extract_channel_community_cursor(continuation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if body["continuations"]?
|
||||||
|
continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
|
||||||
|
json.field "continuation", extract_channel_community_cursor(continuation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if format == "html"
|
||||||
|
response = JSON.parse(response)
|
||||||
|
content_html = template_youtube_comments(response, locale, thin_mode)
|
||||||
|
|
||||||
|
response = JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "contentHtml", content_html
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
|
def produce_channel_community_continuation(ucid, cursor)
|
||||||
|
cursor = URI.escape(cursor)
|
||||||
|
|
||||||
|
data = IO::Memory.new
|
||||||
|
|
||||||
|
data.write_byte 0x12
|
||||||
|
VarInt.to_io(data, ucid.bytesize)
|
||||||
|
data.print ucid
|
||||||
|
|
||||||
|
data.write_byte 0x1a
|
||||||
|
VarInt.to_io(data, cursor.bytesize)
|
||||||
|
data.print cursor
|
||||||
|
|
||||||
|
data.rewind
|
||||||
|
|
||||||
|
buffer = IO::Memory.new
|
||||||
|
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
|
||||||
|
VarInt.to_io(buffer, data.size)
|
||||||
|
|
||||||
|
IO.copy data, buffer
|
||||||
|
|
||||||
|
continuation = Base64.urlsafe_encode(buffer)
|
||||||
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
|
return continuation
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_channel_community_cursor(continuation)
|
||||||
|
continuation = URI.unescape(continuation)
|
||||||
|
data = IO::Memory.new(Base64.decode(continuation))
|
||||||
|
|
||||||
|
# 0xe2 0xa9 0x85 0xb2 0x02
|
||||||
|
data.pos += 5
|
||||||
|
|
||||||
|
continuation = Bytes.new(data.read_bytes(VarInt))
|
||||||
|
data.read continuation
|
||||||
|
data = IO::Memory.new(continuation)
|
||||||
|
|
||||||
|
data.read_byte # => 0x12
|
||||||
|
ucid = Bytes.new(data.read_bytes(VarInt))
|
||||||
|
data.read ucid
|
||||||
|
|
||||||
|
data.read_byte # => 0x1a
|
||||||
|
until data.peek[0] == 'E'.ord
|
||||||
|
data.read_byte
|
||||||
|
end
|
||||||
|
|
||||||
|
return URI.unescape(data.gets_to_end)
|
||||||
|
end
|
||||||
|
|
||||||
def get_about_info(ucid, locale)
|
def get_about_info(ucid, locale)
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
|
|
||||||
@ -576,14 +929,12 @@ def get_about_info(ucid, locale)
|
|||||||
|
|
||||||
if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
|
if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
|
||||||
error_message = translate(locale, "This channel does not exist.")
|
error_message = translate(locale, "This channel does not exist.")
|
||||||
|
|
||||||
raise error_message
|
raise error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
if about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).try &.content.empty?
|
if about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).try &.content.empty?
|
||||||
error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
|
error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
|
||||||
error_message ||= translate(locale, "Could not get channel info.")
|
error_message ||= translate(locale, "Could not get channel info.")
|
||||||
|
|
||||||
raise error_message
|
raise error_message
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -594,8 +945,63 @@ def get_about_info(ucid, locale)
|
|||||||
sub_count ||= 0
|
sub_count ||= 0
|
||||||
|
|
||||||
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
|
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
|
||||||
|
author_url = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!["href"]
|
||||||
|
author_thumbnail = about.xpath_node(%q(//img[@class="channel-header-profile-image"])).not_nil!["src"]
|
||||||
|
|
||||||
ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
|
ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
|
||||||
|
|
||||||
|
banner = about.xpath_node(%q(//div[@id="gh-banner"]/style)).not_nil!.content
|
||||||
|
banner = "https:" + banner.match(/background-image: url\((?<url>[^)]+)\)/).not_nil!["url"]
|
||||||
|
|
||||||
|
if banner.includes? "channels/c4/default_banner"
|
||||||
|
banner = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
description_html = about.xpath_node(%q(//div[contains(@class,"about-description")])).try &.to_s || ""
|
||||||
|
|
||||||
|
paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True"
|
||||||
|
is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
|
||||||
|
allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
|
||||||
|
|
||||||
|
related_channels = about.xpath_nodes(%q(//div[contains(@class, "branded-page-related-channels")]/ul/li))
|
||||||
|
related_channels = related_channels.map do |node|
|
||||||
|
related_id = node["data-external-id"]?
|
||||||
|
related_id ||= ""
|
||||||
|
|
||||||
|
anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
|
||||||
|
related_title = anchor.try &.["title"]
|
||||||
|
related_title ||= ""
|
||||||
|
|
||||||
|
related_author_url = anchor.try &.["href"]
|
||||||
|
related_author_url ||= ""
|
||||||
|
|
||||||
|
related_author_thumbnail = node.xpath_node(%q(.//img)).try &.["data-thumb"]
|
||||||
|
related_author_thumbnail ||= ""
|
||||||
|
|
||||||
|
AboutRelatedChannel.new(
|
||||||
|
ucid: related_id,
|
||||||
|
author: related_title,
|
||||||
|
author_url: related_author_url,
|
||||||
|
author_thumbnail: related_author_thumbnail,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
total_views = 0_i64
|
||||||
|
sub_count = 0_i64
|
||||||
|
|
||||||
|
joined = Time.unix(0)
|
||||||
|
metadata = about.xpath_nodes(%q(//span[@class="about-stat"]))
|
||||||
|
metadata.each do |item|
|
||||||
|
case item.content
|
||||||
|
when .includes? "views"
|
||||||
|
total_views = item.content.gsub(/\D/, "").to_i64
|
||||||
|
when .includes? "subscribers"
|
||||||
|
sub_count = item.content.delete("subscribers").gsub(/\D/, "").to_i64
|
||||||
|
when .includes? "Joined"
|
||||||
|
joined = Time.parse(item.content.lchop("Joined "), "%b %-d, %Y", Time::Location.local)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Auto-generated channels
|
# Auto-generated channels
|
||||||
# https://support.google.com/youtube/answer/2579942
|
# https://support.google.com/youtube/answer/2579942
|
||||||
auto_generated = false
|
auto_generated = false
|
||||||
@ -604,10 +1010,28 @@ def get_about_info(ucid, locale)
|
|||||||
auto_generated = true
|
auto_generated = true
|
||||||
end
|
end
|
||||||
|
|
||||||
return {author, ucid, auto_generated, sub_count}
|
tabs = about.xpath_nodes(%q(//ul[@id="channel-navigation-menu"]/li/a/span)).map { |node| node.content.downcase }
|
||||||
|
|
||||||
|
return AboutChannel.new(
|
||||||
|
ucid: ucid,
|
||||||
|
author: author,
|
||||||
|
auto_generated: auto_generated,
|
||||||
|
author_url: author_url,
|
||||||
|
author_thumbnail: author_thumbnail,
|
||||||
|
banner: banner,
|
||||||
|
description_html: description_html,
|
||||||
|
paid: paid,
|
||||||
|
total_views: total_views,
|
||||||
|
sub_count: sub_count,
|
||||||
|
joined: joined,
|
||||||
|
is_family_friendly: is_family_friendly,
|
||||||
|
allowed_regions: allowed_regions,
|
||||||
|
related_channels: related_channels,
|
||||||
|
tabs: tabs
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
|
def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
|
||||||
count = 0
|
count = 0
|
||||||
videos = [] of SearchVideo
|
videos = [] of SearchVideo
|
||||||
|
|
||||||
@ -629,7 +1053,7 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
|
|||||||
if auto_generated
|
if auto_generated
|
||||||
videos += extract_videos(nodeset)
|
videos += extract_videos(nodeset)
|
||||||
else
|
else
|
||||||
videos += extract_videos(nodeset, ucid)
|
videos += extract_videos(nodeset, ucid, author)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
break
|
break
|
||||||
|
@ -22,6 +22,7 @@ class RedditComment
|
|||||||
replies: RedditThing | String,
|
replies: RedditThing | String,
|
||||||
score: Int32,
|
score: Int32,
|
||||||
depth: Int32,
|
depth: Int32,
|
||||||
|
permalink: String,
|
||||||
created_utc: {
|
created_utc: {
|
||||||
type: Time,
|
type: Time,
|
||||||
converter: RedditComment::TimeConverter,
|
converter: RedditComment::TimeConverter,
|
||||||
@ -56,14 +57,14 @@ class RedditListing
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode, region, sort_by = "top")
|
def fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by = "top")
|
||||||
video = fetch_video(id, proxies, region: region)
|
video = fetch_video(id, region)
|
||||||
session_token = video.info["session_token"]?
|
session_token = video.info["session_token"]?
|
||||||
|
|
||||||
ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
|
ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
|
||||||
continuation ||= ctoken
|
continuation ||= ctoken
|
||||||
|
|
||||||
if !continuation || !session_token
|
if !continuation || continuation.empty? || !session_token
|
||||||
if format == "json"
|
if format == "json"
|
||||||
return {"comments" => [] of String}.to_json
|
return {"comments" => [] of String}.to_json
|
||||||
else
|
else
|
||||||
@ -72,11 +73,10 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
end
|
end
|
||||||
|
|
||||||
post_req = {
|
post_req = {
|
||||||
"session_token" => session_token,
|
session_token: session_token,
|
||||||
}
|
}
|
||||||
post_req = HTTP::Params.encode(post_req)
|
|
||||||
|
|
||||||
client = make_client(YT_URL, proxies, video.info["region"]?)
|
client = make_client(YT_URL, video.info["region"]?)
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
|
|
||||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||||
@ -89,7 +89,7 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
headers["x-youtube-client-name"] = "1"
|
headers["x-youtube-client-name"] = "1"
|
||||||
headers["x-youtube-client-version"] = "2.20180719"
|
headers["x-youtube-client-version"] = "2.20180719"
|
||||||
|
|
||||||
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, post_req)
|
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
|
||||||
response = JSON.parse(response.body)
|
response = JSON.parse(response.body)
|
||||||
|
|
||||||
if !response["response"]["continuationContents"]?
|
if !response["response"]["continuationContents"]?
|
||||||
@ -112,10 +112,13 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
comments = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
if body["header"]?
|
if body["header"]?
|
||||||
comment_count = body["header"]["commentsHeaderRenderer"]["countText"]["simpleText"].as_s.delete("Comments,").to_i
|
count_text = body["header"]["commentsHeaderRenderer"]["countText"]
|
||||||
|
comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?)
|
||||||
|
.try &.as_s.gsub(/\D/, "").to_i? || 0
|
||||||
|
|
||||||
json.field "commentCount", comment_count
|
json.field "commentCount", comment_count
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -139,16 +142,9 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
node_comment = node["commentRenderer"]
|
node_comment = node["commentRenderer"]
|
||||||
end
|
end
|
||||||
|
|
||||||
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff')
|
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
|
||||||
if content_html
|
content_to_comment_html(node_comment["contentText"]["runs"].as_a).try &.to_s || ""
|
||||||
content_html = HTML.escape(content_html)
|
author = node_comment["authorText"]?.try &.["simpleText"]? || ""
|
||||||
end
|
|
||||||
|
|
||||||
content_html ||= content_to_comment_html(node_comment["contentText"]["runs"].as_a)
|
|
||||||
content_html, content = html_to_content(content_html)
|
|
||||||
|
|
||||||
author = node_comment["authorText"]?.try &.["simpleText"]
|
|
||||||
author ||= ""
|
|
||||||
|
|
||||||
json.field "author", author
|
json.field "author", author
|
||||||
json.field "authorThumbnails" do
|
json.field "authorThumbnails" do
|
||||||
@ -180,10 +176,12 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
json.field "isEdited", false
|
json.field "isEdited", false
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "content", content
|
json.field "content", html_to_content(content_html)
|
||||||
json.field "contentHtml", content_html
|
json.field "contentHtml", content_html
|
||||||
|
|
||||||
json.field "published", published.to_unix
|
json.field "published", published.to_unix
|
||||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
|
||||||
|
|
||||||
json.field "likeCount", node_comment["likeCount"]
|
json.field "likeCount", node_comment["likeCount"]
|
||||||
json.field "commentId", node_comment["commentId"]
|
json.field "commentId", node_comment["commentId"]
|
||||||
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
|
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
|
||||||
@ -199,13 +197,8 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
end
|
end
|
||||||
|
|
||||||
if node_replies && !response["commentRepliesContinuation"]?
|
if node_replies && !response["commentRepliesContinuation"]?
|
||||||
reply_count = node_replies["moreText"]["simpleText"].as_s.delete("View all reply replies,")
|
reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||||
if reply_count.empty?
|
.try &.as_s.gsub(/\D/, "").to_i? || 1
|
||||||
reply_count = 1
|
|
||||||
else
|
|
||||||
reply_count = reply_count.try &.to_i?
|
|
||||||
reply_count ||= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
|
continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
|
||||||
continuation ||= ""
|
continuation ||= ""
|
||||||
@ -230,15 +223,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
end
|
end
|
||||||
|
|
||||||
if format == "html"
|
if format == "html"
|
||||||
comments = JSON.parse(comments)
|
response = JSON.parse(response)
|
||||||
content_html = template_youtube_comments(comments, locale, thin_mode)
|
content_html = template_youtube_comments(response, locale, thin_mode)
|
||||||
|
|
||||||
comments = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "contentHtml", content_html
|
json.field "contentHtml", content_html
|
||||||
|
|
||||||
if comments["commentCount"]?
|
if response["commentCount"]?
|
||||||
json.field "commentCount", comments["commentCount"]
|
json.field "commentCount", response["commentCount"]
|
||||||
else
|
else
|
||||||
json.field "commentCount", 0
|
json.field "commentCount", 0
|
||||||
end
|
end
|
||||||
@ -246,14 +239,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return comments
|
return response
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_reddit_comments(id, sort_by = "confidence")
|
def fetch_reddit_comments(id, sort_by = "confidence")
|
||||||
client = make_client(REDDIT_URL)
|
client = make_client(REDDIT_URL)
|
||||||
headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"}
|
headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"}
|
||||||
|
|
||||||
query = "(url:3D#{id}%20OR%20url:#{id})%20(site:youtube.com%20OR%20site:youtu.be)"
|
# TODO: Use something like #479 for a static list of instances to use here
|
||||||
|
query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)"
|
||||||
search_results = client.get("/search.json?q=#{query}", headers)
|
search_results = client.get("/search.json?q=#{query}", headers)
|
||||||
|
|
||||||
if search_results.status_code == 200
|
if search_results.status_code == 200
|
||||||
@ -282,56 +276,110 @@ def fetch_reddit_comments(id, sort_by = "confidence")
|
|||||||
end
|
end
|
||||||
|
|
||||||
def template_youtube_comments(comments, locale, thin_mode)
|
def template_youtube_comments(comments, locale, thin_mode)
|
||||||
html = ""
|
String.build do |html|
|
||||||
|
root = comments["comments"].as_a
|
||||||
root = comments["comments"].as_a
|
root.each do |child|
|
||||||
root.each do |child|
|
if child["replies"]?
|
||||||
if child["replies"]?
|
replies_html = <<-END_HTML
|
||||||
replies_html = <<-END_HTML
|
<div id="replies" class="pure-g">
|
||||||
<div id="replies" class="pure-g">
|
<div class="pure-u-1-24"></div>
|
||||||
<div class="pure-u-1-24"></div>
|
<div class="pure-u-23-24">
|
||||||
<div class="pure-u-23-24">
|
<p>
|
||||||
<p>
|
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
|
||||||
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
|
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a>
|
||||||
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", child["replies"]["replyCount"].to_s)}</a>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
END_HTML
|
||||||
END_HTML
|
|
||||||
end
|
|
||||||
|
|
||||||
if !thin_mode
|
|
||||||
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
|
|
||||||
else
|
|
||||||
author_thumbnail = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
html += <<-END_HTML
|
|
||||||
<div class="pure-g">
|
|
||||||
<div class="pure-u-4-24 pure-u-md-2-24">
|
|
||||||
<img style="width:90%;padding-right:1em;padding-top:1em" src="#{author_thumbnail}">
|
|
||||||
</div>
|
|
||||||
<div class="pure-u-20-24 pure-u-md-22-24">
|
|
||||||
<p>
|
|
||||||
<b>
|
|
||||||
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
|
|
||||||
</b>
|
|
||||||
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
|
|
||||||
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), 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 comment permalink")}">[YT]</a>
|
|
||||||
|
|
|
||||||
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
|
|
||||||
END_HTML
|
|
||||||
|
|
||||||
if child["creatorHeart"]?
|
|
||||||
if !thin_mode
|
|
||||||
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).full_path}"
|
|
||||||
else
|
|
||||||
creator_thumbnail = ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
html += <<-END_HTML
|
if !thin_mode
|
||||||
|
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
|
||||||
|
else
|
||||||
|
author_thumbnail = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<div class="pure-g" style="width:100%">
|
||||||
|
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
|
||||||
|
<img style="padding-right:1em;padding-top:1em;width:90%" src="#{author_thumbnail}">
|
||||||
|
</div>
|
||||||
|
<div class="pure-u-20-24 pure-u-md-22-24">
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
|
||||||
|
</b>
|
||||||
|
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
if child["attachment"]?
|
||||||
|
attachment = child["attachment"]
|
||||||
|
|
||||||
|
case attachment["type"]
|
||||||
|
when "image"
|
||||||
|
attachment = attachment["imageThumbnails"][1]
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<div class="pure-g">
|
||||||
|
<div class="pure-u-1 pure-u-md-1-2">
|
||||||
|
<img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).full_path}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
END_HTML
|
||||||
|
when "video"
|
||||||
|
html << <<-END_HTML
|
||||||
|
<div class="pure-g">
|
||||||
|
<div class="pure-u-1 pure-u-md-1-2">
|
||||||
|
<div style="position:relative;width:100%;height:0;padding-bottom:56.25%;margin-bottom:5px">
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
if attachment["error"]?
|
||||||
|
html << <<-END_HTML
|
||||||
|
<p>#{attachment["error"]}</p>
|
||||||
|
END_HTML
|
||||||
|
else
|
||||||
|
html << <<-END_HTML
|
||||||
|
<iframe id='ivplayer' type='text/html' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' frameborder='0'></iframe>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<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>
|
||||||
|
|
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
if comments["videoId"]?
|
||||||
|
html << <<-END_HTML
|
||||||
|
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
||||||
|
|
|
||||||
|
END_HTML
|
||||||
|
elsif comments["authorId"]?
|
||||||
|
html << <<-END_HTML
|
||||||
|
<a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
||||||
|
|
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
if child["creatorHeart"]?
|
||||||
|
if !thin_mode
|
||||||
|
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).full_path}"
|
||||||
|
else
|
||||||
|
creator_thumbnail = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
|
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
|
||||||
<div class="creator-heart">
|
<div class="creator-heart">
|
||||||
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
|
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
|
||||||
@ -340,84 +388,77 @@ def template_youtube_comments(comments, locale, thin_mode)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
</p>
|
||||||
|
#{replies_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
END_HTML
|
END_HTML
|
||||||
end
|
end
|
||||||
|
|
||||||
html += <<-END_HTML
|
if comments["continuation"]?
|
||||||
</p>
|
html << <<-END_HTML
|
||||||
#{replies_html}
|
<div class="pure-g">
|
||||||
|
<div class="pure-u-1">
|
||||||
|
<p>
|
||||||
|
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
|
||||||
|
onclick="get_youtube_replies(this, true)">#{translate(locale, "Load more")}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
END_HTML
|
||||||
END_HTML
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if comments["continuation"]?
|
|
||||||
html += <<-END_HTML
|
|
||||||
<div class="pure-g">
|
|
||||||
<div class="pure-u-1">
|
|
||||||
<p>
|
|
||||||
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
|
|
||||||
onclick="get_youtube_replies(this, true)">#{translate(locale, "Load more")}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
END_HTML
|
|
||||||
end
|
|
||||||
|
|
||||||
return html
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def template_reddit_comments(root, locale)
|
def template_reddit_comments(root, locale)
|
||||||
html = ""
|
String.build do |html|
|
||||||
root.each do |child|
|
root.each do |child|
|
||||||
if child.data.is_a?(RedditComment)
|
if child.data.is_a?(RedditComment)
|
||||||
child = child.data.as(RedditComment)
|
child = child.data.as(RedditComment)
|
||||||
author = child.author
|
body_html = HTML.unescape(child.body_html)
|
||||||
score = child.score
|
|
||||||
body_html = HTML.unescape(child.body_html)
|
|
||||||
|
|
||||||
replies_html = ""
|
replies_html = ""
|
||||||
if child.replies.is_a?(RedditThing)
|
if child.replies.is_a?(RedditThing)
|
||||||
replies = child.replies.as(RedditThing)
|
replies = child.replies.as(RedditThing)
|
||||||
replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
|
replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
content = <<-END_HTML
|
if child.depth > 0
|
||||||
<p>
|
html << <<-END_HTML
|
||||||
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
|
|
||||||
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
|
|
||||||
#{translate(locale, "`x` points", number_with_separator(score))}
|
|
||||||
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
#{body_html}
|
|
||||||
#{replies_html}
|
|
||||||
</div>
|
|
||||||
END_HTML
|
|
||||||
|
|
||||||
if child.depth > 0
|
|
||||||
html += <<-END_HTML
|
|
||||||
<div class="pure-g">
|
<div class="pure-g">
|
||||||
<div class="pure-u-1-24">
|
<div class="pure-u-1-24">
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-u-23-24">
|
<div class="pure-u-23-24">
|
||||||
#{content}
|
END_HTML
|
||||||
</div>
|
else
|
||||||
</div>
|
html << <<-END_HTML
|
||||||
END_HTML
|
|
||||||
else
|
|
||||||
html += <<-END_HTML
|
|
||||||
<div class="pure-g">
|
<div class="pure-g">
|
||||||
<div class="pure-u-1">
|
<div class="pure-u-1">
|
||||||
#{content}
|
END_HTML
|
||||||
</div>
|
end
|
||||||
</div>
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<p>
|
||||||
|
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
|
||||||
|
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
|
||||||
|
#{translate(locale, "`x` points", number_with_separator(child.score))}
|
||||||
|
<span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
|
||||||
|
<a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
#{body_html}
|
||||||
|
#{replies_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
END_HTML
|
END_HTML
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return html
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def replace_links(html)
|
def replace_links(html)
|
||||||
@ -517,114 +558,111 @@ def content_to_comment_html(content)
|
|||||||
end
|
end
|
||||||
|
|
||||||
text
|
text
|
||||||
end.join.rchop('\ufeff')
|
end.join("").delete('\ufeff')
|
||||||
|
|
||||||
return comment_html
|
return comment_html
|
||||||
end
|
end
|
||||||
|
|
||||||
def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
|
def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
|
|
||||||
continuation.write(Bytes[0x12, 0x26])
|
data.write Bytes[0x12, 0x26]
|
||||||
|
|
||||||
continuation.write(Bytes[0x12, video_id.size])
|
data.write_byte 0x12
|
||||||
continuation.print(video_id)
|
VarInt.to_io(data, video_id.bytesize)
|
||||||
|
data.print video_id
|
||||||
|
|
||||||
continuation.write(Bytes[0xc0, 0x01, 0x01])
|
data.write Bytes[0xc0, 0x01, 0x01]
|
||||||
continuation.write(Bytes[0xc8, 0x01, 0x01])
|
data.write Bytes[0xc8, 0x01, 0x01]
|
||||||
continuation.write(Bytes[0xe0, 0x01, 0x01])
|
data.write Bytes[0xe0, 0x01, 0x01]
|
||||||
|
|
||||||
continuation.write(Bytes[0xa2, 0x02, 0x0d])
|
data.write Bytes[0xa2, 0x02, 0x0d]
|
||||||
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01])
|
data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
|
||||||
|
|
||||||
continuation.write(Bytes[0x40, 0x00])
|
data.write Bytes[0x40, 0x00]
|
||||||
continuation.write(Bytes[0x18, 0x06])
|
data.write Bytes[0x18, 0x06]
|
||||||
|
|
||||||
if cursor.empty?
|
if cursor.empty?
|
||||||
continuation.write(Bytes[0x32])
|
data.write Bytes[0x32]
|
||||||
continuation.write(write_var_int(video_id.size + 8))
|
VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 8)
|
||||||
|
|
||||||
continuation.write(Bytes[0x22, video_id.size + 4])
|
data.write Bytes[0x22, video_id.bytesize + 4]
|
||||||
continuation.write(Bytes[0x22, video_id.size])
|
data.write Bytes[0x22, video_id.bytesize]
|
||||||
continuation.print(video_id)
|
data.print video_id
|
||||||
|
|
||||||
case sort_by
|
case sort_by
|
||||||
when "top"
|
when "top"
|
||||||
continuation.write(Bytes[0x30, 0x00])
|
data.write Bytes[0x30, 0x00]
|
||||||
when "new", "newest"
|
when "new", "newest"
|
||||||
continuation.write(Bytes[0x30, 0x01])
|
data.write Bytes[0x30, 0x01]
|
||||||
end
|
end
|
||||||
|
|
||||||
continuation.write(Bytes[0x78, 0x02])
|
data.write(Bytes[0x78, 0x02])
|
||||||
else
|
else
|
||||||
continuation.write(Bytes[0x32])
|
data.write Bytes[0x32]
|
||||||
continuation.write(write_var_int(cursor.size + video_id.size + 11))
|
VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 11)
|
||||||
|
|
||||||
continuation.write(Bytes[0x0a])
|
data.write_byte 0x0a
|
||||||
continuation.write(write_var_int(cursor.size))
|
VarInt.to_io(data, cursor.bytesize)
|
||||||
continuation.print(cursor)
|
data.print cursor
|
||||||
|
|
||||||
continuation.write(Bytes[0x22, video_id.size + 4])
|
data.write Bytes[0x22, video_id.bytesize + 4]
|
||||||
continuation.write(Bytes[0x22, video_id.size])
|
data.write Bytes[0x22, video_id.bytesize]
|
||||||
continuation.print(video_id)
|
data.print video_id
|
||||||
|
|
||||||
case sort_by
|
case sort_by
|
||||||
when "top"
|
when "top"
|
||||||
continuation.write(Bytes[0x30, 0x00])
|
data.write Bytes[0x30, 0x00]
|
||||||
when "new", "newest"
|
when "new", "newest"
|
||||||
continuation.write(Bytes[0x30, 0x01])
|
data.write Bytes[0x30, 0x01]
|
||||||
end
|
end
|
||||||
|
|
||||||
continuation.write(Bytes[0x28, 0x14])
|
data.write Bytes[0x28, 0x14]
|
||||||
end
|
end
|
||||||
|
|
||||||
continuation.rewind
|
continuation = Base64.urlsafe_encode(data)
|
||||||
continuation = continuation.gets_to_end
|
|
||||||
|
|
||||||
continuation = Base64.urlsafe_encode(continuation.to_slice)
|
|
||||||
continuation = URI.escape(continuation)
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
return continuation
|
return continuation
|
||||||
end
|
end
|
||||||
|
|
||||||
def produce_comment_reply_continuation(video_id, ucid, comment_id)
|
def produce_comment_reply_continuation(video_id, ucid, comment_id)
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
|
|
||||||
continuation.write(Bytes[0x12, 0x26])
|
data.write Bytes[0x12, 0x26]
|
||||||
|
|
||||||
continuation.write(Bytes[0x12, video_id.size])
|
data.write_byte 0x12
|
||||||
continuation.print(video_id)
|
VarInt.to_io(data, video_id.size)
|
||||||
|
data.print video_id
|
||||||
|
|
||||||
continuation.write(Bytes[0xc0, 0x01, 0x01])
|
data.write Bytes[0xc0, 0x01, 0x01]
|
||||||
continuation.write(Bytes[0xc8, 0x01, 0x01])
|
data.write Bytes[0xc8, 0x01, 0x01]
|
||||||
continuation.write(Bytes[0xe0, 0x01, 0x01])
|
data.write Bytes[0xe0, 0x01, 0x01]
|
||||||
|
|
||||||
continuation.write(Bytes[0xa2, 0x02, 0x0d])
|
data.write Bytes[0xa2, 0x02, 0x0d]
|
||||||
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01])
|
data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
|
||||||
|
|
||||||
continuation.write(Bytes[0x40, 0x00])
|
data.write Bytes[0x40, 0x00]
|
||||||
continuation.write(Bytes[0x18, 0x06])
|
data.write Bytes[0x18, 0x06]
|
||||||
|
|
||||||
continuation.write(Bytes[0x32, ucid.size + video_id.size + comment_id.size + 16])
|
data.write(Bytes[0x32, ucid.size + video_id.size + comment_id.size + 16])
|
||||||
continuation.write(Bytes[0x1a, ucid.size + video_id.size + comment_id.size + 14])
|
data.write(Bytes[0x1a, ucid.size + video_id.size + comment_id.size + 14])
|
||||||
|
|
||||||
continuation.write(Bytes[0x12, comment_id.size])
|
data.write_byte 0x12
|
||||||
continuation.print(comment_id)
|
VarInt.to_io(data, comment_id.size)
|
||||||
|
data.print comment_id
|
||||||
|
|
||||||
continuation.write(Bytes[0x22, 0x02, 0x08, 0x00]) # ??
|
data.write(Bytes[0x22, 0x02, 0x08, 0x00]) # ??
|
||||||
|
|
||||||
continuation.write(Bytes[ucid.size + video_id.size + 7])
|
data.write(Bytes[ucid.size + video_id.size + 7])
|
||||||
continuation.write(Bytes[ucid.size])
|
data.write(Bytes[ucid.size])
|
||||||
continuation.print(ucid)
|
data.print(ucid)
|
||||||
continuation.write(Bytes[0x32, video_id.size])
|
data.write(Bytes[0x32, video_id.size])
|
||||||
continuation.print(video_id)
|
data.print(video_id)
|
||||||
continuation.write(Bytes[0x40, 0x01])
|
data.write(Bytes[0x40, 0x01])
|
||||||
continuation.write(Bytes[0x48, 0x0a])
|
data.write(Bytes[0x48, 0x0a])
|
||||||
|
|
||||||
continuation.rewind
|
continuation = Base64.urlsafe_encode(data.to_slice)
|
||||||
continuation = continuation.gets_to_end
|
|
||||||
|
|
||||||
continuation = Base64.urlsafe_encode(continuation.to_slice)
|
|
||||||
continuation = URI.escape(continuation)
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
return continuation
|
return continuation
|
||||||
|
@ -176,3 +176,41 @@ class HTTP::Client
|
|||||||
response
|
response
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# https://github.com/will/crystal-pg/pull/171
|
||||||
|
class PG::Statement < ::DB::Statement
|
||||||
|
protected def perform_query(args : Enumerable) : ResultSet
|
||||||
|
params = args.map { |arg| PQ::Param.encode(arg) }
|
||||||
|
conn = self.conn
|
||||||
|
conn.send_parse_message(@sql)
|
||||||
|
conn.send_bind_message params
|
||||||
|
conn.send_describe_portal_message
|
||||||
|
conn.send_execute_message
|
||||||
|
conn.send_sync_message
|
||||||
|
conn.expect_frame PQ::Frame::ParseComplete
|
||||||
|
conn.expect_frame PQ::Frame::BindComplete
|
||||||
|
frame = conn.read
|
||||||
|
case frame
|
||||||
|
when PQ::Frame::RowDescription
|
||||||
|
fields = frame.fields
|
||||||
|
when PQ::Frame::NoData
|
||||||
|
fields = nil
|
||||||
|
else
|
||||||
|
raise "expected RowDescription or NoData, got #{frame}"
|
||||||
|
end
|
||||||
|
ResultSet.new(self, fields)
|
||||||
|
rescue IO::Error
|
||||||
|
raise DB::ConnectionLost.new(connection)
|
||||||
|
end
|
||||||
|
|
||||||
|
protected def perform_exec(args : Enumerable) : ::DB::ExecResult
|
||||||
|
result = perform_query(args)
|
||||||
|
result.each { }
|
||||||
|
::DB::ExecResult.new(
|
||||||
|
rows_affected: result.rows_affected,
|
||||||
|
last_insert_id: 0_i64 # postgres doesn't support this
|
||||||
|
)
|
||||||
|
rescue IO::Error
|
||||||
|
raise DB::ConnectionLost.new(connection)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@ -87,12 +87,53 @@ end
|
|||||||
|
|
||||||
struct Config
|
struct Config
|
||||||
module ConfigPreferencesConverter
|
module ConfigPreferencesConverter
|
||||||
|
def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder)
|
||||||
|
value.to_yaml(yaml)
|
||||||
|
end
|
||||||
|
|
||||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Preferences
|
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Preferences
|
||||||
Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple)
|
Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder)
|
module FamilyConverter
|
||||||
value.to_yaml(yaml)
|
def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
|
||||||
|
case value
|
||||||
|
when Socket::Family::UNSPEC
|
||||||
|
yaml.scalar nil
|
||||||
|
when Socket::Family::INET
|
||||||
|
yaml.scalar "ipv4"
|
||||||
|
when Socket::Family::INET6
|
||||||
|
yaml.scalar "ipv6"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
|
||||||
|
if node.is_a?(YAML::Nodes::Scalar)
|
||||||
|
case node.value.downcase
|
||||||
|
when "ipv4"
|
||||||
|
Socket::Family::INET
|
||||||
|
when "ipv6"
|
||||||
|
Socket::Family::INET6
|
||||||
|
else
|
||||||
|
Socket::Family::UNSPEC
|
||||||
|
end
|
||||||
|
else
|
||||||
|
node.raise "Expected scalar, not #{node.class}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def disabled?(option)
|
||||||
|
case disabled = CONFIG.disable_proxy
|
||||||
|
when Bool
|
||||||
|
return disabled
|
||||||
|
when Array
|
||||||
|
if disabled.includes? option
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -105,7 +146,6 @@ struct Config
|
|||||||
hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||||
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
|
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
|
||||||
use_pubsub_feeds: {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
use_pubsub_feeds: {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||||
use_feed_events: {type: Bool | Int32, default: false}, # Update feeds on receiving notifications
|
|
||||||
default_home: {type: String, default: "Top"},
|
default_home: {type: String, default: "Top"},
|
||||||
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
|
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
|
||||||
top_enabled: {type: Bool, default: true},
|
top_enabled: {type: Bool, default: true},
|
||||||
@ -119,11 +159,13 @@ struct Config
|
|||||||
default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple),
|
default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple),
|
||||||
converter: ConfigPreferencesConverter,
|
converter: ConfigPreferencesConverter,
|
||||||
},
|
},
|
||||||
dmca_content: {type: Array(String), default: [] of String}, # For compliance with DMCA, disables download widget using list of video IDs
|
dmca_content: {type: Array(String), default: [] of String}, # For compliance with DMCA, disables download widget using list of video IDs
|
||||||
check_tables: {type: Bool, default: false}, # Check table integrity, automatically try to add any missing columns, create tables, etc.
|
check_tables: {type: Bool, default: false}, # Check table integrity, automatically try to add any missing columns, create tables, etc.
|
||||||
cache_annotations: {type: Bool, default: false}, # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
cache_annotations: {type: Bool, default: false}, # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
||||||
banner: {type: String?, default: nil}, # Optional banner to be displayed along top of page for announcements, etc.
|
banner: {type: String?, default: nil}, # Optional banner to be displayed along top of page for announcements, etc.
|
||||||
hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||||
|
disable_proxy: {type: Bool? | Array(String)?, default: false}, # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
|
||||||
|
force_resolve: {type: Socket::Family, default: Socket::Family::UNSPEC, converter: FamilyConverter}, # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -147,7 +189,7 @@ def rank_videos(db, n)
|
|||||||
published = rs.read(Time)
|
published = rs.read(Time)
|
||||||
|
|
||||||
# Exponential decay, older videos tend to rank lower
|
# Exponential decay, older videos tend to rank lower
|
||||||
temperature = wilson_score * Math.exp(-0.000005*((Time.now - published).total_minutes))
|
temperature = wilson_score * Math.exp(-0.000005*((Time.utc - published).total_minutes))
|
||||||
top << {temperature, id}
|
top << {temperature, id}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -161,40 +203,42 @@ def rank_videos(db, n)
|
|||||||
return top[0..n - 1]
|
return top[0..n - 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
def login_req(login_form, f_req)
|
def login_req(f_req)
|
||||||
data = {
|
data = {
|
||||||
|
# Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard
|
||||||
|
# Generally this is much longer (>1250 characters), see also
|
||||||
|
# https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb .
|
||||||
|
# For now this can be empty.
|
||||||
|
"bgRequest" => %|["identifier",""]|,
|
||||||
"pstMsg" => "1",
|
"pstMsg" => "1",
|
||||||
"checkConnection" => "youtube",
|
"checkConnection" => "youtube",
|
||||||
"checkedDomains" => "youtube",
|
"checkedDomains" => "youtube",
|
||||||
"hl" => "en",
|
"hl" => "en",
|
||||||
"deviceinfo" => %q([null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]),
|
"deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|,
|
||||||
"f.req" => f_req,
|
"f.req" => f_req,
|
||||||
"flowName" => "GlifWebSignIn",
|
"flowName" => "GlifWebSignIn",
|
||||||
"flowEntry" => "ServiceLogin",
|
"flowEntry" => "ServiceLogin",
|
||||||
|
# "cookiesDisabled" => "false",
|
||||||
|
# "gmscoreversion" => "undefined",
|
||||||
|
# "continue" => "https://accounts.google.com/ManageAccount",
|
||||||
|
# "azt" => "",
|
||||||
|
# "bgHash" => "",
|
||||||
}
|
}
|
||||||
|
|
||||||
data = login_form.merge(data)
|
|
||||||
|
|
||||||
return HTTP::Params.encode(data)
|
return HTTP::Params.encode(data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def html_to_content(description_html)
|
def html_to_content(description_html : String)
|
||||||
if !description_html
|
description = description_html.gsub(/(<br>)|(<br\/>)/, {
|
||||||
description = ""
|
"<br>": "\n",
|
||||||
description_html = ""
|
"<br/>": "\n",
|
||||||
else
|
})
|
||||||
description_html = description_html.to_s
|
|
||||||
description = description_html.gsub("<br>", "\n")
|
|
||||||
description = description.gsub("<br/>", "\n")
|
|
||||||
|
|
||||||
if description.empty?
|
if !description.empty?
|
||||||
description = ""
|
description = XML.parse_html(description).content.strip("\n ")
|
||||||
else
|
|
||||||
description = XML.parse_html(description).content.strip("\n ")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return description_html, description
|
return description
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_videos(nodeset, ucid = nil, author_name = nil)
|
def extract_videos(nodeset, ucid = nil, author_name = nil)
|
||||||
@ -231,8 +275,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
author ||= ""
|
author ||= ""
|
||||||
author_id ||= ""
|
author_id ||= ""
|
||||||
|
|
||||||
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
|
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || ""
|
||||||
description_html, description = html_to_content(description_html)
|
|
||||||
|
|
||||||
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
|
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
|
||||||
if !tile
|
if !tile
|
||||||
@ -331,7 +374,6 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
author_thumbnail: author_thumbnail,
|
author_thumbnail: author_thumbnail,
|
||||||
subscriber_count: subscriber_count,
|
subscriber_count: subscriber_count,
|
||||||
video_count: video_count,
|
video_count: video_count,
|
||||||
description: description,
|
|
||||||
description_html: description_html
|
description_html: description_html
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@ -347,7 +389,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
|
published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
published ||= Time.now
|
published ||= Time.utc
|
||||||
|
|
||||||
begin
|
begin
|
||||||
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
|
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
|
||||||
@ -397,7 +439,6 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
|||||||
ucid: author_id,
|
ucid: author_id,
|
||||||
published: published,
|
published: published,
|
||||||
views: view_count,
|
views: view_count,
|
||||||
description: description,
|
|
||||||
description_html: description_html,
|
description_html: description_html,
|
||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
live_now: live_now,
|
live_now: live_now,
|
||||||
@ -523,7 +564,7 @@ def analyze_table(db, logger, table_name, struct_type = nil)
|
|||||||
begin
|
begin
|
||||||
db.exec("SELECT * FROM #{table_name} LIMIT 0")
|
db.exec("SELECT * FROM #{table_name} LIMIT 0")
|
||||||
rescue ex
|
rescue ex
|
||||||
logger.write("CREATE TABLE #{table_name}\n")
|
logger.puts("CREATE TABLE #{table_name}")
|
||||||
|
|
||||||
db.using_connection do |conn|
|
db.using_connection do |conn|
|
||||||
conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
|
conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
|
||||||
@ -547,7 +588,7 @@ def analyze_table(db, logger, table_name, struct_type = nil)
|
|||||||
if name != column_array[i]?
|
if name != column_array[i]?
|
||||||
if !column_array[i]?
|
if !column_array[i]?
|
||||||
new_column = column_types.select { |line| line.starts_with? name }[0]
|
new_column = column_types.select { |line| line.starts_with? name }[0]
|
||||||
logger.write("ALTER TABLE #{table_name} ADD COLUMN #{new_column}\n")
|
logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
||||||
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
@ -565,26 +606,29 @@ def analyze_table(db, logger, table_name, struct_type = nil)
|
|||||||
|
|
||||||
# There's a column we didn't expect
|
# There's a column we didn't expect
|
||||||
if !new_column
|
if !new_column
|
||||||
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}\n")
|
logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
|
||||||
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
||||||
|
|
||||||
column_array = get_column_array(db, table_name)
|
column_array = get_column_array(db, table_name)
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
logger.write("ALTER TABLE #{table_name} ADD COLUMN #{new_column}\n")
|
logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
||||||
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
|
||||||
logger.write("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}\n")
|
|
||||||
|
logger.puts("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
|
||||||
db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
|
db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
|
||||||
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE\n")
|
|
||||||
|
logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
||||||
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
||||||
logger.write("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}\n")
|
|
||||||
|
logger.puts("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
|
||||||
db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
|
db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
|
||||||
|
|
||||||
column_array = get_column_array(db, table_name)
|
column_array = get_column_array(db, table_name)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE\n")
|
logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
||||||
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -635,52 +679,25 @@ def cache_annotation(db, id, annotations)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def proxy_file(response, env)
|
def create_notification_stream(env, config, kemal_config, decrypt_function, topics, connection_channel)
|
||||||
if !response.body_io?
|
connection = Channel(PQ::Notification).new(8)
|
||||||
return
|
connection_channel.send({true, connection})
|
||||||
end
|
|
||||||
|
|
||||||
if response.headers.includes_word?("Content-Encoding", "gzip")
|
|
||||||
Gzip::Writer.open(env.response) do |deflate|
|
|
||||||
copy_in_chunks(response.body_io, deflate)
|
|
||||||
end
|
|
||||||
elsif response.headers.includes_word?("Content-Encoding", "deflate")
|
|
||||||
Flate::Writer.open(env.response) do |deflate|
|
|
||||||
copy_in_chunks(response.body_io, deflate)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
copy_in_chunks(response.body_io, env.response)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# https://stackoverflow.com/a/44802810 <3
|
|
||||||
def copy_in_chunks(input, output, chunk_size = 4096)
|
|
||||||
size = 1
|
|
||||||
while size > 0
|
|
||||||
size = IO.copy(input, output, chunk_size)
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_notification_stream(env, proxies, config, kemal_config, decrypt_function, topics)
|
|
||||||
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
|
||||||
|
|
||||||
env.response.content_type = "text/event-stream"
|
|
||||||
|
|
||||||
since = env.params.query["since"]?.try &.to_i?
|
since = env.params.query["since"]?.try &.to_i?
|
||||||
|
id = 0
|
||||||
|
|
||||||
begin
|
if topics.includes? "debug"
|
||||||
id = 0
|
spawn do
|
||||||
|
begin
|
||||||
if topics.includes? "debug"
|
|
||||||
spawn do
|
|
||||||
loop do
|
loop do
|
||||||
time_span = [0, 0, 0, 0]
|
time_span = [0, 0, 0, 0]
|
||||||
time_span[rand(4)] = rand(30) + 5
|
time_span[rand(4)] = rand(30) + 5
|
||||||
published = Time.now - Time::Span.new(time_span[0], time_span[1], time_span[2], time_span[3])
|
published = Time.utc - Time::Span.new(time_span[0], time_span[1], time_span[2], time_span[3])
|
||||||
video_id = TEST_IDS[rand(TEST_IDS.size)]
|
video_id = TEST_IDS[rand(TEST_IDS.size)]
|
||||||
|
|
||||||
video = get_video(video_id, PG_DB, proxies)
|
video = get_video(video_id, PG_DB)
|
||||||
video.published = published
|
video.published = published
|
||||||
response = JSON.parse(video.to_json(locale, config, kemal_config, decrypt_function))
|
response = JSON.parse(video.to_json(locale, config, kemal_config, decrypt_function))
|
||||||
|
|
||||||
@ -701,11 +718,15 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
|
|||||||
id += 1
|
id += 1
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
|
rescue ex
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
|
begin
|
||||||
if since
|
if since
|
||||||
topics.try &.each do |topic|
|
topics.try &.each do |topic|
|
||||||
case topic
|
case topic
|
||||||
@ -735,14 +756,24 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
begin
|
||||||
|
loop do
|
||||||
|
event = connection.receive
|
||||||
|
|
||||||
PG.connect_listen(PG_URL, "notifications") do |event|
|
|
||||||
notification = JSON.parse(event.payload)
|
notification = JSON.parse(event.payload)
|
||||||
topic = notification["topic"].as_s
|
topic = notification["topic"].as_s
|
||||||
video_id = notification["videoId"].as_s
|
video_id = notification["videoId"].as_s
|
||||||
published = notification["published"].as_i64
|
published = notification["published"].as_i64
|
||||||
|
|
||||||
video = get_video(video_id, PG_DB, proxies)
|
if !topics.try &.includes? topic
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
video = get_video(video_id, PG_DB)
|
||||||
video.published = Time.unix(published)
|
video.published = Time.unix(published)
|
||||||
response = JSON.parse(video.to_json(locale, config, Kemal.config, decrypt_function))
|
response = JSON.parse(video.to_json(locale, config, Kemal.config, decrypt_function))
|
||||||
|
|
||||||
@ -755,24 +786,114 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if topics.try &.includes? topic
|
env.response.puts "id: #{id}"
|
||||||
env.response.puts "id: #{id}"
|
env.response.puts "data: #{response.to_json}"
|
||||||
env.response.puts "data: #{response.to_json}"
|
env.response.puts
|
||||||
env.response.puts
|
env.response.flush
|
||||||
env.response.flush
|
|
||||||
|
|
||||||
id += 1
|
id += 1
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
rescue ex
|
||||||
|
ensure
|
||||||
|
connection_channel.send({false, connection})
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
# Send heartbeat
|
# Send heartbeat
|
||||||
loop do
|
loop do
|
||||||
env.response.puts ":keepalive #{Time.now.to_unix}"
|
env.response.puts ":keepalive #{Time.utc.to_unix}"
|
||||||
env.response.puts
|
env.response.puts
|
||||||
env.response.flush
|
env.response.flush
|
||||||
sleep (20 + rand(11)).seconds
|
sleep (20 + rand(11)).seconds
|
||||||
end
|
end
|
||||||
rescue
|
rescue ex
|
||||||
|
ensure
|
||||||
|
connection_channel.send({false, connection})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_initial_data(body)
|
||||||
|
initial_data = body.match(/window\["ytInitialData"\] = (?<info>.*?);\n/).try &.["info"] || "{}"
|
||||||
|
if initial_data.starts_with?("JSON.parse(\"")
|
||||||
|
return JSON.parse(JSON.parse(%({"initial_data":"#{initial_data[12..-3]}"}))["initial_data"].as_s)
|
||||||
|
else
|
||||||
|
return JSON.parse(initial_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def proxy_file(response, env)
|
||||||
|
if response.headers.includes_word?("Content-Encoding", "gzip")
|
||||||
|
Gzip::Writer.open(env.response) do |deflate|
|
||||||
|
response.pipe(deflate)
|
||||||
|
end
|
||||||
|
elsif response.headers.includes_word?("Content-Encoding", "deflate")
|
||||||
|
Flate::Writer.open(env.response) do |deflate|
|
||||||
|
response.pipe(deflate)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
response.pipe(env.response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class HTTP::Client::Response
|
||||||
|
def pipe(io)
|
||||||
|
HTTP.serialize_body(io, headers, @body, @body_io, @version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Supports serialize_body without first writing headers
|
||||||
|
module HTTP
|
||||||
|
def self.serialize_body(io, headers, body, body_io, version)
|
||||||
|
if body
|
||||||
|
io << body
|
||||||
|
elsif body_io
|
||||||
|
content_length = content_length(headers)
|
||||||
|
if content_length
|
||||||
|
copied = IO.copy(body_io, io)
|
||||||
|
if copied != content_length
|
||||||
|
raise ArgumentError.new("Content-Length header is #{content_length} but body had #{copied} bytes")
|
||||||
|
end
|
||||||
|
elsif Client::Response.supports_chunked?(version)
|
||||||
|
headers["Transfer-Encoding"] = "chunked"
|
||||||
|
serialize_chunked_body(io, body_io)
|
||||||
|
else
|
||||||
|
io << body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class HTTP::Client
|
||||||
|
property family : Socket::Family = Socket::Family::UNSPEC
|
||||||
|
|
||||||
|
private def socket
|
||||||
|
socket = @socket
|
||||||
|
return socket if socket
|
||||||
|
|
||||||
|
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
|
||||||
|
socket = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family
|
||||||
|
socket.read_timeout = @read_timeout if @read_timeout
|
||||||
|
socket.sync = false
|
||||||
|
|
||||||
|
{% if !flag?(:without_openssl) %}
|
||||||
|
if tls = @tls
|
||||||
|
socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: @host)
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
@socket = socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TCPSocket
|
||||||
|
def initialize(host, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC)
|
||||||
|
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
|
||||||
|
super(addrinfo.family, addrinfo.type, addrinfo.protocol)
|
||||||
|
connect(addrinfo, timeout: connect_timeout) do |error|
|
||||||
|
close
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -22,12 +22,12 @@ def refresh_channels(db, logger, config)
|
|||||||
begin
|
begin
|
||||||
channel = fetch_channel(id, db, config.full_refresh)
|
channel = fetch_channel(id, db, config.full_refresh)
|
||||||
|
|
||||||
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.now, channel.author, id)
|
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
|
||||||
rescue ex
|
rescue ex
|
||||||
if ex.message == "Deleted or invalid channel"
|
if ex.message == "Deleted or invalid channel"
|
||||||
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.now, id)
|
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
|
||||||
end
|
end
|
||||||
logger.write("#{id} : #{ex.message}\n")
|
logger.puts("#{id} : #{ex.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
active_channel.send(true)
|
active_channel.send(true)
|
||||||
@ -36,6 +36,7 @@ def refresh_channels(db, logger, config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -43,66 +44,6 @@ def refresh_channels(db, logger, config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def refresh_feeds(db, logger, config)
|
def refresh_feeds(db, logger, config)
|
||||||
# Spawn thread to handle feed events
|
|
||||||
if config.use_feed_events
|
|
||||||
case config.use_feed_events
|
|
||||||
when Bool
|
|
||||||
max_feed_event_threads = config.use_feed_events.as(Bool).to_unsafe
|
|
||||||
when Int32
|
|
||||||
max_feed_event_threads = config.use_feed_events.as(Int32)
|
|
||||||
end
|
|
||||||
max_feed_event_channel = Channel(Int32).new
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
queue = Deque(String).new(30)
|
|
||||||
PG.connect_listen(PG_URL, "feeds") do |event|
|
|
||||||
if !queue.includes? event.payload
|
|
||||||
queue << event.payload
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
max_threads = max_feed_event_channel.receive
|
|
||||||
active_threads = 0
|
|
||||||
active_channel = Channel(Bool).new
|
|
||||||
|
|
||||||
loop do
|
|
||||||
until queue.empty?
|
|
||||||
event = queue.shift
|
|
||||||
|
|
||||||
if active_threads >= max_threads
|
|
||||||
if active_channel.receive
|
|
||||||
active_threads -= 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
active_threads += 1
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
begin
|
|
||||||
feed = JSON.parse(event)
|
|
||||||
email = feed["email"].as_s
|
|
||||||
action = feed["action"].as_s
|
|
||||||
|
|
||||||
view_name = "subscriptions_#{sha256(email)}"
|
|
||||||
|
|
||||||
case action
|
|
||||||
when "refresh"
|
|
||||||
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
end
|
|
||||||
|
|
||||||
active_channel.send(true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
sleep 5.seconds
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
max_feed_event_channel.send(max_feed_event_threads.as(Int32))
|
|
||||||
end
|
|
||||||
|
|
||||||
max_channel = Channel(Int32).new
|
max_channel = Channel(Int32).new
|
||||||
spawn do
|
spawn do
|
||||||
max_threads = max_channel.receive
|
max_threads = max_channel.receive
|
||||||
@ -110,7 +51,7 @@ def refresh_feeds(db, logger, config)
|
|||||||
active_channel = Channel(Bool).new
|
active_channel = Channel(Bool).new
|
||||||
|
|
||||||
loop do
|
loop do
|
||||||
db.query("SELECT email FROM users") do |rs|
|
db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
email = rs.read(String)
|
email = rs.read(String)
|
||||||
view_name = "subscriptions_#{sha256(email)}"
|
view_name = "subscriptions_#{sha256(email)}"
|
||||||
@ -128,33 +69,37 @@ def refresh_feeds(db, logger, config)
|
|||||||
column_array = get_column_array(db, view_name)
|
column_array = get_column_array(db, view_name)
|
||||||
ChannelVideo.to_type_tuple.each_with_index do |name, i|
|
ChannelVideo.to_type_tuple.each_with_index do |name, i|
|
||||||
if name != column_array[i]?
|
if name != column_array[i]?
|
||||||
logger.write("DROP MATERIALIZED VIEW #{view_name}\n")
|
logger.puts("DROP MATERIALIZED VIEW #{view_name}")
|
||||||
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||||
raise "view does not exist"
|
raise "view does not exist"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
|
||||||
|
logger.puts("Materialized view #{view_name} is out-of-date, recreating...")
|
||||||
|
db.exec("DROP MATERIALIZED VIEW #{view_name}")
|
||||||
|
end
|
||||||
|
|
||||||
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
|
||||||
|
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||||
rescue ex
|
rescue ex
|
||||||
# Rename old views
|
# Rename old views
|
||||||
begin
|
begin
|
||||||
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
|
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
|
||||||
|
|
||||||
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
|
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
|
||||||
logger.write("RENAME MATERIALIZED VIEW #{legacy_view_name}\n")
|
logger.puts("RENAME MATERIALIZED VIEW #{legacy_view_name}")
|
||||||
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
|
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
|
||||||
rescue ex
|
rescue ex
|
||||||
begin
|
begin
|
||||||
# While iterating through, we may have an email stored from a deleted account
|
# While iterating through, we may have an email stored from a deleted account
|
||||||
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
||||||
logger.write("CREATE #{view_name}\n")
|
logger.puts("CREATE #{view_name}")
|
||||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
|
||||||
SELECT * FROM channel_videos WHERE \
|
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
|
|
||||||
ORDER BY published DESC;")
|
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
logger.write("REFRESH #{email} : #{ex.message}\n")
|
logger.puts("REFRESH #{email} : #{ex.message}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -164,7 +109,8 @@ def refresh_feeds(db, logger, config)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 5.seconds
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -204,7 +150,7 @@ def subscribe_to_feeds(db, logger, key, config)
|
|||||||
response = subscribe_pubsub(ucid, key, config)
|
response = subscribe_pubsub(ucid, key, config)
|
||||||
|
|
||||||
if response.status_code >= 400
|
if response.status_code >= 400
|
||||||
logger.write("#{ucid} : #{response.body}\n")
|
logger.puts("#{ucid} : #{response.body}")
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
@ -215,6 +161,7 @@ def subscribe_to_feeds(db, logger, key, config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -227,12 +174,16 @@ def pull_top_videos(config, db)
|
|||||||
begin
|
begin
|
||||||
top = rank_videos(db, 40)
|
top = rank_videos(db, 40)
|
||||||
rescue ex
|
rescue ex
|
||||||
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
if top.size > 0
|
if top.size == 0
|
||||||
args = arg_array(top)
|
sleep 1.minute
|
||||||
else
|
Fiber.yield
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -247,22 +198,23 @@ def pull_top_videos(config, db)
|
|||||||
end
|
end
|
||||||
|
|
||||||
yield videos
|
yield videos
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def pull_popular_videos(db)
|
def pull_popular_videos(db)
|
||||||
loop do
|
loop do
|
||||||
subscriptions = db.query_all("SELECT channel FROM \
|
videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE ucid IN \
|
||||||
(SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
|
(SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
|
||||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String)
|
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) \
|
||||||
|
ORDER BY ucid, published DESC", as: ChannelVideo).sort_by { |video| video.published }.reverse
|
||||||
videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM \
|
|
||||||
channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \
|
|
||||||
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
|
|
||||||
|
|
||||||
yield videos
|
yield videos
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -270,12 +222,13 @@ def update_decrypt_function
|
|||||||
loop do
|
loop do
|
||||||
begin
|
begin
|
||||||
decrypt_function = fetch_decrypt_function
|
decrypt_function = fetch_decrypt_function
|
||||||
|
yield decrypt_function
|
||||||
rescue ex
|
rescue ex
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
yield decrypt_function
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -290,5 +243,6 @@ def find_working_proxies(regions)
|
|||||||
end
|
end
|
||||||
|
|
||||||
sleep 1.minute
|
sleep 1.minute
|
||||||
|
Fiber.yield
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
require "logger"
|
require "logger"
|
||||||
|
|
||||||
|
enum LogLevel
|
||||||
|
Debug
|
||||||
|
Info
|
||||||
|
Warn
|
||||||
|
Error
|
||||||
|
end
|
||||||
|
|
||||||
class Invidious::LogHandler < Kemal::BaseLogHandler
|
class Invidious::LogHandler < Kemal::BaseLogHandler
|
||||||
def initialize(@io : IO = STDOUT)
|
def initialize(@io : IO = STDOUT, @level = LogLevel::Warn)
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
time = Time.now
|
time = Time.utc
|
||||||
call_next(context)
|
call_next(context)
|
||||||
elapsed_text = elapsed_text(Time.now - time)
|
elapsed_text = elapsed_text(Time.utc - time)
|
||||||
|
|
||||||
@io << time << ' ' << context.response.status_code << ' ' << context.request.method << ' ' << context.request.resource << ' ' << elapsed_text << '\n'
|
@io << time << ' ' << context.response.status_code << ' ' << context.request.method << ' ' << context.request.resource << ' ' << elapsed_text << '\n'
|
||||||
|
|
||||||
@ -18,7 +25,15 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
|
|||||||
context
|
context
|
||||||
end
|
end
|
||||||
|
|
||||||
def write(message : String)
|
def puts(message : String)
|
||||||
|
@io << message << '\n'
|
||||||
|
|
||||||
|
if @io.is_a? File
|
||||||
|
@io.flush
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(message : String, level = @level)
|
||||||
@io << message
|
@io << message
|
||||||
|
|
||||||
if @io.is_a? File
|
if @io.is_a? File
|
||||||
@ -26,6 +41,29 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_log_level(level : String)
|
||||||
|
case level.downcase
|
||||||
|
when "debug"
|
||||||
|
set_log_level(LogLevel::Debug)
|
||||||
|
when "info"
|
||||||
|
set_log_level(LogLevel::Info)
|
||||||
|
when "warn"
|
||||||
|
set_log_level(LogLevel::Warn)
|
||||||
|
when "error"
|
||||||
|
set_log_level(LogLevel::Error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_log_level(level : LogLevel)
|
||||||
|
@level = level
|
||||||
|
end
|
||||||
|
|
||||||
|
{% for level in %w(debug info warn error) %}
|
||||||
|
def {{level.id}}(message : String)
|
||||||
|
puts(message, LogLevel::{{level.id.capitalize}})
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
private def elapsed_text(elapsed)
|
private def elapsed_text(elapsed)
|
||||||
millis = elapsed.total_milliseconds
|
millis = elapsed.total_milliseconds
|
||||||
return "#{millis.round(2)}ms" if millis >= 1
|
return "#{millis.round(2)}ms" if millis >= 1
|
||||||
|
194
src/invidious/helpers/static_file_handler.cr
Normal file
194
src/invidious/helpers/static_file_handler.cr
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Since systems have a limit on number of open files (`ulimit -a`),
|
||||||
|
# we serve them from memory to avoid 'Too many open files' without needing
|
||||||
|
# to modify ulimit.
|
||||||
|
#
|
||||||
|
# Very heavily re-used:
|
||||||
|
# https://github.com/kemalcr/kemal/blob/master/src/kemal/helpers/helpers.cr
|
||||||
|
# https://github.com/kemalcr/kemal/blob/master/src/kemal/static_file_handler.cr
|
||||||
|
#
|
||||||
|
# Changes:
|
||||||
|
# - A `send_file` overload is added which supports sending a Slice, file_path, filestat
|
||||||
|
# - `StaticFileHandler` is patched to cache to and serve from @cached_files
|
||||||
|
|
||||||
|
private def multipart(file, env : HTTP::Server::Context)
|
||||||
|
# See http://httpwg.org/specs/rfc7233.html
|
||||||
|
fileb = file.size
|
||||||
|
startb = endb = 0
|
||||||
|
|
||||||
|
if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/
|
||||||
|
startb = match[1].to_i { 0 } if match.size >= 2
|
||||||
|
endb = match[2].to_i { 0 } if match.size >= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
endb = fileb - 1 if endb == 0
|
||||||
|
|
||||||
|
if startb < endb < fileb
|
||||||
|
content_length = 1 + endb - startb
|
||||||
|
env.response.status_code = 206
|
||||||
|
env.response.content_length = content_length
|
||||||
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
|
||||||
|
|
||||||
|
if startb > 1024
|
||||||
|
skipped = 0
|
||||||
|
# file.skip only accepts values less or equal to 1024 (buffer size, undocumented)
|
||||||
|
until (increase_skipped = skipped + 1024) > startb
|
||||||
|
file.skip(1024)
|
||||||
|
skipped = increase_skipped
|
||||||
|
end
|
||||||
|
if (skipped_minus_startb = skipped - startb) > 0
|
||||||
|
file.skip skipped_minus_startb
|
||||||
|
end
|
||||||
|
else
|
||||||
|
file.skip(startb)
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.copy(file, env.response, content_length)
|
||||||
|
else
|
||||||
|
env.response.content_length = fileb
|
||||||
|
env.response.status_code = 200 # Range not satisfable, see 4.4 Note
|
||||||
|
IO.copy(file, env.response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the Content-Disposition to "attachment" with the specified filename,
|
||||||
|
# instructing the user agents to prompt to save.
|
||||||
|
private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil)
|
||||||
|
disposition = "attachment" if disposition.nil? && filename
|
||||||
|
if disposition && filename
|
||||||
|
env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt8), filestat : File::Info, filename : String? = nil, disposition : String? = nil)
|
||||||
|
config = Kemal.config.serve_static
|
||||||
|
mime_type = MIME.from_filename(file_path, "application/octet-stream")
|
||||||
|
env.response.content_type = mime_type
|
||||||
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ??
|
||||||
|
request_headers = env.request.headers
|
||||||
|
filesize = data.bytesize
|
||||||
|
attachment(env, filename, disposition)
|
||||||
|
|
||||||
|
Kemal.config.static_headers.try(&.call(env.response, file_path, filestat))
|
||||||
|
|
||||||
|
file = IO::Memory.new(data)
|
||||||
|
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
||||||
|
return multipart(file, env)
|
||||||
|
end
|
||||||
|
|
||||||
|
condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path)
|
||||||
|
if condition && request_headers.includes_word?("Accept-Encoding", "gzip")
|
||||||
|
env.response.headers["Content-Encoding"] = "gzip"
|
||||||
|
Gzip::Writer.open(env.response) do |deflate|
|
||||||
|
IO.copy(file, deflate)
|
||||||
|
end
|
||||||
|
elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate")
|
||||||
|
env.response.headers["Content-Encoding"] = "deflate"
|
||||||
|
Flate::Writer.open(env.response) do |deflate|
|
||||||
|
IO.copy(file, deflate)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
env.response.content_length = filesize
|
||||||
|
IO.copy(file, env.response)
|
||||||
|
end
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
module Kemal
|
||||||
|
class StaticFileHandler < HTTP::StaticFileHandler
|
||||||
|
CACHE_LIMIT = 5_000_000 # 5MB
|
||||||
|
@cached_files = {} of String => {data: Bytes, filestat: File::Info}
|
||||||
|
|
||||||
|
def call(context : HTTP::Server::Context)
|
||||||
|
return call_next(context) if context.request.path.not_nil! == "/"
|
||||||
|
|
||||||
|
case context.request.method
|
||||||
|
when "GET", "HEAD"
|
||||||
|
else
|
||||||
|
if @fallthrough
|
||||||
|
call_next(context)
|
||||||
|
else
|
||||||
|
context.response.status_code = 405
|
||||||
|
context.response.headers.add("Allow", "GET, HEAD")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
config = Kemal.config.serve_static
|
||||||
|
original_path = context.request.path.not_nil!
|
||||||
|
request_path = URI.unescape(original_path)
|
||||||
|
|
||||||
|
# File path cannot contains '\0' (NUL) because all filesystem I know
|
||||||
|
# don't accept '\0' character as file name.
|
||||||
|
if request_path.includes? '\0'
|
||||||
|
context.response.status_code = 400
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
expanded_path = File.expand_path(request_path, "/")
|
||||||
|
is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/'
|
||||||
|
expanded_path = expanded_path + '/'
|
||||||
|
true
|
||||||
|
else
|
||||||
|
expanded_path.ends_with? '/'
|
||||||
|
end
|
||||||
|
|
||||||
|
file_path = File.join(@public_dir, expanded_path)
|
||||||
|
|
||||||
|
if file = @cached_files[file_path]?
|
||||||
|
last_modified = file[:filestat].modification_time
|
||||||
|
add_cache_headers(context.response.headers, last_modified)
|
||||||
|
|
||||||
|
if cache_request?(context, last_modified)
|
||||||
|
context.response.status_code = 304
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
send_file(context, file_path, file[:data], file[:filestat])
|
||||||
|
else
|
||||||
|
is_dir = Dir.exists? file_path
|
||||||
|
|
||||||
|
if request_path != expanded_path
|
||||||
|
redirect_to context, expanded_path
|
||||||
|
elsif is_dir && !is_dir_path
|
||||||
|
redirect_to context, expanded_path + '/'
|
||||||
|
end
|
||||||
|
|
||||||
|
if Dir.exists?(file_path)
|
||||||
|
if config.is_a?(Hash) && config["dir_listing"] == true
|
||||||
|
context.response.content_type = "text/html"
|
||||||
|
directory_listing(context.response, request_path, file_path)
|
||||||
|
else
|
||||||
|
call_next(context)
|
||||||
|
end
|
||||||
|
elsif File.exists?(file_path)
|
||||||
|
last_modified = modification_time(file_path)
|
||||||
|
add_cache_headers(context.response.headers, last_modified)
|
||||||
|
|
||||||
|
if cache_request?(context, last_modified)
|
||||||
|
context.response.status_code = 304
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @cached_files.sum { |element| element[1][:data].bytesize } + (size = File.size(file_path)) < CACHE_LIMIT
|
||||||
|
data = Bytes.new(size)
|
||||||
|
File.open(file_path) do |file|
|
||||||
|
file.read(data)
|
||||||
|
end
|
||||||
|
filestat = File.info(file_path)
|
||||||
|
|
||||||
|
@cached_files[file_path] = {data: data, filestat: filestat}
|
||||||
|
send_file(context, file_path, data, filestat)
|
||||||
|
else
|
||||||
|
send_file(context, file_path)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
call_next(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -1,6 +1,6 @@
|
|||||||
def generate_token(email, scopes, expire, key, db)
|
def generate_token(email, scopes, expire, key, db)
|
||||||
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
|
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
|
||||||
PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.now)
|
PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc)
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
"session" => session,
|
"session" => session,
|
||||||
@ -18,7 +18,7 @@ def generate_token(email, scopes, expire, key, db)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
|
def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
|
||||||
expire = Time.now + expire
|
expire = Time.utc + expire
|
||||||
|
|
||||||
token = {
|
token = {
|
||||||
"session" => session,
|
"session" => session,
|
||||||
@ -85,8 +85,8 @@ def validate_request(token, session, request, key, db, locale = nil)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
|
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
|
||||||
if nonce[1] > Time.now
|
if nonce[1] > Time.utc
|
||||||
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
|
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
|
||||||
else
|
else
|
||||||
raise translate(locale, "Erroneous token")
|
raise translate(locale, "Erroneous token")
|
||||||
end
|
end
|
||||||
@ -100,7 +100,7 @@ def validate_request(token, session, request, key, db, locale = nil)
|
|||||||
end
|
end
|
||||||
|
|
||||||
expire = token["expire"]?.try &.as_i
|
expire = token["expire"]?.try &.as_i
|
||||||
if expire.try &.< Time.now.to_unix
|
if expire.try &.< Time.utc.to_unix
|
||||||
raise translate(locale, "Token is expired, please try again")
|
raise translate(locale, "Token is expired, please try again")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -18,24 +18,14 @@ def elapsed_text(elapsed)
|
|||||||
"#{(millis * 1000).round(2)}µs"
|
"#{(millis * 1000).round(2)}µs"
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_client(url : URI, proxies = {} of String => Array({ip: String, port: Int32}), region = nil)
|
def make_client(url : URI, region = nil)
|
||||||
context = nil
|
client = HTTPClient.new(url)
|
||||||
|
client.family = CONFIG.force_resolve
|
||||||
if url.scheme == "https"
|
client.read_timeout = 15.seconds
|
||||||
context = OpenSSL::SSL::Context::Client.new
|
client.connect_timeout = 15.seconds
|
||||||
context.add_options(
|
|
||||||
OpenSSL::SSL::Options::ALL |
|
|
||||||
OpenSSL::SSL::Options::NO_SSL_V2 |
|
|
||||||
OpenSSL::SSL::Options::NO_SSL_V3
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
client = HTTPClient.new(url, context)
|
|
||||||
client.read_timeout = 10.seconds
|
|
||||||
client.connect_timeout = 10.seconds
|
|
||||||
|
|
||||||
if region
|
if region
|
||||||
proxies[region]?.try &.sample(40).each do |proxy|
|
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||||
begin
|
begin
|
||||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||||
client.set_proxy(proxy)
|
client.set_proxy(proxy)
|
||||||
@ -90,7 +80,7 @@ def decode_time(string)
|
|||||||
millis = /(?<millis>\d+)ms/.match(string).try &.["millis"].try &.to_f
|
millis = /(?<millis>\d+)ms/.match(string).try &.["millis"].try &.to_f
|
||||||
millis ||= 0
|
millis ||= 0
|
||||||
|
|
||||||
time = hours * 3600 + minutes * 60 + seconds + millis / 1000
|
time = hours * 3600 + minutes * 60 + seconds + millis // 1000
|
||||||
end
|
end
|
||||||
|
|
||||||
return time
|
return time
|
||||||
@ -99,7 +89,7 @@ end
|
|||||||
def decode_date(string : String)
|
def decode_date(string : String)
|
||||||
# String matches 'YYYY'
|
# String matches 'YYYY'
|
||||||
if string.match(/^\d{4}/)
|
if string.match(/^\d{4}/)
|
||||||
return Time.new(string.to_i, 1, 1)
|
return Time.utc(string.to_i, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try to parse as format Jul 10, 2000
|
# Try to parse as format Jul 10, 2000
|
||||||
@ -110,9 +100,9 @@ def decode_date(string : String)
|
|||||||
|
|
||||||
case string
|
case string
|
||||||
when "today"
|
when "today"
|
||||||
return Time.now
|
return Time.utc
|
||||||
when "yesterday"
|
when "yesterday"
|
||||||
return Time.now - 1.day
|
return Time.utc - 1.day
|
||||||
end
|
end
|
||||||
|
|
||||||
# String matches format "20 hours ago", "4 months ago"...
|
# String matches format "20 hours ago", "4 months ago"...
|
||||||
@ -138,18 +128,18 @@ def decode_date(string : String)
|
|||||||
raise "Could not parse #{string}"
|
raise "Could not parse #{string}"
|
||||||
end
|
end
|
||||||
|
|
||||||
return Time.now - delta
|
return Time.utc - delta
|
||||||
end
|
end
|
||||||
|
|
||||||
def recode_date(time : Time, locale)
|
def recode_date(time : Time, locale)
|
||||||
span = Time.now - time
|
span = Time.utc - time
|
||||||
|
|
||||||
if span.total_days > 365.0
|
if span.total_days > 365.0
|
||||||
span = translate(locale, "`x` years", (span.total_days.to_i / 365).to_s)
|
span = translate(locale, "`x` years", (span.total_days.to_i // 365).to_s)
|
||||||
elsif span.total_days > 30.0
|
elsif span.total_days > 30.0
|
||||||
span = translate(locale, "`x` months", (span.total_days.to_i / 30).to_s)
|
span = translate(locale, "`x` months", (span.total_days.to_i // 30).to_s)
|
||||||
elsif span.total_days > 7.0
|
elsif span.total_days > 7.0
|
||||||
span = translate(locale, "`x` weeks", (span.total_days.to_i / 7).to_s)
|
span = translate(locale, "`x` weeks", (span.total_days.to_i // 7).to_s)
|
||||||
elsif span.total_hours > 24.0
|
elsif span.total_hours > 24.0
|
||||||
span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
|
span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
|
||||||
elsif span.total_minutes > 60.0
|
elsif span.total_minutes > 60.0
|
||||||
@ -194,11 +184,11 @@ def number_to_short_text(number)
|
|||||||
|
|
||||||
text = text.rchop(".0")
|
text = text.rchop(".0")
|
||||||
|
|
||||||
if number / 1_000_000_000 != 0
|
if number // 1_000_000_000 != 0
|
||||||
text += "B"
|
text += "B"
|
||||||
elsif number / 1_000_000 != 0
|
elsif number // 1_000_000 != 0
|
||||||
text += "M"
|
text += "M"
|
||||||
elsif number / 1000 != 0
|
elsif number // 1000 != 0
|
||||||
text += "K"
|
text += "K"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -243,7 +233,7 @@ def make_host_url(config, kemal_config)
|
|||||||
return "#{scheme}#{host}#{port}"
|
return "#{scheme}#{host}#{port}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_referer(env, fallback = "/")
|
def get_referer(env, fallback = "/", unroll = true)
|
||||||
referer = env.params.query["referer"]?
|
referer = env.params.query["referer"]?
|
||||||
referer ||= env.request.headers["referer"]?
|
referer ||= env.request.headers["referer"]?
|
||||||
referer ||= fallback
|
referer ||= fallback
|
||||||
@ -251,16 +241,18 @@ def get_referer(env, fallback = "/")
|
|||||||
referer = URI.parse(referer)
|
referer = URI.parse(referer)
|
||||||
|
|
||||||
# "Unroll" nested referrers
|
# "Unroll" nested referrers
|
||||||
loop do
|
if unroll
|
||||||
if referer.query
|
loop do
|
||||||
params = HTTP::Params.parse(referer.query.not_nil!)
|
if referer.query
|
||||||
if params["referer"]?
|
params = HTTP::Params.parse(referer.query.not_nil!)
|
||||||
referer = URI.parse(URI.unescape(params["referer"]))
|
if params["referer"]?
|
||||||
|
referer = URI.parse(URI.unescape(params["referer"]))
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
else
|
else
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -274,50 +266,40 @@ def get_referer(env, fallback = "/")
|
|||||||
return referer
|
return referer
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_var_int(bytes)
|
struct VarInt
|
||||||
num_read = 0
|
def self.from_io(io : IO, format = IO::ByteFormat::BigEndian) : Int32
|
||||||
result = 0
|
result = 0_i32
|
||||||
|
num_read = 0
|
||||||
|
|
||||||
read = bytes[num_read]
|
loop do
|
||||||
|
byte = io.read_byte
|
||||||
if bytes.size == 1
|
raise "Invalid VarInt" if !byte
|
||||||
result = bytes[0].to_i32
|
value = byte & 0x7f
|
||||||
else
|
|
||||||
while ((read & 0b10000000) != 0)
|
|
||||||
read = bytes[num_read].to_u64
|
|
||||||
value = (read & 0b01111111)
|
|
||||||
result |= (value << (7 * num_read))
|
|
||||||
|
|
||||||
|
result |= value.to_i32 << (7 * num_read)
|
||||||
num_read += 1
|
num_read += 1
|
||||||
if num_read > 5
|
|
||||||
raise "VarInt is too big"
|
break if byte & 0x80 == 0
|
||||||
end
|
raise "Invalid VarInt" if num_read > 5
|
||||||
end
|
end
|
||||||
|
|
||||||
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
return result
|
def self.to_io(io : IO, value : Int32)
|
||||||
end
|
io.write_byte 0x00 if value == 0x00
|
||||||
|
|
||||||
def write_var_int(value : Int)
|
|
||||||
bytes = [] of UInt8
|
|
||||||
value = value.to_u32
|
|
||||||
|
|
||||||
if value == 0
|
|
||||||
bytes = [0_u8]
|
|
||||||
else
|
|
||||||
while value != 0
|
while value != 0
|
||||||
temp = (value & 0b01111111).to_u8
|
byte = (value & 0x7f).to_u8
|
||||||
value = value >> 7
|
value >>= 7
|
||||||
|
|
||||||
if value != 0
|
if value != 0
|
||||||
temp |= 0b10000000
|
byte |= 0x80
|
||||||
end
|
end
|
||||||
|
|
||||||
bytes << temp
|
io.write_byte byte
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return Slice.new(bytes.to_unsafe, bytes.size)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sha256(text)
|
def sha256(text)
|
||||||
@ -325,3 +307,52 @@ def sha256(text)
|
|||||||
digest << text
|
digest << text
|
||||||
return digest.hexdigest
|
return digest.hexdigest
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def subscribe_pubsub(topic, key, config)
|
||||||
|
case topic
|
||||||
|
when .match(/^UC[A-Za-z0-9_-]{22}$/)
|
||||||
|
topic = "channel_id=#{topic}"
|
||||||
|
when .match(/^(PL|LL|EC|UU|FL|UL|OLAK5uy_)[0-9A-Za-z-_]{10,}$/)
|
||||||
|
# There's a couple missing from the above regex, namely TL and RD, which
|
||||||
|
# don't have feeds
|
||||||
|
topic = "playlist_id=#{topic}"
|
||||||
|
else
|
||||||
|
# TODO
|
||||||
|
end
|
||||||
|
|
||||||
|
client = make_client(PUBSUB_URL)
|
||||||
|
time = Time.utc.to_unix.to_s
|
||||||
|
nonce = Random::Secure.hex(4)
|
||||||
|
signature = "#{time}:#{nonce}"
|
||||||
|
|
||||||
|
host_url = make_host_url(config, Kemal.config)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
|
||||||
|
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?#{topic}",
|
||||||
|
"hub.verify" => "async",
|
||||||
|
"hub.mode" => "subscribe",
|
||||||
|
"hub.lease_seconds" => "432000",
|
||||||
|
"hub.secret" => key.to_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.post("/subscribe", form: body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_range(range)
|
||||||
|
if !range
|
||||||
|
return 0_i64, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
ranges = range.lchop("bytes=").split(',')
|
||||||
|
ranges.each do |range|
|
||||||
|
start_range, end_range = range.split('-')
|
||||||
|
|
||||||
|
start_range = start_range.to_i64? || 0_i64
|
||||||
|
end_range = end_range.to_i64?
|
||||||
|
|
||||||
|
return start_range, end_range
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0_i64, nil
|
||||||
|
end
|
||||||
|
@ -6,7 +6,7 @@ struct MixVideo
|
|||||||
ucid: String,
|
ucid: String,
|
||||||
length_seconds: Int32,
|
length_seconds: Int32,
|
||||||
index: Int32,
|
index: Int32,
|
||||||
mixes: Array(String),
|
rdid: String,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -28,18 +28,13 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
|||||||
end
|
end
|
||||||
response = client.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en&has_verified=1&bpctr=9999999999", headers)
|
response = client.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en&has_verified=1&bpctr=9999999999", headers)
|
||||||
|
|
||||||
yt_data = response.body.match(/window\["ytInitialData"\] = (?<data>.*);/)
|
initial_data = extract_initial_data(response.body)
|
||||||
if yt_data
|
|
||||||
yt_data = JSON.parse(yt_data["data"].rchop(";"))
|
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
||||||
else
|
|
||||||
raise translate(locale, "Could not create mix.")
|
raise translate(locale, "Could not create mix.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if !yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
playlist = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
|
||||||
raise translate(locale, "Could not create mix.")
|
|
||||||
end
|
|
||||||
|
|
||||||
playlist = yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
|
|
||||||
mix_title = playlist["title"].as_s
|
mix_title = playlist["title"].as_s
|
||||||
|
|
||||||
contents = playlist["contents"].as_a
|
contents = playlist["contents"].as_a
|
||||||
@ -70,7 +65,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
|||||||
ucid,
|
ucid,
|
||||||
length_seconds,
|
length_seconds,
|
||||||
index,
|
index,
|
||||||
[rdid]
|
rdid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1,4 +1,32 @@
|
|||||||
struct PlaylistVideo
|
struct PlaylistVideo
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "title", self.title
|
||||||
|
json.field "videoId", self.id
|
||||||
|
|
||||||
|
json.field "author", self.author
|
||||||
|
json.field "authorId", self.ucid
|
||||||
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, self.id, config, kemal_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "index", self.index
|
||||||
|
json.field "lengthSeconds", self.length_seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
|
||||||
|
if json
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
else
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
@ -6,7 +34,7 @@ struct PlaylistVideo
|
|||||||
ucid: String,
|
ucid: String,
|
||||||
length_seconds: Int32,
|
length_seconds: Int32,
|
||||||
published: Time,
|
published: Time,
|
||||||
playlists: Array(String),
|
plid: String,
|
||||||
index: Int32,
|
index: Int32,
|
||||||
live_now: Bool,
|
live_now: Bool,
|
||||||
})
|
})
|
||||||
@ -19,7 +47,6 @@ struct Playlist
|
|||||||
author: String,
|
author: String,
|
||||||
author_thumbnail: String,
|
author_thumbnail: String,
|
||||||
ucid: String,
|
ucid: String,
|
||||||
description: String,
|
|
||||||
description_html: String,
|
description_html: String,
|
||||||
video_count: Int32,
|
video_count: Int32,
|
||||||
views: Int64,
|
views: Int64,
|
||||||
@ -114,8 +141,8 @@ def extract_playlist(plid, nodeset, index)
|
|||||||
author: author,
|
author: author,
|
||||||
ucid: ucid,
|
ucid: ucid,
|
||||||
length_seconds: length_seconds,
|
length_seconds: length_seconds,
|
||||||
published: Time.now,
|
published: Time.utc,
|
||||||
playlists: [plid],
|
plid: plid,
|
||||||
index: index + offset,
|
index: index + offset,
|
||||||
live_now: live_now
|
live_now: live_now
|
||||||
)
|
)
|
||||||
@ -130,37 +157,44 @@ def produce_playlist_url(id, index)
|
|||||||
end
|
end
|
||||||
ucid = "VL" + id
|
ucid = "VL" + id
|
||||||
|
|
||||||
meta = IO::Memory.new
|
data = IO::Memory.new
|
||||||
meta.write(Bytes[0x08])
|
data.write_byte 0x08
|
||||||
meta.write(write_var_int(index))
|
VarInt.to_io(data, index)
|
||||||
|
|
||||||
meta.rewind
|
data.rewind
|
||||||
meta = Base64.urlsafe_encode(meta.to_slice, false)
|
data = Base64.urlsafe_encode(data, false)
|
||||||
meta = "PT:#{meta}"
|
data = "PT:#{data}"
|
||||||
|
|
||||||
continuation = IO::Memory.new
|
continuation = IO::Memory.new
|
||||||
continuation.write(Bytes[0x7a, meta.size])
|
continuation.write_byte 0x7a
|
||||||
continuation.print(meta)
|
VarInt.to_io(continuation, data.bytesize)
|
||||||
|
continuation.print data
|
||||||
|
|
||||||
continuation.rewind
|
data = Base64.urlsafe_encode(continuation)
|
||||||
meta = Base64.urlsafe_encode(continuation.to_slice)
|
cursor = URI.escape(data)
|
||||||
meta = URI.escape(meta)
|
|
||||||
|
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
continuation.write(Bytes[0x12, ucid.size])
|
|
||||||
continuation.print(ucid)
|
|
||||||
continuation.write(Bytes[0x1a, meta.size])
|
|
||||||
continuation.print(meta)
|
|
||||||
|
|
||||||
wrapper = IO::Memory.new
|
data.write_byte 0x12
|
||||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
VarInt.to_io(data, ucid.bytesize)
|
||||||
wrapper.print(continuation)
|
data.print ucid
|
||||||
wrapper.rewind
|
|
||||||
|
|
||||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
data.write_byte 0x1a
|
||||||
wrapper = URI.escape(wrapper)
|
VarInt.to_io(data, cursor.bytesize)
|
||||||
|
data.print cursor
|
||||||
|
|
||||||
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
|
data.rewind
|
||||||
|
|
||||||
|
buffer = IO::Memory.new
|
||||||
|
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
|
||||||
|
VarInt.to_io(buffer, data.bytesize)
|
||||||
|
|
||||||
|
IO.copy data, buffer
|
||||||
|
|
||||||
|
continuation = Base64.urlsafe_encode(buffer)
|
||||||
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
|
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
|
||||||
|
|
||||||
return url
|
return url
|
||||||
end
|
end
|
||||||
@ -186,9 +220,8 @@ def fetch_playlist(plid, locale)
|
|||||||
end
|
end
|
||||||
title = title.content.strip(" \n")
|
title = title.content.strip(" \n")
|
||||||
|
|
||||||
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
|
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])).try &.to_s ||
|
||||||
description_html ||= document.xpath_node(%q(//span[@class="pl-header-description-text"]))
|
document.xpath_node(%q(//span[@class="pl-header-description-text"])).try &.to_s || ""
|
||||||
description_html, description = html_to_content(description_html)
|
|
||||||
|
|
||||||
# YouTube allows anonymous playlists, so most of this can be empty or optional
|
# YouTube allows anonymous playlists, so most of this can be empty or optional
|
||||||
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
|
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
|
||||||
@ -208,7 +241,7 @@ def fetch_playlist(plid, locale)
|
|||||||
if updated
|
if updated
|
||||||
updated = decode_date(updated)
|
updated = decode_date(updated)
|
||||||
else
|
else
|
||||||
updated = Time.now
|
updated = Time.utc
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = Playlist.new(
|
playlist = Playlist.new(
|
||||||
@ -217,7 +250,6 @@ def fetch_playlist(plid, locale)
|
|||||||
author: author,
|
author: author,
|
||||||
author_thumbnail: author_thumbnail,
|
author_thumbnail: author_thumbnail,
|
||||||
ucid: ucid,
|
ucid: ucid,
|
||||||
description: description,
|
|
||||||
description_html: description_html,
|
description_html: description_html,
|
||||||
video_count: video_count,
|
video_count: video_count,
|
||||||
views: views,
|
views: views,
|
||||||
|
@ -1,4 +1,92 @@
|
|||||||
struct SearchVideo
|
struct SearchVideo
|
||||||
|
def to_xml(host_url, auto_generated, xml : XML::Builder)
|
||||||
|
xml.element("entry") do
|
||||||
|
xml.element("id") { xml.text "yt:video:#{self.id}" }
|
||||||
|
xml.element("yt:videoId") { xml.text self.id }
|
||||||
|
xml.element("yt:channelId") { xml.text self.ucid }
|
||||||
|
xml.element("title") { xml.text self.title }
|
||||||
|
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}")
|
||||||
|
|
||||||
|
xml.element("author") do
|
||||||
|
if auto_generated
|
||||||
|
xml.element("name") { xml.text self.author }
|
||||||
|
xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" }
|
||||||
|
else
|
||||||
|
xml.element("name") { xml.text author }
|
||||||
|
xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("content", type: "xhtml") do
|
||||||
|
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
|
||||||
|
xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do
|
||||||
|
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
|
||||||
|
|
||||||
|
xml.element("media:group") do
|
||||||
|
xml.element("media:title") { xml.text self.title }
|
||||||
|
xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg",
|
||||||
|
width: "320", height: "180")
|
||||||
|
xml.element("media:description") { xml.text html_to_content(self.description_html) }
|
||||||
|
end
|
||||||
|
|
||||||
|
xml.element("media:community") do
|
||||||
|
xml.element("media:statistics", views: self.views)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_xml(host_url, auto_generated, xml : XML::Builder | Nil = nil)
|
||||||
|
if xml
|
||||||
|
to_xml(host_url, auto_generated, xml)
|
||||||
|
else
|
||||||
|
XML.build do |json|
|
||||||
|
to_xml(host_url, auto_generated, xml)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "type", "video"
|
||||||
|
json.field "title", self.title
|
||||||
|
json.field "videoId", self.id
|
||||||
|
|
||||||
|
json.field "author", self.author
|
||||||
|
json.field "authorId", self.ucid
|
||||||
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, self.id, config, kemal_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "description", html_to_content(self.description_html)
|
||||||
|
json.field "descriptionHtml", self.description_html
|
||||||
|
|
||||||
|
json.field "viewCount", self.views
|
||||||
|
json.field "published", self.published.to_unix
|
||||||
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||||
|
json.field "lengthSeconds", self.length_seconds
|
||||||
|
json.field "liveNow", self.live_now
|
||||||
|
json.field "paid", self.paid
|
||||||
|
json.field "premium", self.premium
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
|
||||||
|
if json
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
else
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
@ -6,7 +94,6 @@ struct SearchVideo
|
|||||||
ucid: String,
|
ucid: String,
|
||||||
published: Time,
|
published: Time,
|
||||||
views: Int64,
|
views: Int64,
|
||||||
description: String,
|
|
||||||
description_html: String,
|
description_html: String,
|
||||||
length_seconds: Int32,
|
length_seconds: Int32,
|
||||||
live_now: Bool,
|
live_now: Bool,
|
||||||
@ -25,6 +112,45 @@ struct SearchPlaylistVideo
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct SearchPlaylist
|
struct SearchPlaylist
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "type", "playlist"
|
||||||
|
json.field "title", self.title
|
||||||
|
json.field "playlistId", self.id
|
||||||
|
|
||||||
|
json.field "author", self.author
|
||||||
|
json.field "authorId", self.ucid
|
||||||
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
|
||||||
|
json.field "videoCount", self.video_count
|
||||||
|
json.field "videos" do
|
||||||
|
json.array do
|
||||||
|
self.videos.each do |video|
|
||||||
|
json.object do
|
||||||
|
json.field "title", video.title
|
||||||
|
json.field "videoId", video.id
|
||||||
|
json.field "lengthSeconds", video.length_seconds
|
||||||
|
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, video.id, config, Kemal.config)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
|
||||||
|
if json
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
else
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
title: String,
|
title: String,
|
||||||
id: String,
|
id: String,
|
||||||
@ -37,13 +163,50 @@ struct SearchPlaylist
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct SearchChannel
|
struct SearchChannel
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "type", "channel"
|
||||||
|
json.field "author", self.author
|
||||||
|
json.field "authorId", self.ucid
|
||||||
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
|
||||||
|
json.field "authorThumbnails" do
|
||||||
|
json.array do
|
||||||
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
|
||||||
|
qualities.each do |quality|
|
||||||
|
json.object do
|
||||||
|
json.field "url", self.author_thumbnail.gsub("=s176-", "=s#{quality}-")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "subCount", self.subscriber_count
|
||||||
|
json.field "videoCount", self.video_count
|
||||||
|
json.field "description", html_to_content(self.description_html)
|
||||||
|
json.field "descriptionHtml", self.description_html
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
|
||||||
|
if json
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
else
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(locale, config, kemal_config, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
author: String,
|
author: String,
|
||||||
ucid: String,
|
ucid: String,
|
||||||
author_thumbnail: String,
|
author_thumbnail: String,
|
||||||
subscriber_count: Int32,
|
subscriber_count: Int32,
|
||||||
video_count: Int32,
|
video_count: Int32,
|
||||||
description: String,
|
|
||||||
description_html: String,
|
description_html: String,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@ -93,8 +256,8 @@ def channel_search(query, page, channel)
|
|||||||
return count, items
|
return count, items
|
||||||
end
|
end
|
||||||
|
|
||||||
def search(query, page = 1, search_params = produce_search_params(content_type: "all"), proxies = nil, region = nil)
|
def search(query, page = 1, search_params = produce_search_params(content_type: "all"), region = nil)
|
||||||
client = make_client(YT_URL, proxies, region)
|
client = make_client(YT_URL, region)
|
||||||
if query.empty?
|
if query.empty?
|
||||||
return {0, [] of SearchItem}
|
return {0, [] of SearchItem}
|
||||||
end
|
end
|
||||||
@ -211,45 +374,51 @@ end
|
|||||||
def produce_channel_search_url(ucid, query, page)
|
def produce_channel_search_url(ucid, query, page)
|
||||||
page = "#{page}"
|
page = "#{page}"
|
||||||
|
|
||||||
meta = IO::Memory.new
|
data = IO::Memory.new
|
||||||
meta.write(Bytes[0x12, 0x06])
|
data.write_byte 0x12
|
||||||
meta.print("search")
|
data.write_byte 0x06
|
||||||
|
data.print "search"
|
||||||
|
|
||||||
meta.write(Bytes[0x30, 0x02])
|
data.write Bytes[0x30, 0x02]
|
||||||
meta.write(Bytes[0x38, 0x01])
|
data.write Bytes[0x38, 0x01]
|
||||||
meta.write(Bytes[0x60, 0x01])
|
data.write Bytes[0x60, 0x01]
|
||||||
meta.write(Bytes[0x6a, 0x00])
|
data.write Bytes[0x6a, 0x00]
|
||||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
data.write Bytes[0xb8, 0x01, 0x00]
|
||||||
|
|
||||||
meta.write(Bytes[0x7a, page.size])
|
data.write_byte 0x7a
|
||||||
meta.print(page)
|
VarInt.to_io(data, page.bytesize)
|
||||||
|
data.print page
|
||||||
|
|
||||||
meta.rewind
|
data.rewind
|
||||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
data = Base64.urlsafe_encode(data)
|
||||||
meta = URI.escape(meta)
|
continuation = URI.escape(data)
|
||||||
|
|
||||||
continuation = IO::Memory.new
|
data = IO::Memory.new
|
||||||
continuation.write(Bytes[0x12, ucid.size])
|
|
||||||
continuation.print(ucid)
|
|
||||||
|
|
||||||
continuation.write(Bytes[0x1a, meta.size])
|
data.write_byte 0x12
|
||||||
continuation.print(meta)
|
VarInt.to_io(data, ucid.bytesize)
|
||||||
|
data.print ucid
|
||||||
|
|
||||||
continuation.write(Bytes[0x5a, query.size])
|
data.write_byte 0x1a
|
||||||
continuation.print(query)
|
VarInt.to_io(data, continuation.bytesize)
|
||||||
|
data.print continuation
|
||||||
|
|
||||||
continuation.rewind
|
data.write_byte 0x5a
|
||||||
continuation = continuation.gets_to_end
|
VarInt.to_io(data, query.bytesize)
|
||||||
|
data.print query
|
||||||
|
|
||||||
wrapper = IO::Memory.new
|
data.rewind
|
||||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
|
||||||
wrapper.print(continuation)
|
|
||||||
wrapper.rewind
|
|
||||||
|
|
||||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
buffer = IO::Memory.new
|
||||||
wrapper = URI.escape(wrapper)
|
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
|
||||||
|
VarInt.to_io(buffer, data.bytesize)
|
||||||
|
|
||||||
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en"
|
IO.copy data, buffer
|
||||||
|
|
||||||
|
continuation = Base64.urlsafe_encode(buffer)
|
||||||
|
continuation = URI.escape(continuation)
|
||||||
|
|
||||||
|
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
|
||||||
|
|
||||||
return url
|
return url
|
||||||
end
|
end
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
def fetch_trending(trending_type, proxies, region, locale)
|
def fetch_trending(trending_type, region, locale)
|
||||||
client = make_client(YT_URL)
|
client = make_client(YT_URL)
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
||||||
@ -14,14 +14,9 @@ def fetch_trending(trending_type, proxies, region, locale)
|
|||||||
|
|
||||||
response = client.get("/feed/trending?gl=#{region}&hl=en", headers).body
|
response = client.get("/feed/trending?gl=#{region}&hl=en", headers).body
|
||||||
|
|
||||||
yt_data = response.match(/window\["ytInitialData"\] = (?<data>.*);/)
|
initial_data = extract_initial_data(response)
|
||||||
if yt_data
|
|
||||||
yt_data = JSON.parse(yt_data["data"].rchop(";"))
|
|
||||||
else
|
|
||||||
raise translate(locale, "Could not pull trending pages.")
|
|
||||||
end
|
|
||||||
|
|
||||||
tabs = yt_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"]["content"]["sectionListRenderer"]["subMenu"]["channelListSubMenuRenderer"]["contents"].as_a
|
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"]["content"]["sectionListRenderer"]["subMenu"]["channelListSubMenuRenderer"]["contents"].as_a
|
||||||
url = tabs.select { |tab| tab["channelListSubMenuAvatarRenderer"]["title"]["simpleText"] == trending_type }[0]?
|
url = tabs.select { |tab| tab["channelListSubMenuAvatarRenderer"]["title"]["simpleText"] == trending_type }[0]?
|
||||||
|
|
||||||
if url
|
if url
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
require "crypto/bcrypt/password"
|
require "crypto/bcrypt/password"
|
||||||
|
|
||||||
|
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
|
||||||
|
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
|
||||||
|
|
||||||
struct User
|
struct User
|
||||||
module PreferencesConverter
|
module PreferencesConverter
|
||||||
def self.from_rs(rs)
|
def self.from_rs(rs)
|
||||||
@ -20,9 +23,10 @@ struct User
|
|||||||
type: Preferences,
|
type: Preferences,
|
||||||
converter: PreferencesConverter,
|
converter: PreferencesConverter,
|
||||||
},
|
},
|
||||||
password: String?,
|
password: String?,
|
||||||
token: String,
|
token: String,
|
||||||
watched: Array(String),
|
watched: Array(String),
|
||||||
|
feed_needs_update: Bool?,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -40,10 +44,10 @@ struct Preferences
|
|||||||
begin
|
begin
|
||||||
result = [] of String
|
result = [] of String
|
||||||
value.read_array do
|
value.read_array do
|
||||||
result << HTML.escape(value.read_string)
|
result << HTML.escape(value.read_string[0, 100])
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
result = [HTML.escape(value.read_string), ""]
|
result = [HTML.escape(value.read_string[0, 100]), ""]
|
||||||
end
|
end
|
||||||
|
|
||||||
result
|
result
|
||||||
@ -69,11 +73,11 @@ struct Preferences
|
|||||||
node.raise "Expected scalar, not #{item.class}"
|
node.raise "Expected scalar, not #{item.class}"
|
||||||
end
|
end
|
||||||
|
|
||||||
result << HTML.escape(item.value)
|
result << HTML.escape(item.value[0, 100])
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
if node.is_a?(YAML::Nodes::Scalar)
|
if node.is_a?(YAML::Nodes::Scalar)
|
||||||
result = [HTML.escape(node.value), ""]
|
result = [HTML.escape(node.value[0, 100]), ""]
|
||||||
else
|
else
|
||||||
result = ["", ""]
|
result = ["", ""]
|
||||||
end
|
end
|
||||||
@ -83,13 +87,13 @@ struct Preferences
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
module EscapeString
|
module ProcessString
|
||||||
def self.to_json(value : String, json : JSON::Builder)
|
def self.to_json(value : String, json : JSON::Builder)
|
||||||
json.string value
|
json.string value
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.from_json(value : JSON::PullParser) : String
|
def self.from_json(value : JSON::PullParser) : String
|
||||||
HTML.escape(value.read_string)
|
HTML.escape(value.read_string[0, 100])
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||||
@ -97,7 +101,25 @@ struct Preferences
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
||||||
HTML.escape(node.value)
|
HTML.escape(node.value[0, 100])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module ClampInt
|
||||||
|
def self.to_json(value : Int32, json : JSON::Builder)
|
||||||
|
json.number value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_json(value : JSON::PullParser) : Int32
|
||||||
|
value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
|
||||||
|
yaml.scalar value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
|
||||||
|
node.value.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -113,13 +135,13 @@ struct Preferences
|
|||||||
latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
|
latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
|
||||||
listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
|
listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
|
||||||
local: {type: Bool, default: CONFIG.default_user_preferences.local},
|
local: {type: Bool, default: CONFIG.default_user_preferences.local},
|
||||||
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: EscapeString},
|
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString},
|
||||||
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results},
|
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt},
|
||||||
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only},
|
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only},
|
||||||
quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: EscapeString},
|
quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString},
|
||||||
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed},
|
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed},
|
||||||
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},
|
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},
|
||||||
sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: EscapeString},
|
sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString},
|
||||||
speed: {type: Float32, default: CONFIG.default_user_preferences.speed},
|
speed: {type: Float32, default: CONFIG.default_user_preferences.speed},
|
||||||
thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode},
|
thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode},
|
||||||
unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only},
|
unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only},
|
||||||
@ -132,7 +154,7 @@ def get_user(sid, headers, db, refresh = true)
|
|||||||
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
|
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
|
||||||
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
|
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
|
||||||
|
|
||||||
if refresh && Time.now - user.updated > 1.minute
|
if refresh && Time.utc - user.updated > 1.minute
|
||||||
user, sid = fetch_user(sid, headers, db)
|
user, sid = fetch_user(sid, headers, db)
|
||||||
user_array = user.to_a
|
user_array = user.to_a
|
||||||
|
|
||||||
@ -143,14 +165,11 @@ def get_user(sid, headers, db, refresh = true)
|
|||||||
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
|
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
|
||||||
|
|
||||||
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
|
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
|
||||||
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
|
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
view_name = "subscriptions_#{sha256(user.email)}"
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||||
SELECT * FROM channel_videos WHERE \
|
|
||||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
|
|
||||||
ORDER BY published DESC;")
|
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -165,14 +184,11 @@ def get_user(sid, headers, db, refresh = true)
|
|||||||
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
|
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
|
||||||
|
|
||||||
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
|
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
|
||||||
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now)
|
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
view_name = "subscriptions_#{sha256(user.email)}"
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||||
SELECT * FROM channel_videos WHERE \
|
|
||||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
|
|
||||||
ORDER BY published DESC;")
|
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -205,7 +221,7 @@ def fetch_user(sid, headers, db)
|
|||||||
|
|
||||||
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
|
||||||
user = User.new(Time.now, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String)
|
user = User.new(Time.utc, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String, true)
|
||||||
return user, sid
|
return user, sid
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -213,7 +229,7 @@ def create_user(sid, email, password)
|
|||||||
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
||||||
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
|
||||||
user = User.new(Time.now, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String)
|
user = User.new(Time.utc, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String, true)
|
||||||
|
|
||||||
return user, sid
|
return user, sid
|
||||||
end
|
end
|
||||||
@ -313,10 +329,108 @@ def subscribe_ajax(channel_id, action, env_headers)
|
|||||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
post_req = {
|
post_req = {
|
||||||
"session_token" => session_token,
|
session_token: session_token,
|
||||||
}
|
}
|
||||||
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
|
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
|
||||||
|
|
||||||
client.post(post_url, headers, form: post_req)
|
client.post(post_url, headers, form: post_req)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_subscription_feed(db, user, max_results = 40, page = 1)
|
||||||
|
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
|
||||||
|
as: Array(String))
|
||||||
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
|
|
||||||
|
if user.preferences.notifications_only && !notifications.empty?
|
||||||
|
# Only show notifications
|
||||||
|
|
||||||
|
args = arg_array(notifications)
|
||||||
|
|
||||||
|
notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args})
|
||||||
|
ORDER BY published DESC", notifications, as: ChannelVideo)
|
||||||
|
videos = [] of ChannelVideo
|
||||||
|
|
||||||
|
notifications.sort_by! { |video| video.published }.reverse!
|
||||||
|
|
||||||
|
case user.preferences.sort
|
||||||
|
when "alphabetically"
|
||||||
|
notifications.sort_by! { |video| video.title }
|
||||||
|
when "alphabetically - reverse"
|
||||||
|
notifications.sort_by! { |video| video.title }.reverse!
|
||||||
|
when "channel name"
|
||||||
|
notifications.sort_by! { |video| video.author }
|
||||||
|
when "channel name - reverse"
|
||||||
|
notifications.sort_by! { |video| video.author }.reverse!
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if user.preferences.latest_only
|
||||||
|
if user.preferences.unseen_only
|
||||||
|
# Show latest video from a channel that a user hasn't watched
|
||||||
|
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
|
||||||
|
|
||||||
|
if user.watched.empty?
|
||||||
|
values = "'{}'"
|
||||||
|
else
|
||||||
|
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
|
end
|
||||||
|
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE \
|
||||||
|
NOT id = ANY (#{values}) \
|
||||||
|
ORDER BY ucid, published DESC", as: ChannelVideo)
|
||||||
|
else
|
||||||
|
# Show latest video from each channel
|
||||||
|
|
||||||
|
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} \
|
||||||
|
ORDER BY ucid, published DESC", as: ChannelVideo)
|
||||||
|
end
|
||||||
|
|
||||||
|
videos.sort_by! { |video| video.published }.reverse!
|
||||||
|
else
|
||||||
|
if user.preferences.unseen_only
|
||||||
|
# Only show unwatched
|
||||||
|
|
||||||
|
if user.watched.empty?
|
||||||
|
values = "'{}'"
|
||||||
|
else
|
||||||
|
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
|
end
|
||||||
|
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE \
|
||||||
|
NOT id = ANY (#{values}) \
|
||||||
|
ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
||||||
|
else
|
||||||
|
# Sort subscriptions as normal
|
||||||
|
|
||||||
|
videos = PG_DB.query_all("SELECT * FROM #{view_name} \
|
||||||
|
ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
case user.preferences.sort
|
||||||
|
when "published - reverse"
|
||||||
|
videos.sort_by! { |video| video.published }
|
||||||
|
when "alphabetically"
|
||||||
|
videos.sort_by! { |video| video.title }
|
||||||
|
when "alphabetically - reverse"
|
||||||
|
videos.sort_by! { |video| video.title }.reverse!
|
||||||
|
when "channel name"
|
||||||
|
videos.sort_by! { |video| video.author }
|
||||||
|
when "channel name - reverse"
|
||||||
|
videos.sort_by! { |video| video.author }.reverse!
|
||||||
|
end
|
||||||
|
|
||||||
|
notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
|
||||||
|
as: Array(String))
|
||||||
|
|
||||||
|
notifications = videos.select { |v| notifications.includes? v.id }
|
||||||
|
videos = videos - notifications
|
||||||
|
end
|
||||||
|
|
||||||
|
if !limit
|
||||||
|
videos = videos[0..max_results]
|
||||||
|
end
|
||||||
|
|
||||||
|
return videos, notifications
|
||||||
|
end
|
||||||
|
@ -182,7 +182,7 @@ VIDEO_FORMATS = {
|
|||||||
"135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
|
"135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
"136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"},
|
"136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
"137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"},
|
"137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
"138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https=>//github.com/rg3/youtube-dl/issues/4559)
|
"138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
|
||||||
"160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"},
|
"160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
"212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
|
"212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
"264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"},
|
"264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"},
|
||||||
@ -239,6 +239,12 @@ VIDEO_FORMATS = {
|
|||||||
"249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50},
|
"249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50},
|
||||||
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
|
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
|
||||||
"251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160},
|
"251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160},
|
||||||
|
|
||||||
|
# av01 video only formats sometimes served with "unknown" codecs
|
||||||
|
"394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"},
|
||||||
|
"395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"},
|
||||||
|
"396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"},
|
||||||
|
"397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"},
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VideoPreferences
|
struct VideoPreferences
|
||||||
@ -273,190 +279,209 @@ struct Video
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(locale, config, kemal_config, decrypt_function)
|
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder)
|
||||||
JSON.build do |json|
|
json.object do
|
||||||
json.object do
|
json.field "type", "video"
|
||||||
json.field "title", self.title
|
|
||||||
json.field "videoId", self.id
|
|
||||||
json.field "videoThumbnails" do
|
|
||||||
generate_thumbnails(json, self.id, config, kemal_config)
|
|
||||||
end
|
|
||||||
json.field "storyboards" do
|
|
||||||
generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
|
|
||||||
end
|
|
||||||
|
|
||||||
description_html, description = html_to_content(self.description)
|
json.field "title", self.title
|
||||||
|
json.field "videoId", self.id
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, self.id, config, kemal_config)
|
||||||
|
end
|
||||||
|
json.field "storyboards" do
|
||||||
|
generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
|
||||||
|
end
|
||||||
|
|
||||||
json.field "description", description
|
json.field "description", html_to_content(self.description_html)
|
||||||
json.field "descriptionHtml", description_html
|
json.field "descriptionHtml", self.description_html
|
||||||
json.field "published", self.published.to_unix
|
json.field "published", self.published.to_unix
|
||||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||||
json.field "keywords", self.keywords
|
json.field "keywords", self.keywords
|
||||||
|
|
||||||
json.field "viewCount", self.views
|
json.field "viewCount", self.views
|
||||||
json.field "likeCount", self.likes
|
json.field "likeCount", self.likes
|
||||||
json.field "dislikeCount", self.dislikes
|
json.field "dislikeCount", self.dislikes
|
||||||
|
|
||||||
json.field "paid", self.paid
|
json.field "paid", self.paid
|
||||||
json.field "premium", self.premium
|
json.field "premium", self.premium
|
||||||
json.field "isFamilyFriendly", self.is_family_friendly
|
json.field "isFamilyFriendly", self.is_family_friendly
|
||||||
json.field "allowedRegions", self.allowed_regions
|
json.field "allowedRegions", self.allowed_regions
|
||||||
json.field "genre", self.genre
|
json.field "genre", self.genre
|
||||||
json.field "genreUrl", self.genre_url
|
json.field "genreUrl", self.genre_url
|
||||||
|
|
||||||
json.field "author", self.author
|
json.field "author", self.author
|
||||||
json.field "authorId", self.ucid
|
json.field "authorId", self.ucid
|
||||||
json.field "authorUrl", "/channel/#{self.ucid}"
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
|
||||||
json.field "authorThumbnails" do
|
json.field "authorThumbnails" do
|
||||||
json.array do
|
json.array do
|
||||||
qualities = {32, 48, 76, 100, 176, 512}
|
qualities = {32, 48, 76, 100, 176, 512}
|
||||||
|
|
||||||
qualities.each do |quality|
|
qualities.each do |quality|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "url", self.author_thumbnail.gsub("=s48-", "=s#{quality}-")
|
json.field "url", self.author_thumbnail.gsub("=s48-", "=s#{quality}-")
|
||||||
json.field "width", quality
|
json.field "width", quality
|
||||||
json.field "height", quality
|
json.field "height", quality
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
json.field "subCountText", self.sub_count_text
|
json.field "subCountText", self.sub_count_text
|
||||||
|
|
||||||
json.field "lengthSeconds", self.info["length_seconds"].to_i
|
json.field "lengthSeconds", self.length_seconds
|
||||||
json.field "allowRatings", self.allow_ratings
|
json.field "allowRatings", self.allow_ratings
|
||||||
json.field "rating", self.info["avg_rating"].to_f32
|
json.field "rating", self.info["avg_rating"].to_f32
|
||||||
json.field "isListed", self.is_listed
|
json.field "isListed", self.is_listed
|
||||||
json.field "liveNow", self.live_now
|
json.field "liveNow", self.live_now
|
||||||
json.field "isUpcoming", self.is_upcoming
|
json.field "isUpcoming", self.is_upcoming
|
||||||
|
|
||||||
if self.premiere_timestamp
|
if self.premiere_timestamp
|
||||||
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
|
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
||||||
host_url = make_host_url(config, kemal_config)
|
host_url = make_host_url(config, kemal_config)
|
||||||
|
|
||||||
hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s
|
hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s
|
||||||
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
||||||
|
|
||||||
json.field "hlsUrl", hlsvp
|
json.field "hlsUrl", hlsvp
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}"
|
json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}"
|
||||||
|
|
||||||
json.field "adaptiveFormats" do
|
json.field "adaptiveFormats" do
|
||||||
json.array do
|
json.array do
|
||||||
self.adaptive_fmts(decrypt_function).each do |fmt|
|
self.adaptive_fmts(decrypt_function).each do |fmt|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "index", fmt["index"]
|
json.field "index", fmt["index"]
|
||||||
json.field "bitrate", fmt["bitrate"]
|
json.field "bitrate", fmt["bitrate"]
|
||||||
json.field "init", fmt["init"]
|
json.field "init", fmt["init"]
|
||||||
json.field "url", fmt["url"]
|
json.field "url", fmt["url"]
|
||||||
json.field "itag", fmt["itag"]
|
json.field "itag", fmt["itag"]
|
||||||
json.field "type", fmt["type"]
|
json.field "type", fmt["type"]
|
||||||
json.field "clen", fmt["clen"]
|
json.field "clen", fmt["clen"]
|
||||||
json.field "lmt", fmt["lmt"]
|
json.field "lmt", fmt["lmt"]
|
||||||
json.field "projectionType", fmt["projection_type"]
|
json.field "projectionType", fmt["projection_type"]
|
||||||
|
|
||||||
fmt_info = itag_to_metadata?(fmt["itag"])
|
fmt_info = itag_to_metadata?(fmt["itag"])
|
||||||
if fmt_info
|
if fmt_info
|
||||||
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
|
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
|
||||||
json.field "fps", fps
|
json.field "fps", fps
|
||||||
json.field "container", fmt_info["ext"]
|
json.field "container", fmt_info["ext"]
|
||||||
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
||||||
|
|
||||||
if fmt_info["height"]?
|
if fmt_info["height"]?
|
||||||
json.field "resolution", "#{fmt_info["height"]}p"
|
json.field "resolution", "#{fmt_info["height"]}p"
|
||||||
|
|
||||||
quality_label = "#{fmt_info["height"]}p"
|
quality_label = "#{fmt_info["height"]}p"
|
||||||
if fps > 30
|
if fps > 30
|
||||||
quality_label += "60"
|
quality_label += "60"
|
||||||
end
|
|
||||||
json.field "qualityLabel", quality_label
|
|
||||||
|
|
||||||
if fmt_info["width"]?
|
|
||||||
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
json.field "qualityLabel", quality_label
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "formatStreams" do
|
if fmt_info["width"]?
|
||||||
json.array do
|
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
||||||
self.fmt_stream(decrypt_function).each do |fmt|
|
|
||||||
json.object do
|
|
||||||
json.field "url", fmt["url"]
|
|
||||||
json.field "itag", fmt["itag"]
|
|
||||||
json.field "type", fmt["type"]
|
|
||||||
json.field "quality", fmt["quality"]
|
|
||||||
|
|
||||||
fmt_info = itag_to_metadata?(fmt["itag"])
|
|
||||||
if fmt_info
|
|
||||||
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
|
|
||||||
json.field "fps", fps
|
|
||||||
json.field "container", fmt_info["ext"]
|
|
||||||
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
|
||||||
|
|
||||||
if fmt_info["height"]?
|
|
||||||
json.field "resolution", "#{fmt_info["height"]}p"
|
|
||||||
|
|
||||||
quality_label = "#{fmt_info["height"]}p"
|
|
||||||
if fps > 30
|
|
||||||
quality_label += "60"
|
|
||||||
end
|
|
||||||
json.field "qualityLabel", quality_label
|
|
||||||
|
|
||||||
if fmt_info["width"]?
|
|
||||||
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
json.field "captions" do
|
|
||||||
json.array do
|
json.field "formatStreams" do
|
||||||
self.captions.each do |caption|
|
json.array do
|
||||||
json.object do
|
self.fmt_stream(decrypt_function).each do |fmt|
|
||||||
json.field "label", caption.name.simpleText
|
json.object do
|
||||||
json.field "languageCode", caption.languageCode
|
json.field "url", fmt["url"]
|
||||||
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}"
|
json.field "itag", fmt["itag"]
|
||||||
end
|
json.field "type", fmt["type"]
|
||||||
end
|
json.field "quality", fmt["quality"]
|
||||||
end
|
|
||||||
end
|
fmt_info = itag_to_metadata?(fmt["itag"])
|
||||||
|
if fmt_info
|
||||||
json.field "recommendedVideos" do
|
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
|
||||||
json.array do
|
json.field "fps", fps
|
||||||
self.info["rvs"]?.try &.split(",").each do |rv|
|
json.field "container", fmt_info["ext"]
|
||||||
rv = HTTP::Params.parse(rv)
|
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
||||||
|
|
||||||
if rv["id"]?
|
if fmt_info["height"]?
|
||||||
json.object do
|
json.field "resolution", "#{fmt_info["height"]}p"
|
||||||
json.field "videoId", rv["id"]
|
|
||||||
json.field "title", rv["title"]
|
quality_label = "#{fmt_info["height"]}p"
|
||||||
json.field "videoThumbnails" do
|
if fps > 30
|
||||||
generate_thumbnails(json, rv["id"], config, kemal_config)
|
quality_label += "60"
|
||||||
end
|
end
|
||||||
json.field "author", rv["author"]
|
json.field "qualityLabel", quality_label
|
||||||
json.field "lengthSeconds", rv["length_seconds"].to_i
|
|
||||||
json.field "viewCountText", rv["short_view_count_text"]
|
if fmt_info["width"]?
|
||||||
end
|
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "captions" do
|
||||||
|
json.array do
|
||||||
|
self.captions.each do |caption|
|
||||||
|
json.object do
|
||||||
|
json.field "label", caption.name.simpleText
|
||||||
|
json.field "languageCode", caption.languageCode
|
||||||
|
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "recommendedVideos" do
|
||||||
|
json.array do
|
||||||
|
self.info["rvs"]?.try &.split(",").each do |rv|
|
||||||
|
rv = HTTP::Params.parse(rv)
|
||||||
|
|
||||||
|
if rv["id"]?
|
||||||
|
json.object do
|
||||||
|
json.field "videoId", rv["id"]
|
||||||
|
json.field "title", rv["title"]
|
||||||
|
json.field "videoThumbnails" do
|
||||||
|
generate_thumbnails(json, rv["id"], config, kemal_config)
|
||||||
|
end
|
||||||
|
json.field "author", rv["author"]
|
||||||
|
json.field "lengthSeconds", rv["length_seconds"].to_i
|
||||||
|
json.field "viewCountText", rv["short_view_count_text"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder | Nil = nil)
|
||||||
|
if json
|
||||||
|
to_json(locale, config, kemal_config, decrypt_function, json)
|
||||||
|
else
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(locale, config, kemal_config, decrypt_function, json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# `description_html` is stored in DB as `description`, which can be
|
||||||
|
# quite confusing. Since it currently isn't very practical to rename
|
||||||
|
# it, we instead define a getter and setter here.
|
||||||
|
def description_html
|
||||||
|
self.description
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_html=(other : String)
|
||||||
|
self.description = other
|
||||||
|
end
|
||||||
|
|
||||||
def allow_ratings
|
def allow_ratings
|
||||||
allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool
|
allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool
|
||||||
|
|
||||||
@ -538,7 +563,14 @@ struct Video
|
|||||||
fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0"
|
fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0"
|
||||||
fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
|
fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
|
||||||
fmt["itag"] = fmt_stream["itag"].as_i.to_s
|
fmt["itag"] = fmt_stream["itag"].as_i.to_s
|
||||||
fmt["url"] = fmt_stream["url"].as_s
|
if fmt_stream["url"]?
|
||||||
|
fmt["url"] = fmt_stream["url"].as_s
|
||||||
|
end
|
||||||
|
if fmt_stream["cipher"]?
|
||||||
|
HTTP::Params.parse(fmt_stream["cipher"].as_s).each do |key, value|
|
||||||
|
fmt[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
fmt["quality"] = fmt_stream["quality"].as_s
|
fmt["quality"] = fmt_stream["quality"].as_s
|
||||||
|
|
||||||
if fmt_stream["width"]?
|
if fmt_stream["width"]?
|
||||||
@ -610,8 +642,14 @@ struct Video
|
|||||||
fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
|
fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
|
||||||
fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
|
fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
|
||||||
fmt["itag"] = adaptive_fmt["itag"].as_i.to_s
|
fmt["itag"] = adaptive_fmt["itag"].as_i.to_s
|
||||||
fmt["url"] = adaptive_fmt["url"].as_s
|
if adaptive_fmt["url"]?
|
||||||
|
fmt["url"] = adaptive_fmt["url"].as_s
|
||||||
|
end
|
||||||
|
if adaptive_fmt["cipher"]?
|
||||||
|
HTTP::Params.parse(adaptive_fmt["cipher"].as_s).each do |key, value|
|
||||||
|
fmt[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
if index = adaptive_fmt["indexRange"]?
|
if index = adaptive_fmt["indexRange"]?
|
||||||
fmt["index"] = "#{index["start"]}-#{index["end"]}"
|
fmt["index"] = "#{index["start"]}-#{index["end"]}"
|
||||||
end
|
end
|
||||||
@ -786,18 +824,23 @@ struct Video
|
|||||||
end
|
end
|
||||||
|
|
||||||
def short_description
|
def short_description
|
||||||
description = self.description.gsub("<br>", " ")
|
short_description = self.description_html.gsub(/(<br>)|(<br\/>|"|\n)/, {
|
||||||
description = description.gsub("<br/>", " ")
|
"<br>": " ",
|
||||||
description = XML.parse_html(description).content[0..200].gsub('"', """).gsub("\n", " ").strip(" ")
|
"<br/>": " ",
|
||||||
if description.empty?
|
"\"": """,
|
||||||
description = " "
|
"\n": " ",
|
||||||
|
})
|
||||||
|
short_description = XML.parse_html(short_description).content[0..200].strip(" ")
|
||||||
|
|
||||||
|
if short_description.empty?
|
||||||
|
short_description = " "
|
||||||
end
|
end
|
||||||
|
|
||||||
return description
|
return short_description
|
||||||
end
|
end
|
||||||
|
|
||||||
def length_seconds
|
def length_seconds
|
||||||
return self.info["length_seconds"].to_i
|
self.player_response["videoDetails"]["lengthSeconds"].as_s.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
db_mapping({
|
db_mapping({
|
||||||
@ -845,14 +888,16 @@ end
|
|||||||
class VideoRedirect < Exception
|
class VideoRedirect < Exception
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32}), refresh = true, region = nil, force_refresh = false)
|
def get_video(id, db, refresh = true, region = nil, force_refresh = false)
|
||||||
if db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", id, as: Bool) && !region
|
if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region
|
||||||
video = db.query_one("SELECT * FROM videos WHERE id = $1", id, as: Video)
|
# If record was last updated over 10 minutes ago, or video has since premiered,
|
||||||
|
# refresh (expire param in response lasts for 6 hours)
|
||||||
# If record was last updated over 10 minutes ago, refresh (expire param in response lasts for 6 hours)
|
if (refresh &&
|
||||||
if (refresh && Time.now - video.updated > 10.minutes) || force_refresh
|
(Time.utc - video.updated > 10.minutes) ||
|
||||||
|
(video.premiere_timestamp && video.premiere_timestamp.as(Time) < Time.utc)) ||
|
||||||
|
force_refresh
|
||||||
begin
|
begin
|
||||||
video = fetch_video(id, proxies, region: region)
|
video = fetch_video(id, region)
|
||||||
video_array = video.to_a
|
video_array = video.to_a
|
||||||
|
|
||||||
args = arg_array(video_array[1..-1], 2)
|
args = arg_array(video_array[1..-1], 2)
|
||||||
@ -867,7 +912,7 @@ def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32})
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
video = fetch_video(id, proxies, region: region)
|
video = fetch_video(id, region)
|
||||||
video_array = video.to_a
|
video_array = video.to_a
|
||||||
|
|
||||||
args = arg_array(video_array)
|
args = arg_array(video_array)
|
||||||
@ -893,7 +938,7 @@ def extract_polymer_config(body, html)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
initial_data = JSON.parse(body.match(/window\["ytInitialData"\] = (?<info>.*?);\n/).try &.["info"] || "{}")
|
initial_data = extract_initial_data(body)
|
||||||
|
|
||||||
primary_results = initial_data["contents"]?
|
primary_results = initial_data["contents"]?
|
||||||
.try &.["twoColumnWatchNextResults"]?
|
.try &.["twoColumnWatchNextResults"]?
|
||||||
@ -971,7 +1016,7 @@ def extract_polymer_config(body, html)
|
|||||||
if published
|
if published
|
||||||
params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s
|
params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s
|
||||||
else
|
else
|
||||||
params["published"] = Time.new(1990, 1, 1).to_unix.to_s
|
params["published"] = Time.utc(1990, 1, 1).to_unix.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
params["description_html"] = "<p></p>"
|
params["description_html"] = "<p></p>"
|
||||||
@ -1071,8 +1116,8 @@ def extract_player_config(body, html)
|
|||||||
return params
|
return params
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_video(id, proxies, region)
|
def fetch_video(id, region)
|
||||||
client = make_client(YT_URL, proxies, region)
|
client = make_client(YT_URL, region)
|
||||||
response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
|
response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
|
||||||
|
|
||||||
if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/)
|
if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/)
|
||||||
@ -1087,9 +1132,9 @@ def fetch_video(id, proxies, region)
|
|||||||
if info["reason"]? && info["reason"].includes? "your country"
|
if info["reason"]? && info["reason"].includes? "your country"
|
||||||
bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new
|
bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new
|
||||||
|
|
||||||
proxies.each do |proxy_region, list|
|
PROXY_LIST.each do |proxy_region, list|
|
||||||
spawn do
|
spawn do
|
||||||
client = make_client(YT_URL, proxies, proxy_region)
|
client = make_client(YT_URL, proxy_region)
|
||||||
proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
|
proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
|
||||||
|
|
||||||
proxy_html = XML.parse_html(proxy_response.body)
|
proxy_html = XML.parse_html(proxy_response.body)
|
||||||
@ -1105,7 +1150,7 @@ def fetch_video(id, proxies, region)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
proxies.size.times do
|
PROXY_LIST.size.times do
|
||||||
response = bypass_channel.receive
|
response = bypass_channel.receive
|
||||||
if response
|
if response
|
||||||
html, info = response
|
html, info = response
|
||||||
@ -1130,41 +1175,38 @@ def fetch_video(id, proxies, region)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if info["errorcode"]?.try &.== "2"
|
if info["errorcode"]?.try &.== "2" || !info["player_response"]
|
||||||
raise "Video unavailable."
|
raise "Video unavailable."
|
||||||
end
|
end
|
||||||
|
|
||||||
if !info["title"]?
|
if info["reason"]?
|
||||||
raise "Video unavailable."
|
raise info["reason"]
|
||||||
end
|
end
|
||||||
|
|
||||||
title = info["title"]
|
player_json = JSON.parse(info["player_response"])
|
||||||
author = info["author"]
|
|
||||||
ucid = info["ucid"]
|
title = player_json["videoDetails"]["title"].as_s
|
||||||
|
author = player_json["videoDetails"]["author"]?.try &.as_s || ""
|
||||||
|
ucid = player_json["videoDetails"]["ucid"]?.try &.as_s || ""
|
||||||
|
|
||||||
views = html.xpath_node(%q(//meta[@itemprop="interactionCount"]))
|
views = html.xpath_node(%q(//meta[@itemprop="interactionCount"]))
|
||||||
views = views.try &.["content"].to_i64?
|
.try &.["content"].to_i64? || 0_i64
|
||||||
views ||= 0_i64
|
|
||||||
|
|
||||||
likes = html.xpath_node(%q(//button[@title="I like this"]/span))
|
likes = html.xpath_node(%q(//button[@title="I like this"]/span))
|
||||||
likes = likes.try &.content.delete(",").try &.to_i?
|
.try &.content.delete(",").try &.to_i? || 0
|
||||||
likes ||= 0
|
|
||||||
|
|
||||||
dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span))
|
dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span))
|
||||||
dislikes = dislikes.try &.content.delete(",").try &.to_i?
|
.try &.content.delete(",").try &.to_i? || 0
|
||||||
dislikes ||= 0
|
|
||||||
|
|
||||||
avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1)
|
avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1)
|
||||||
avg_rating = avg_rating.nan? ? 0.0 : avg_rating
|
avg_rating = avg_rating.nan? ? 0.0 : avg_rating
|
||||||
info["avg_rating"] = "#{avg_rating}"
|
info["avg_rating"] = "#{avg_rating}"
|
||||||
|
|
||||||
description = html.xpath_node(%q(//p[@id="eow-description"]))
|
description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || ""
|
||||||
description = description ? description.to_xml(options: XML::SaveOptions::NO_DECL) : ""
|
|
||||||
|
|
||||||
wilson_score = ci_lower_bound(likes, likes + dislikes)
|
wilson_score = ci_lower_bound(likes, likes + dislikes)
|
||||||
|
|
||||||
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"]
|
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"]
|
||||||
published ||= Time.now.to_s("%Y-%m-%d")
|
published ||= Time.utc.to_s("%Y-%m-%d")
|
||||||
published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
|
published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
|
||||||
|
|
||||||
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
|
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
|
||||||
@ -1176,7 +1218,8 @@ def fetch_video(id, proxies, region)
|
|||||||
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"]
|
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"]
|
||||||
genre ||= ""
|
genre ||= ""
|
||||||
|
|
||||||
genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"]
|
genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"]?
|
||||||
|
genre_url ||= ""
|
||||||
|
|
||||||
# YouTube provides invalid URLs for some genres, so we fix that here
|
# YouTube provides invalid URLs for some genres, so we fix that here
|
||||||
case genre
|
case genre
|
||||||
@ -1193,30 +1236,12 @@ def fetch_video(id, proxies, region)
|
|||||||
when "Trailers"
|
when "Trailers"
|
||||||
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
|
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
|
||||||
end
|
end
|
||||||
genre_url ||= ""
|
|
||||||
|
|
||||||
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li))
|
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || ""
|
||||||
if license
|
sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")])).try &.["title"]? || "0"
|
||||||
license = license.content
|
author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || ""
|
||||||
else
|
|
||||||
license = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")]))
|
video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html,
|
||||||
if sub_count_text
|
|
||||||
sub_count_text = sub_count_text["title"]
|
|
||||||
else
|
|
||||||
sub_count_text = "0"
|
|
||||||
end
|
|
||||||
|
|
||||||
author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img))
|
|
||||||
if author_thumbnail
|
|
||||||
author_thumbnail = author_thumbnail["data-thumb"]
|
|
||||||
else
|
|
||||||
author_thumbnail = ""
|
|
||||||
end
|
|
||||||
|
|
||||||
video = Video.new(id, info, Time.now, title, views, likes, dislikes, wilson_score, published, description,
|
|
||||||
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail)
|
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail)
|
||||||
|
|
||||||
return video
|
return video
|
||||||
@ -1233,12 +1258,12 @@ def process_video_params(query, preferences)
|
|||||||
continue = query["continue"]?.try &.to_i?
|
continue = query["continue"]?.try &.to_i?
|
||||||
continue_autoplay = query["continue_autoplay"]?.try &.to_i?
|
continue_autoplay = query["continue_autoplay"]?.try &.to_i?
|
||||||
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
|
local = query["local"]? && (query["local"] == "true" || query["local"] == "1").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"]?
|
related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe
|
||||||
speed = query["speed"]?.try &.to_f?
|
speed = query["speed"]?.try &.rchop("x").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?
|
||||||
|
|
||||||
@ -1282,6 +1307,14 @@ def process_video_params(query, preferences)
|
|||||||
related_videos = related_videos == 1
|
related_videos = related_videos == 1
|
||||||
video_loop = video_loop == 1
|
video_loop = video_loop == 1
|
||||||
|
|
||||||
|
if CONFIG.disabled?("dash") && quality == "dash"
|
||||||
|
quality = "high"
|
||||||
|
end
|
||||||
|
|
||||||
|
if CONFIG.disabled?("local") && local
|
||||||
|
local = false
|
||||||
|
end
|
||||||
|
|
||||||
if query["t"]?
|
if query["t"]?
|
||||||
video_start = decode_time(query["t"])
|
video_start = decode_time(query["t"])
|
||||||
end
|
end
|
||||||
|
Loading…
x
Reference in New Issue
Block a user