mirror of
https://github.com/iv-org/invidious.git
synced 2025-03-13 09:26:44 -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)
|
||||
|
||||
# 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
|
||||
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
|
||||
|
||||
Onion links:
|
||||
## Invidious Instances
|
||||
|
||||
- kgg2m7yk5aybusll.onion
|
||||
- axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
|
||||
See [Invidious Instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) for a full list of publicly available instances.
|
||||
|
||||
[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
|
||||
|
||||
|
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
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "إلغاء الإشتراك",
|
||||
"Subscribe": "إشتراك",
|
||||
"View channel on YouTube": "زيارة القناة على موقع يوتيوب",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب",
|
||||
"newest": "الأجدد",
|
||||
"oldest": "الأقدم",
|
||||
"popular": "الاكثر شعبية",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "شغل الفيديو التالى تلقائيا",
|
||||
"Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)",
|
||||
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
|
||||
"Proxy videos? ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟",
|
||||
"Proxy videos: ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟",
|
||||
"Default speed: ": "السرعة الإفتراضية: ",
|
||||
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
|
||||
"Player volume: ": "صوت المشغل: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "الترجمات الإفتراضية: ",
|
||||
"Fallback captions: ": "الترجمات المصاحبة: ",
|
||||
"Show related videos? ": "عرض مقاطع الفيديو ذات الصلة؟",
|
||||
"Show annotations by default? ": "عرض الملاحظات فى الفيديو تلقائيا ؟",
|
||||
"Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟",
|
||||
"Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟",
|
||||
"Visual preferences": "التفضيلات المرئية",
|
||||
"Dark mode: ": "الوضع الليلى: ",
|
||||
"Thin mode: ": "الوضع الخفيف: ",
|
||||
"Subscription preferences": "تفضيلات الإشتراك",
|
||||
"Show annotations by default for subscribed channels? ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
|
||||
"Show annotations by default for subscribed channels: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
|
||||
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
|
||||
"Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
|
||||
"Sort videos by: ": "ترتيب الفيديو بـ: ",
|
||||
@ -85,6 +85,9 @@
|
||||
"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": "إضافة\\إستخراج البيانات",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "إعدادات المدير",
|
||||
"Default homepage: ": "الصفحة الرئيسية الافتراضية ",
|
||||
"Feed menu: ": "قائمة التغذية",
|
||||
"Top enabled? ": "تفعيل 'الأفضل' ؟ ",
|
||||
"CAPTCHA enabled? ": "تفعيل الكابتشا ؟",
|
||||
"Login enabled? ": "تفعيل تسجيل الدخول ؟",
|
||||
"Registration enabled? ": "تفعيل التسجيل ؟",
|
||||
"Report statistics? ": "إبلاغ الإحصائيات",
|
||||
"Top enabled: ": "تفعيل 'الأفضل' ؟ ",
|
||||
"CAPTCHA enabled: ": "تفعيل الكابتشا ؟",
|
||||
"Login enabled: ": "تفعيل تسجيل الدخول ؟",
|
||||
"Registration enabled: ": "تفعيل التسجيل ؟",
|
||||
"Report statistics: ": "إبلاغ الإحصائيات",
|
||||
"Save preferences": "حفظ التفضيلات",
|
||||
"Subscription manager": "مدير الإشتراكات",
|
||||
"Token manager": "إداره الرمز",
|
||||
@ -133,6 +136,7 @@
|
||||
"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.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.",
|
||||
"View YouTube comments": "عرض تعليقات اليوتيوب",
|
||||
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "",
|
||||
"(edited)": "(تم تعديلة)",
|
||||
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
|
||||
"permalink": "الرابط",
|
||||
"`x` marked it with a ❤": "`x` اعجب بهذا",
|
||||
"Audio mode": "الوضع الصوتى",
|
||||
"Video mode": "وضع الفيديو",
|
||||
"Videos": "الفيديوهات",
|
||||
"Playlists": "قوائم التشغيل",
|
||||
"Community": "المجتمع",
|
||||
"Current version: ": "الإصدار الحالى"
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "Abbestellen",
|
||||
"Subscribe": "Abonnieren",
|
||||
"View channel on YouTube": "Kanal auf YouTube anzeigen",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen",
|
||||
"newest": "neueste",
|
||||
"oldest": "älteste",
|
||||
"popular": "beliebt",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Standardmäßig als nächstes abspielen: ",
|
||||
"Autoplay next video: ": "nächstes Video automatisch abspielen: ",
|
||||
"Listen by default: ": "Nur Ton als Standard: ",
|
||||
"Proxy videos? ": "Proxy-Videos? ",
|
||||
"Proxy videos: ": "Proxy-Videos? ",
|
||||
"Default speed: ": "Standardgeschwindigkeit: ",
|
||||
"Preferred video quality: ": "Bevorzugte Videoqualität: ",
|
||||
"Player volume: ": "Playerlautstärke: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "reddit",
|
||||
"Default captions: ": "Standarduntertitel: ",
|
||||
"Fallback captions: ": "Ersatzuntertitel: ",
|
||||
"Show related videos? ": "Ähnliche Videos anzeigen? ",
|
||||
"Show annotations by default? ": "Standardmäßig Anmerkungen anzeigen? ",
|
||||
"Show related videos: ": "Ähnliche Videos anzeigen? ",
|
||||
"Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
|
||||
"Visual preferences": "Anzeigeeinstellungen",
|
||||
"Dark mode: ": "Nachtmodus: ",
|
||||
"Thin mode: ": "Schlanker Modus: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ",
|
||||
"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 unwatched: ": "Nur ungesehene anzeigen: ",
|
||||
"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",
|
||||
"Clear watch history": "Verlauf löschen",
|
||||
"Import/export data": "Daten im- exportieren",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Administratoreinstellungen",
|
||||
"Default homepage: ": "Standard-Homepage: ",
|
||||
"Feed menu: ": "Feed-Menü: ",
|
||||
"Top enabled? ": "Top aktiviert? ",
|
||||
"CAPTCHA enabled? ": "CAPTCHA aktiviert? ",
|
||||
"Login enabled? ": "Login aktiviert? ",
|
||||
"Registration enabled? ": "Registrierung aktiviert? ",
|
||||
"Report statistics? ": "Statistiken berichten? ",
|
||||
"Top enabled: ": "Top aktiviert? ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA aktiviert? ",
|
||||
"Login enabled: ": "Login aktiviert? ",
|
||||
"Registration enabled: ": "Registrierung aktiviert? ",
|
||||
"Report statistics: ": "Statistiken berichten? ",
|
||||
"Save preferences": "Einstellungen speichern",
|
||||
"Subscription manager": "Abonnementverwaltung",
|
||||
"Token manager": "Token-Manager",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Geteilt `x`",
|
||||
"`x` views": "`x` Ansichten",
|
||||
"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.",
|
||||
"View YouTube comments": "YouTube Kommentare anzeigen",
|
||||
"View more comments on Reddit": "Mehr Kommentare auf Reddit anzeigen",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(editiert)",
|
||||
"YouTube comment permalink": "YouTube-Kommentar Permalink",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` markierte es mit einem ❤",
|
||||
"Audio mode": "Audiomodus",
|
||||
"Video mode": "Videomodus",
|
||||
"Videos": "Videos",
|
||||
"Playlists": "Wiedergabelisten",
|
||||
"Community": "",
|
||||
"Current version: ": "Aktuelle Version: "
|
||||
}
|
@ -62,7 +62,7 @@
|
||||
"Play next by default: ": "Αναπαραγωγή επόμενου: ",
|
||||
"Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ",
|
||||
"Listen by default: ": "Φόρτωση μόνο ήχου: ",
|
||||
"Proxy videos? ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
|
||||
"Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
|
||||
"Default speed: ": "Προεπιλεγμένη ταχύτητα: ",
|
||||
"Preferred video quality: ": "Προτιμώμενη ανάλυση: ",
|
||||
"Player volume: ": "Ένταση αναπαραγωγής: ",
|
||||
@ -71,13 +71,13 @@
|
||||
"reddit": "reddit",
|
||||
"Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ",
|
||||
"Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ",
|
||||
"Show related videos? ": "Προβολή σχετικών βίντεο; ",
|
||||
"Show annotations by default? ": "Αυτόματη προβολή σημειώσεων; :",
|
||||
"Show related videos: ": "Προβολή σχετικών βίντεο; ",
|
||||
"Show annotations by default: ": "Αυτόματη προβολή σημειώσεων; :",
|
||||
"Visual preferences": "Προτιμήσεις εμφάνισης",
|
||||
"Dark mode: ": "Σκοτεινή λειτουργία: ",
|
||||
"Thin mode: ": "Ελαφριά λειτουργία: ",
|
||||
"Subscription preferences": "Προτιμήσεις συνδρομών",
|
||||
"Show annotations by default for subscribed channels? ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
|
||||
"Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
|
||||
"Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ",
|
||||
"Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
|
||||
"Sort videos by: ": "Ταξινόμηση ανά: ",
|
||||
@ -91,6 +91,9 @@
|
||||
"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` is live": "",
|
||||
"Data preferences": "Προτιμήσεις δεδομένων",
|
||||
"Clear watch history": "Εκκαθάριση ιστορικού προβολής",
|
||||
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",
|
||||
@ -102,11 +105,11 @@
|
||||
"Administrator preferences": "Προτιμήσεις διαχειριστή",
|
||||
"Default homepage: ": "Προεπιλεγμένη αρχική: ",
|
||||
"Feed menu: ": "Μενού ροής συνδρομών: ",
|
||||
"Top enabled? ": "Ενεργοποίηση κορυφαίων; ",
|
||||
"CAPTCHA enabled? ": "Ενεργοποίηση CAPTCHA; ",
|
||||
"Login enabled? ": "Ενεργοποίηση σύνδεσης; ",
|
||||
"Registration enabled? ": "Ενεργοποίηση εγγραφής; ",
|
||||
"Report statistics? ": "Αναφορά στατιστικών; ",
|
||||
"Top enabled: ": "Ενεργοποίηση κορυφαίων; ",
|
||||
"CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ",
|
||||
"Login enabled: ": "Ενεργοποίηση σύνδεσης; ",
|
||||
"Registration enabled: ": "Ενεργοποίηση εγγραφής; ",
|
||||
"Report statistics: ": "Αναφορά στατιστικών; ",
|
||||
"Save preferences": "Αποθήκευση προτιμήσεων",
|
||||
"Subscription manager": "Διαχειριστής συνδρομών",
|
||||
"Token manager": "Διαχειριστής διασυνδέσεων",
|
||||
@ -151,6 +154,7 @@
|
||||
"": "`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. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ",
|
||||
"View YouTube comments": "Προβολή σχολίων από το YouTube",
|
||||
"View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit",
|
||||
@ -351,10 +355,12 @@
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(τροποποιημένο)",
|
||||
"YouTube comment permalink": "Σύνδεσμος YouTube σχολίου",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤",
|
||||
"Audio mode": "Λειτουργία ήχου",
|
||||
"Video mode": "Λειτουργία βίντεο",
|
||||
"Videos": "Βίντεο",
|
||||
"Playlists": "Λίστες Αναπαραγωγής",
|
||||
"Community": "",
|
||||
"Current 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 videos? ",
|
||||
"Proxy videos: ": "Proxy videos: ",
|
||||
"Default speed: ": "Default speed: ",
|
||||
"Preferred video quality: ": "Preferred video quality: ",
|
||||
"Player volume: ": "Player volume: ",
|
||||
@ -71,13 +71,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? ",
|
||||
"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? ",
|
||||
"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": "Enable web notifications",
|
||||
"`x` uploaded a video": "`x` uploaded a video",
|
||||
"`x` is live": "`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 enabled? ",
|
||||
"Login enabled? ": "Login enabled? ",
|
||||
"Registration enabled? ": "Registration enabled? ",
|
||||
"Report statistics? ": "Report statistics? ",
|
||||
"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",
|
||||
@ -151,6 +154,7 @@
|
||||
"": "`x` views"
|
||||
},
|
||||
"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.",
|
||||
"View YouTube comments": "View YouTube comments",
|
||||
"View more comments on Reddit": "View more comments on Reddit",
|
||||
@ -351,10 +355,12 @@
|
||||
"%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",
|
||||
"Videos": "Videos",
|
||||
"Playlists": "Playlists",
|
||||
"Community": "Community",
|
||||
"Current version: ": "Current version: "
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "Malaboni",
|
||||
"Subscribe": "Aboni",
|
||||
"View channel on YouTube": "Vidi kanalon en YouTube",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "Vidi ludliston en YouTube",
|
||||
"newest": "pli novaj",
|
||||
"oldest": "pli malnovaj",
|
||||
"popular": "popularaj",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Ludi sekvan defaŭlte: ",
|
||||
"Autoplay next video: ": "Aŭtomate ludi sekvan videon: ",
|
||||
"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: ",
|
||||
"Preferred video quality: ": "Preferita videkvalito: ",
|
||||
"Player volume: ": "Ludila sonforteco: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "reddit",
|
||||
"Default captions: ": "Defaŭltaj subtekstoj: ",
|
||||
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
|
||||
"Show related videos? ": "Ĉu montri rilatajn videojn? ",
|
||||
"Show annotations by default? ": "Ĉu montri prinotojn defaŭlte? ",
|
||||
"Show related videos: ": "Ĉu montri rilatajn videojn? ",
|
||||
"Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
|
||||
"Visual preferences": "Vidaj preferoj",
|
||||
"Dark mode: ": "Malhela reĝimo: ",
|
||||
"Thin mode: ": "Maldika reĝimo: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ",
|
||||
"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 unwatched: ": "Nur montri malviditajn: ",
|
||||
"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",
|
||||
"Clear watch history": "Forigi vidohistorion",
|
||||
"Import/export data": "Importi/Eksporti datumojn",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Agordoj de administranto",
|
||||
"Default homepage: ": "Defaŭlta hejmpaĝo: ",
|
||||
"Feed menu: ": "Flua menuo: ",
|
||||
"Top enabled? ": "Ĉu pli bonaj ŝaltitaj? ",
|
||||
"CAPTCHA enabled? ": "Ĉu CAPTCHA ŝaltita? ",
|
||||
"Login enabled? ": "Ĉu ensaluto aktivita? ",
|
||||
"Registration enabled? ": "Ĉu registriĝo aktivita? ",
|
||||
"Report statistics? ": "Ĉu raporti statistikojn? ",
|
||||
"Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ",
|
||||
"CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ",
|
||||
"Login enabled: ": "Ĉu ensaluto aktivita? ",
|
||||
"Registration enabled: ": "Ĉu registriĝo aktivita? ",
|
||||
"Report statistics: ": "Ĉu raporti statistikojn? ",
|
||||
"Save preferences": "Konservi agordojn",
|
||||
"Subscription manager": "Administrilo de abonoj",
|
||||
"Token manager": "Ĵetona administrilo",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Konigita `x`",
|
||||
"`x` views": "`x` spektaĵoj",
|
||||
"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.",
|
||||
"View YouTube comments": "Vidi komentojn de YouTube",
|
||||
"View more comments on Reddit": "Vidi pli komentoj en Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%A %-d de %B %Y",
|
||||
"(edited)": "(redaktita)",
|
||||
"YouTube comment permalink": "Fiksligilo de la komento en YouTube",
|
||||
"permalink": "konstanta ligilo",
|
||||
"`x` marked it with a ❤": "`x` markis ĝin per ❤",
|
||||
"Audio mode": "Aŭda reĝimo",
|
||||
"Video mode": "Videa reĝimo",
|
||||
"Videos": "Videoj",
|
||||
"Playlists": "Ludlistoj",
|
||||
"Community": "Komunumo",
|
||||
"Current version: ": "Nuna versio: "
|
||||
}
|
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Reproducir siguiente por defecto: ",
|
||||
"Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ",
|
||||
"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: ",
|
||||
"Preferred video quality: ": "Calidad de vídeo preferida: ",
|
||||
"Player volume: ": "Volumen del reproductor: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Subtítulos por defecto: ",
|
||||
"Fallback captions: ": "Subtítulos alternativos: ",
|
||||
"Show related videos? ": "¿Mostrar vídeos relacionados? ",
|
||||
"Show annotations by default? ": "¿Mostrar anotaciones por defecto? ",
|
||||
"Show related videos: ": "¿Mostrar vídeos relacionados? ",
|
||||
"Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
|
||||
"Visual preferences": "Preferencias visuales",
|
||||
"Dark mode: ": "Modo oscuro: ",
|
||||
"Thin mode: ": "Modo compacto: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ",
|
||||
"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 unwatched: ": "Mostrar solo los no vistos: ",
|
||||
"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",
|
||||
"Clear watch history": "Borrar el historial de reproducción",
|
||||
"Import/export data": "Importar/Exportar datos",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Preferencias de administrador",
|
||||
"Default homepage: ": "Página de inicio por defecto: ",
|
||||
"Feed menu: ": "Menú de fuentes: ",
|
||||
"Top enabled? ": "¿Habilitar los destacados? ",
|
||||
"CAPTCHA enabled? ": "¿Habilitar los CAPTCHA? ",
|
||||
"Login enabled? ": "¿Habilitar el inicio de sesión? ",
|
||||
"Registration enabled? ": "¿Habilitar el registro? ",
|
||||
"Report statistics? ": "¿Enviar estadísticas? ",
|
||||
"Top enabled: ": "¿Habilitar los destacados? ",
|
||||
"CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ",
|
||||
"Login enabled: ": "¿Habilitar el inicio de sesión? ",
|
||||
"Registration enabled: ": "¿Habilitar el registro? ",
|
||||
"Report statistics: ": "¿Enviar estadísticas? ",
|
||||
"Save preferences": "Guardar las preferencias",
|
||||
"Subscription manager": "Gestor de suscripciones",
|
||||
"Token manager": "Gestor de tokens",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Compartido `x`",
|
||||
"`x` views": "`x` visualizaciones",
|
||||
"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.",
|
||||
"View YouTube comments": "Ver los comentarios de YouTube",
|
||||
"View more comments on Reddit": "Ver más comentarios en Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(editado)",
|
||||
"YouTube comment permalink": "Enlace permanente de YouTube del comentario",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
|
||||
"Audio mode": "Modo de audio",
|
||||
"Video mode": "Modo de vídeo",
|
||||
"Videos": "Vídeos",
|
||||
"Playlists": "Listas de reproducción",
|
||||
"Community": "",
|
||||
"Current version: ": "Versión actual: "
|
||||
}
|
||||
}
|
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "",
|
||||
"Autoplay next video: ": "",
|
||||
"Listen by default: ": "",
|
||||
"Proxy videos? ": "",
|
||||
"Proxy videos: ": "",
|
||||
"Default speed: ": "",
|
||||
"Preferred video quality: ": "",
|
||||
"Player volume: ": "",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "",
|
||||
"Default captions: ": "",
|
||||
"Fallback captions: ": "",
|
||||
"Show related videos? ": "",
|
||||
"Show annotations by default? ": "",
|
||||
"Show related videos: ": "",
|
||||
"Show annotations by default: ": "",
|
||||
"Visual preferences": "",
|
||||
"Dark mode: ": "",
|
||||
"Thin mode: ": "",
|
||||
"Subscription preferences": "",
|
||||
"Show annotations by default for subscribed channels? ": "",
|
||||
"Show annotations by default for subscribed channels: ": "",
|
||||
"Redirect homepage to feed: ": "",
|
||||
"Number of videos shown in feed: ": "",
|
||||
"Sort videos by: ": "",
|
||||
@ -85,6 +85,9 @@
|
||||
"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` is live": "",
|
||||
"Data preferences": "",
|
||||
"Clear watch history": "",
|
||||
"Import/export data": "",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "",
|
||||
"Default homepage: ": "",
|
||||
"Feed menu: ": "",
|
||||
"Top enabled? ": "",
|
||||
"CAPTCHA enabled? ": "",
|
||||
"Login enabled? ": "",
|
||||
"Registration enabled? ": "",
|
||||
"Report statistics? ": "",
|
||||
"Top enabled: ": "",
|
||||
"CAPTCHA enabled: ": "",
|
||||
"Login enabled: ": "",
|
||||
"Registration enabled: ": "",
|
||||
"Report statistics: ": "",
|
||||
"Save preferences": "",
|
||||
"Subscription manager": "",
|
||||
"Token manager": "",
|
||||
@ -306,6 +309,7 @@
|
||||
"%A %B %-d, %Y": "",
|
||||
"(edited)": "",
|
||||
"YouTube comment permalink": "",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "",
|
||||
"Audio mode": "",
|
||||
"Video mode": "",
|
||||
|
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Jouer suirvante par défaut : ",
|
||||
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
|
||||
"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 : ",
|
||||
"Preferred video quality: ": "Qualité vidéo souhaitée : ",
|
||||
"Player volume: ": "Volume du lecteur : ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Sous-titres par défaut : ",
|
||||
"Fallback captions: ": "Sous-titres de repli : ",
|
||||
"Show related videos? ": "Voir les vidéos liées ? ",
|
||||
"Show annotations by default? ": "Voir les annotations par défaut ? ",
|
||||
"Show related videos: ": "Voir les vidéos liées ? ",
|
||||
"Show annotations by default: ": "Voir les annotations par défaut ? ",
|
||||
"Visual preferences": "Préférences du site",
|
||||
"Dark mode: ": "Mode Sombre : ",
|
||||
"Thin mode: ": "Mode Simplifié : ",
|
||||
"Subscription preferences": "Préférences de la page d'abonnements",
|
||||
"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 : ",
|
||||
"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 : ",
|
||||
@ -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 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) : ",
|
||||
"Enable web notifications": "",
|
||||
"`x` uploaded a video": "",
|
||||
"`x` is live": "",
|
||||
"Data preferences": "Préférences liées aux données",
|
||||
"Clear watch history": "Supprimer l'historique des vidéos regardées",
|
||||
"Import/export data": "Importer/exporter les données",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Préferences d'Administrateur",
|
||||
"Default homepage: ": "Page d'accueil par défaut : ",
|
||||
"Feed menu: ": "Menu des Flux : ",
|
||||
"Top enabled? ": "Top activé ? ",
|
||||
"CAPTCHA enabled? ": "CAPTCHA activé ? ",
|
||||
"Login enabled? ": "Connexion activé ? ",
|
||||
"Registration enabled? ": "Inscription activée ? ",
|
||||
"Report statistics? ": "Télémétrie activé ? ",
|
||||
"Top enabled: ": "Top activé ? ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA activé ? ",
|
||||
"Login enabled: ": "Connexion activé ? ",
|
||||
"Registration enabled: ": "Inscription activée ? ",
|
||||
"Report statistics: ": "Télémétrie activé ? ",
|
||||
"Save preferences": "Enregistrer les préférences",
|
||||
"Subscription manager": "Gestionnaire d'abonnement",
|
||||
"Token manager": "Gestionnaire de tokens",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Ajoutée le `x`",
|
||||
"`x` views": "`x` vues",
|
||||
"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.",
|
||||
"View YouTube comments": "Voir les commentaires YouTube",
|
||||
"View more comments on Reddit": "Voir plus de commentaires sur Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||
"(edited)": "(modifié)",
|
||||
"YouTube comment permalink": "Lien YouTube permanent vers le commentaire",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
|
||||
"Audio mode": "Mode Audio",
|
||||
"Video mode": "Mode Vidéo",
|
||||
"Videos": "Vidéos",
|
||||
"Playlists": "Liste de lecture",
|
||||
"Community": "",
|
||||
"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: ",
|
||||
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
|
||||
"Listen by default: ": "Modalità solo audio come predefinita: ",
|
||||
"Proxy videos? ": "",
|
||||
"Proxy videos: ": "",
|
||||
"Default speed: ": "Velocità di riproduzione predefinita: ",
|
||||
"Preferred video quality: ": "Preferenza sulla qualità video: ",
|
||||
"Player volume: ": "Volume di riproduzione: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "",
|
||||
"Default captions: ": "Sottotitoli predefiniti: ",
|
||||
"Fallback captions: ": "Sottotitoli alternativi: ",
|
||||
"Show related videos? ": "Mostra video correlati? ",
|
||||
"Show annotations by default? ": "Mostra le annotazioni per impostazione predefinita? ",
|
||||
"Show related videos: ": "Mostra video correlati? ",
|
||||
"Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ",
|
||||
"Visual preferences": "Preferenze grafiche",
|
||||
"Dark mode: ": "Tema scuro: ",
|
||||
"Thin mode: ": "Modalità per connessioni lente: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ",
|
||||
"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 unwatched: ": "Mostra solo i video non guardati: ",
|
||||
"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",
|
||||
"Clear watch history": "Cancella la cronologia dei video guardati",
|
||||
"Import/export data": "Importazione/esportazione dati",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "",
|
||||
"Default homepage: ": "",
|
||||
"Feed menu: ": "",
|
||||
"Top enabled? ": "",
|
||||
"CAPTCHA enabled? ": "",
|
||||
"Login enabled? ": "",
|
||||
"Registration enabled? ": "",
|
||||
"Report statistics? ": "",
|
||||
"Top enabled: ": "",
|
||||
"CAPTCHA enabled: ": "",
|
||||
"Login enabled: ": "",
|
||||
"Registration enabled: ": "",
|
||||
"Report statistics: ": "",
|
||||
"Save preferences": "Salva le preferenze",
|
||||
"Subscription manager": "Gestisci le iscrizioni",
|
||||
"Token manager": "",
|
||||
@ -306,10 +309,12 @@
|
||||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||
"(edited)": "(modificato)",
|
||||
"YouTube comment permalink": "Link permanente al commento di YouTube",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
|
||||
"Audio mode": "Modalità audio",
|
||||
"Video mode": "Modalità video",
|
||||
"Videos": "",
|
||||
"Playlists": "",
|
||||
"Community": "",
|
||||
"Current version: ": ""
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "Opphev abonnement",
|
||||
"Subscribe": "Abonner",
|
||||
"View channel on YouTube": "Vis kanal på YouTube",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "Vis spilleliste på YouTube",
|
||||
"newest": "nyeste",
|
||||
"oldest": "eldste",
|
||||
"popular": "populært",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Spill neste som forvalg: ",
|
||||
"Autoplay next video: ": "Autospill neste video: ",
|
||||
"Listen by default: ": "Lytt som forvalg: ",
|
||||
"Proxy videos? ": "Mellomtjen videoer? ",
|
||||
"Proxy videos: ": "Mellomtjen videoer? ",
|
||||
"Default speed: ": "Forvalgt hastighet: ",
|
||||
"Preferred video quality: ": "Foretrukket videokvalitet: ",
|
||||
"Player volume: ": "Avspillerlydstyrke: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Forvalgte undertitler: ",
|
||||
"Fallback captions: ": "Tilbakefallsundertitler: ",
|
||||
"Show related videos? ": "Vis relaterte videoer? ",
|
||||
"Show annotations by default? ": "Vis merknader som forvalg? ",
|
||||
"Show related videos: ": "Vis relaterte videoer? ",
|
||||
"Show annotations by default: ": "Vis merknader som forvalg? ",
|
||||
"Visual preferences": "Visuelle innstillinger",
|
||||
"Dark mode: ": "Mørk drakt: ",
|
||||
"Thin mode: ": "Tynt modus: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Antall videoer å vise i flyt: ",
|
||||
"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 unwatched: ": "Kun vis usette: ",
|
||||
"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",
|
||||
"Clear watch history": "Tøm visningshistorikk",
|
||||
"Import/export data": "Importer/eksporter data",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Administratorinnstillinger",
|
||||
"Default homepage: ": "Forvalgt hjemmeside: ",
|
||||
"Feed menu: ": "Flyt-meny: ",
|
||||
"Top enabled? ": "Topp påskrudd? ",
|
||||
"CAPTCHA enabled? ": "CAPTCHA påskrudd? ",
|
||||
"Login enabled? ": "Innlogging påskrudd? ",
|
||||
"Registration enabled? ": "Registrering påskrudd? ",
|
||||
"Report statistics? ": "Innrapporter statistikk? ",
|
||||
"Top enabled: ": "Topp påskrudd? ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA påskrudd? ",
|
||||
"Login enabled: ": "Innlogging påskrudd? ",
|
||||
"Registration enabled: ": "Registrering påskrudd? ",
|
||||
"Report statistics: ": "Innrapporter statistikk? ",
|
||||
"Save preferences": "Lagre innstillinger",
|
||||
"Subscription manager": "Abonnementsbehandler",
|
||||
"Token manager": "Symbolbehandler",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Delt `x`",
|
||||
"`x` views": "`x` visninger",
|
||||
"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.",
|
||||
"View YouTube comments": "Vis YouTube-kommentarer",
|
||||
"View more comments on Reddit": "Vis flere kommenterer på Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "",
|
||||
"(edited)": "(redigert)",
|
||||
"YouTube comment permalink": "Permanent YouTube-lenke til innholdet",
|
||||
"permalink": "permanent lenke",
|
||||
"`x` marked it with a ❤": "`x` levnet et ❤",
|
||||
"Audio mode": "Lydmodus",
|
||||
"Video mode": "Video-modus",
|
||||
"Videos": "Videoer",
|
||||
"Playlists": "Spillelister",
|
||||
"Community": "",
|
||||
"Current version: ": "Nåværende versjon: "
|
||||
}
|
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Standaard volgende video afspelen: ",
|
||||
"Autoplay next video: ": "Volgende video automatisch afspelen: ",
|
||||
"Listen by default: ": "Standaard luisteren: ",
|
||||
"Proxy videos? ": "Video's afspelen via proxy? ",
|
||||
"Proxy videos: ": "Video's afspelen via proxy? ",
|
||||
"Default speed: ": "Standaard afspeelsnelheid: ",
|
||||
"Preferred video quality: ": "Voorkeurskwaliteit: ",
|
||||
"Player volume: ": "Spelervolume: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Standaard ondertiteling: ",
|
||||
"Fallback captions: ": "Alternatieve ondertiteling: ",
|
||||
"Show related videos? ": "Gerelateerde video's tonen? ",
|
||||
"Show annotations by default? ": "Standaard annotaties tonen? ",
|
||||
"Show related videos: ": "Gerelateerde video's tonen? ",
|
||||
"Show annotations by default: ": "Standaard annotaties tonen? ",
|
||||
"Visual preferences": "Visuele instellingen",
|
||||
"Dark mode: ": "Donkere modus: ",
|
||||
"Thin mode: ": "Smalle modus: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Aantal te tonen video's in feed: ",
|
||||
"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 unwatched: ": "Alleen niet-bekeken videos tonen: ",
|
||||
"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",
|
||||
"Clear watch history": "Kijkgeschiedenis wissen",
|
||||
"Import/export data": "Gegevens im-/exporteren",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Beheerdersinstellingen",
|
||||
"Default homepage: ": "Standaard startpagina: ",
|
||||
"Feed menu: ": "Feedmenu:",
|
||||
"Top enabled? ": "Bovenkant inschakelen? ",
|
||||
"CAPTCHA enabled? ": "CAPTCHA gebruiken? ",
|
||||
"Login enabled? ": "Inloggen toestaan? ",
|
||||
"Registration enabled? ": "Registratie toestaan? ",
|
||||
"Report statistics? ": "Statistieken bijhouden? ",
|
||||
"Top enabled: ": "Bovenkant inschakelen? ",
|
||||
"CAPTCHA enabled: ": "CAPTCHA gebruiken? ",
|
||||
"Login enabled: ": "Inloggen toestaan? ",
|
||||
"Registration enabled: ": "Registratie toestaan? ",
|
||||
"Report statistics: ": "Statistieken bijhouden? ",
|
||||
"Save preferences": "Instellingen opslaan",
|
||||
"Subscription manager": "Abonnementen beheren",
|
||||
"Token manager": "Toegangssleutels beheren",
|
||||
@ -114,13 +117,13 @@
|
||||
"`x` unseen notifications": "`x` ongelezen meldingen",
|
||||
"search": "zoeken",
|
||||
"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.",
|
||||
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
|
||||
"View privacy policy.": "Privacybeleid tonen",
|
||||
"Trending": "Uitgelicht",
|
||||
"Unlisted": "Verborgen",
|
||||
"Watch on YouTube": "Bekijk video op YouTube",
|
||||
"Watch on YouTube": "Video bekijken op YouTube",
|
||||
"Hide annotations": "Annotaties verbergen",
|
||||
"Show annotations": "Annotaties tonen",
|
||||
"Genre: ": "Genre: ",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "`x` gedeeld",
|
||||
"`x` views": "`x` weergaven",
|
||||
"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.",
|
||||
"View YouTube comments": "YouTube-reacties tonen",
|
||||
"View more comments on Reddit": "Meer reacties bekijken op Reddit",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%A %B %-d, %Y",
|
||||
"(edited)": "(bewerkt)",
|
||||
"YouTube comment permalink": "Link naar YouTube-reactie",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
|
||||
"Audio mode": "Audiomodus",
|
||||
"Video mode": "Videomodus",
|
||||
"Videos": "Video's",
|
||||
"Playlists": "Afspeellijsten",
|
||||
"Community": "",
|
||||
"Current version: ": "Huidige versie: "
|
||||
}
|
||||
}
|
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "",
|
||||
"Autoplay next video: ": "Odtwórz następny film: ",
|
||||
"Listen by default: ": "Tryb dźwiękowy: ",
|
||||
"Proxy videos? ": "Filmy przez proxy? ",
|
||||
"Proxy videos: ": "Filmy przez proxy? ",
|
||||
"Default speed: ": "Domyślna prędkość: ",
|
||||
"Preferred video quality: ": "Preferowana jakość filmów: ",
|
||||
"Player volume: ": "Głośność odtwarzacza: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "",
|
||||
"Default captions: ": "Domyślne napisy: ",
|
||||
"Fallback captions: ": "Zastępcze napisy: ",
|
||||
"Show related videos? ": "Pokaż powiązane filmy? ",
|
||||
"Show annotations by default? ": "",
|
||||
"Show related videos: ": "Pokaż powiązane filmy? ",
|
||||
"Show annotations by default: ": "",
|
||||
"Visual preferences": "Preferencje Wizualne",
|
||||
"Dark mode: ": "Ciemny motyw: ",
|
||||
"Thin mode: ": "Tryb minimalny: ",
|
||||
"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: ",
|
||||
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
|
||||
"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 unwatched: ": "Pokazuj tylko nie obejrzane: ",
|
||||
"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",
|
||||
"Clear watch history": "Wyczyść historię",
|
||||
"Import/export data": "Import/Eksport danych",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Preferencje administratora",
|
||||
"Default homepage: ": "Domyślna strona główna: ",
|
||||
"Feed menu: ": "",
|
||||
"Top enabled? ": "",
|
||||
"CAPTCHA enabled? ": "CAPTCHA aktywna? ",
|
||||
"Login enabled? ": "Logowanie włączone? ",
|
||||
"Registration enabled? ": "Rejestracja włączona? ",
|
||||
"Report statistics? ": "Raportować statystyki? ",
|
||||
"Top enabled: ": "",
|
||||
"CAPTCHA enabled: ": "CAPTCHA aktywna? ",
|
||||
"Login enabled: ": "Logowanie włączone? ",
|
||||
"Registration enabled: ": "Rejestracja włączona? ",
|
||||
"Report statistics: ": "Raportować statystyki? ",
|
||||
"Save preferences": "Zapisz preferencje",
|
||||
"Subscription manager": "Manager subskrybcji",
|
||||
"Token manager": "",
|
||||
@ -133,6 +136,7 @@
|
||||
"Shared `x`": "Udostępniono `x`",
|
||||
"`x` views": "`x` wyświetleń",
|
||||
"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.",
|
||||
"View YouTube comments": "Wyświetl komentarze z YouTube",
|
||||
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "",
|
||||
"(edited)": "(edytowany)",
|
||||
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "`x` oznaczonych ❤",
|
||||
"Audio mode": "Tryb audio",
|
||||
"Video mode": "Tryb wideo",
|
||||
"Videos": "Filmy",
|
||||
"Playlists": "Playlisty",
|
||||
"Community": "",
|
||||
"Current version: ": "Aktualna wersja: "
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "Отписаться",
|
||||
"Subscribe": "Подписаться",
|
||||
"View channel on YouTube": "Смотреть канал на YouTube",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "Посмотреть плейлист на YouTube",
|
||||
"newest": "самые свежие",
|
||||
"oldest": "самые старые",
|
||||
"popular": "популярные",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Всегда включать следующее видео? ",
|
||||
"Autoplay next video: ": "Автопроигрывание следующего видео: ",
|
||||
"Listen by default: ": "Режим «только аудио» по умолчанию: ",
|
||||
"Proxy videos? ": "Проигрывать видео через прокси? ",
|
||||
"Proxy videos: ": "Проигрывать видео через прокси? ",
|
||||
"Default speed: ": "Скорость видео по умолчанию: ",
|
||||
"Preferred video quality: ": "Предпочтительное качество видео: ",
|
||||
"Player volume: ": "Громкость видео: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Основной язык субтитров: ",
|
||||
"Fallback captions: ": "Дополнительный язык субтитров: ",
|
||||
"Show related videos? ": "Показывать похожие видео? ",
|
||||
"Show annotations by default? ": "Всегда показывать аннотации? ",
|
||||
"Show related videos: ": "Показывать похожие видео? ",
|
||||
"Show annotations by default: ": "Всегда показывать аннотации? ",
|
||||
"Visual preferences": "Настройки сайта",
|
||||
"Dark mode: ": "Тёмное оформление: ",
|
||||
"Thin mode: ": "Облегчённое оформление: ",
|
||||
"Subscription preferences": "Настройки подписок",
|
||||
"Show annotations by default for subscribed channels? ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
|
||||
"Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
|
||||
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
|
||||
"Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ",
|
||||
"Sort videos by: ": "Сортировать видео: ",
|
||||
@ -85,6 +85,9 @@
|
||||
"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": "Импорт/Экспорт данных",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Администраторские настройки",
|
||||
"Default homepage: ": "Главная страница по умолчанию: ",
|
||||
"Feed menu: ": "Меню ленты видео: ",
|
||||
"Top enabled? ": "Включить топ видео? ",
|
||||
"CAPTCHA enabled? ": "Включить капчу? ",
|
||||
"Login enabled? ": "Включить авторизацию? ",
|
||||
"Registration enabled? ": "Включить регистрацию? ",
|
||||
"Report statistics? ": "Сообщать статистику? ",
|
||||
"Top enabled: ": "Включить топ видео? ",
|
||||
"CAPTCHA enabled: ": "Включить капчу? ",
|
||||
"Login enabled: ": "Включить авторизацию? ",
|
||||
"Registration enabled: ": "Включить регистрацию? ",
|
||||
"Report statistics: ": "Сообщать статистику? ",
|
||||
"Save preferences": "Сохранить настройки",
|
||||
"Subscription manager": "Менеджер подписок",
|
||||
"Token manager": "Менеджер токенов",
|
||||
@ -133,6 +136,7 @@
|
||||
"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",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||
"(edited)": "(изменено)",
|
||||
"YouTube comment permalink": "Прямая ссылка на YouTube",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
|
||||
"Audio mode": "Аудио режим",
|
||||
"Video mode": "Видео режим",
|
||||
"Videos": "Видео",
|
||||
"Playlists": "Плейлисты",
|
||||
"Community": "",
|
||||
"Current version: ": "Текущая версия: "
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"Unsubscribe": "Відписатися",
|
||||
"Subscribe": "Підписатися",
|
||||
"View channel on YouTube": "Подивитися канал на YouTube",
|
||||
"View playlist on YouTube": "",
|
||||
"View playlist on YouTube": "Подивитися плейлист на YouTube",
|
||||
"newest": "найновіше",
|
||||
"oldest": "найстаріше",
|
||||
"popular": "популярне",
|
||||
@ -56,7 +56,7 @@
|
||||
"Play next by default: ": "Завжди вмикати наступне відео: ",
|
||||
"Autoplay next video: ": "Автовідтворення наступного відео: ",
|
||||
"Listen by default: ": "Режим «тільки звук» як усталений: ",
|
||||
"Proxy videos? ": "Програвати відео через проксі? ",
|
||||
"Proxy videos: ": "Програвати відео через проксі? ",
|
||||
"Default speed: ": "Усталена швидкість відео: ",
|
||||
"Preferred video quality: ": "Пріорітетна якість відео: ",
|
||||
"Player volume: ": "Гучність відео: ",
|
||||
@ -65,13 +65,13 @@
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Основна мова субтитрів: ",
|
||||
"Fallback captions: ": "Запасна мова субтитрів: ",
|
||||
"Show related videos? ": "Показувати схожі відео? ",
|
||||
"Show annotations by default? ": "Завжди показувати анотації? ",
|
||||
"Show related videos: ": "Показувати схожі відео? ",
|
||||
"Show annotations by default: ": "Завжди показувати анотації? ",
|
||||
"Visual preferences": "Налаштування сайту",
|
||||
"Dark mode: ": "Темне оформлення: ",
|
||||
"Thin mode: ": "Полегшене оформлення: ",
|
||||
"Subscription preferences": "Налаштування підписок",
|
||||
"Show annotations by default for subscribed channels? ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
|
||||
"Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
|
||||
"Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ",
|
||||
"Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ",
|
||||
"Sort videos by: ": "Сортувати відео: ",
|
||||
@ -85,6 +85,9 @@
|
||||
"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": "Імпорт і експорт даних",
|
||||
@ -96,11 +99,11 @@
|
||||
"Administrator preferences": "Адміністраторські налаштування",
|
||||
"Default homepage: ": "Усталена домашня сторінка: ",
|
||||
"Feed menu: ": "Меню потоку з відео: ",
|
||||
"Top enabled? ": "Увімкнути топ відео? ",
|
||||
"CAPTCHA enabled? ": "Увімкнути капчу? ",
|
||||
"Login enabled? ": "Увімкнути авторизацію? ",
|
||||
"Registration enabled? ": "Увімкнути реєстрацію? ",
|
||||
"Report statistics? ": "Повідомляти статистику? ",
|
||||
"Top enabled: ": "Увімкнути топ відео? ",
|
||||
"CAPTCHA enabled: ": "Увімкнути капчу? ",
|
||||
"Login enabled: ": "Увімкнути авторизацію? ",
|
||||
"Registration enabled: ": "Увімкнути реєстрацію? ",
|
||||
"Report statistics: ": "Повідомляти статистику? ",
|
||||
"Save preferences": "Зберегти налаштування",
|
||||
"Subscription manager": "Менеджер підписок",
|
||||
"Token manager": "Менеджер токенів",
|
||||
@ -133,6 +136,7 @@
|
||||
"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",
|
||||
@ -306,10 +310,12 @@
|
||||
"%A %B %-d, %Y": "%-d %B %Y, %A",
|
||||
"(edited)": "(змінено)",
|
||||
"YouTube comment permalink": "Пряме посилання на коментар в YouTube",
|
||||
"permalink": "",
|
||||
"`x` marked it with a ❤": "❤ цьому від каналу `x`",
|
||||
"Audio mode": "Аудіорежим",
|
||||
"Video mode": "Відеорежим",
|
||||
"Videos": "Відео",
|
||||
"Playlists": "Плейлисти",
|
||||
"Community": "",
|
||||
"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
|
||||
version: 0.17.0
|
||||
version: 0.19.0
|
||||
|
||||
authors:
|
||||
- Omar Roth <omarroth@protonmail.com>
|
||||
@ -9,13 +9,13 @@ targets:
|
||||
main: src/invidious.cr
|
||||
|
||||
dependencies:
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
pg:
|
||||
github: will/crystal-pg
|
||||
sqlite3:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
|
||||
crystal: 0.28.0
|
||||
crystal: 0.29.0
|
||||
|
||||
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
|
||||
def to_json(locale, config, kemal_config, json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "type", "shortVideo"
|
||||
|
||||
json.field "title", self.title
|
||||
json.field "videoId", self.id
|
||||
json.field "videoThumbnails" do
|
||||
@ -39,6 +41,48 @@ struct ChannelVideo
|
||||
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({
|
||||
id: String,
|
||||
title: String,
|
||||
@ -53,6 +97,36 @@ struct ChannelVideo
|
||||
})
|
||||
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)
|
||||
finished_channel = Channel(String | Nil).new
|
||||
|
||||
@ -91,10 +165,8 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
|
||||
end
|
||||
|
||||
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)
|
||||
channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
|
||||
|
||||
if refresh && Time.now - channel.updated > 10.minutes
|
||||
if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
|
||||
if refresh && Time.utc - channel.updated > 10.minutes
|
||||
channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
|
||||
channel_array = channel.to_a
|
||||
args = arg_array(channel_array)
|
||||
@ -175,7 +247,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
||||
id: video_id,
|
||||
title: title,
|
||||
published: published,
|
||||
updated: Time.now,
|
||||
updated: Time.utc,
|
||||
ucid: ucid,
|
||||
author: author,
|
||||
length_seconds: length_seconds,
|
||||
@ -184,7 +256,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
||||
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",
|
||||
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, \
|
||||
live_now = $8, views = $10", video_array)
|
||||
|
||||
users.each do |user|
|
||||
payload = {
|
||||
"email" => user,
|
||||
"action" => "refresh",
|
||||
}.to_json
|
||||
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
|
||||
# Update all users affected by insert
|
||||
if emails.empty?
|
||||
values = "'{}'"
|
||||
else
|
||||
values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
|
||||
end
|
||||
|
||||
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
|
||||
end
|
||||
|
||||
if pull_all_videos
|
||||
@ -237,7 +310,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
published: video.published,
|
||||
updated: Time.now,
|
||||
updated: Time.utc,
|
||||
ucid: video.ucid,
|
||||
author: video.author,
|
||||
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,
|
||||
# so since they don't provide a published date here we can safely ignore them.
|
||||
if Time.now - video.published > 1.minute
|
||||
users = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
||||
if Time.utc - video.published > 1.minute
|
||||
emails = db.query_all("UPDATE users SET notifications = notifications || $1 \
|
||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
|
||||
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)
|
||||
|
||||
# Update all users affected by insert
|
||||
users.each do |user|
|
||||
payload = {
|
||||
"email" => user,
|
||||
"action" => "refresh",
|
||||
}.to_json
|
||||
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
|
||||
if emails.empty?
|
||||
values = "'{}'"
|
||||
else
|
||||
values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
|
||||
end
|
||||
|
||||
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
|
||||
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)
|
||||
end
|
||||
|
||||
channel = InvidiousChannel.new(ucid, author, Time.now, false, nil)
|
||||
channel = InvidiousChannel.new(ucid, author, Time.utc, false, nil)
|
||||
|
||||
return channel
|
||||
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)
|
||||
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)
|
||||
|
||||
if json["load_more_widget_html"].as_s.empty?
|
||||
return [] of SearchItem, nil
|
||||
end
|
||||
continuation = nil
|
||||
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)
|
||||
continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href]))
|
||||
if continuation
|
||||
continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
|
||||
if continuation
|
||||
continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
seed = Time.unix(1525757349)
|
||||
|
||||
until seed >= Time.now
|
||||
until seed >= Time.utc
|
||||
seed += 1.month
|
||||
end
|
||||
timestamp = seed - (page - 1).months
|
||||
@ -387,53 +441,57 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
|
||||
switch = 0x00
|
||||
end
|
||||
|
||||
meta = IO::Memory.new
|
||||
meta.write(Bytes[0x12, 0x06])
|
||||
meta.print("videos")
|
||||
data = IO::Memory.new
|
||||
data.write_byte 0x12
|
||||
data.write_byte 0x06
|
||||
data.print "videos"
|
||||
|
||||
meta.write(Bytes[0x30, 0x02])
|
||||
meta.write(Bytes[0x38, 0x01])
|
||||
meta.write(Bytes[0x60, 0x01])
|
||||
meta.write(Bytes[0x6a, 0x00])
|
||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
||||
data.write Bytes[0x30, 0x02]
|
||||
data.write Bytes[0x38, 0x01]
|
||||
data.write Bytes[0x60, 0x01]
|
||||
data.write Bytes[0x6a, 0x00]
|
||||
data.write Bytes[0xb8, 0x01, 0x00]
|
||||
|
||||
meta.write(Bytes[0x20, switch])
|
||||
meta.write(Bytes[0x7a, page.size])
|
||||
meta.print(page)
|
||||
data.write Bytes[0x20, switch]
|
||||
data.write_byte 0x7a
|
||||
VarInt.to_io(data, page.bytesize)
|
||||
data.print page
|
||||
|
||||
case sort_by
|
||||
when "newest"
|
||||
# Empty tags can be omitted
|
||||
# meta.write(Bytes[0x18,0x00])
|
||||
when "popular"
|
||||
meta.write(Bytes[0x18, 0x01])
|
||||
data.write Bytes[0x18, 0x01]
|
||||
when "oldest"
|
||||
meta.write(Bytes[0x18, 0x02])
|
||||
data.write Bytes[0x18, 0x02]
|
||||
end
|
||||
|
||||
meta.rewind
|
||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
||||
meta = URI.escape(meta)
|
||||
data = Base64.urlsafe_encode(data)
|
||||
cursor = URI.escape(data)
|
||||
|
||||
continuation = IO::Memory.new
|
||||
continuation.write(Bytes[0x12, ucid.size])
|
||||
continuation.print(ucid)
|
||||
data = IO::Memory.new
|
||||
|
||||
continuation.write(Bytes[0x1a, meta.size])
|
||||
continuation.print(meta)
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, ucid.bytesize)
|
||||
data.print ucid
|
||||
|
||||
continuation.rewind
|
||||
continuation = continuation.gets_to_end
|
||||
data.write_byte 0x1a
|
||||
VarInt.to_io(data, cursor.bytesize)
|
||||
data.print cursor
|
||||
|
||||
wrapper = IO::Memory.new
|
||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
||||
wrapper.print(continuation)
|
||||
wrapper.rewind
|
||||
data.rewind
|
||||
|
||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
||||
wrapper = URI.escape(wrapper)
|
||||
buffer = IO::Memory.new
|
||||
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
|
||||
end
|
||||
@ -443,117 +501,108 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
|
||||
cursor = Base64.urlsafe_encode(cursor, false)
|
||||
end
|
||||
|
||||
meta = IO::Memory.new
|
||||
data = IO::Memory.new
|
||||
|
||||
if auto_generated
|
||||
meta.write(Bytes[0x08, 0x0a])
|
||||
data.write Bytes[0x08, 0x0a]
|
||||
end
|
||||
|
||||
meta.write(Bytes[0x12, 0x09])
|
||||
meta.print("playlists")
|
||||
data.write Bytes[0x12, 0x09]
|
||||
data.print "playlists"
|
||||
|
||||
if auto_generated
|
||||
meta.write(Bytes[0x20, 0x32])
|
||||
data.write Bytes[0x20, 0x32]
|
||||
else
|
||||
# TODO: Look at 0x01, 0x00
|
||||
case sort
|
||||
when "oldest", "oldest_created"
|
||||
meta.write(Bytes[0x18, 0x02])
|
||||
data.write Bytes[0x18, 0x02]
|
||||
when "newest", "newest_created"
|
||||
meta.write(Bytes[0x18, 0x03])
|
||||
data.write Bytes[0x18, 0x03]
|
||||
when "last", "last_added"
|
||||
meta.write(Bytes[0x18, 0x04])
|
||||
data.write Bytes[0x18, 0x04]
|
||||
end
|
||||
|
||||
meta.write(Bytes[0x20, 0x01])
|
||||
data.write Bytes[0x20, 0x01]
|
||||
end
|
||||
|
||||
meta.write(Bytes[0x30, 0x02])
|
||||
meta.write(Bytes[0x38, 0x01])
|
||||
meta.write(Bytes[0x60, 0x01])
|
||||
meta.write(Bytes[0x6a, 0x00])
|
||||
data.write Bytes[0x30, 0x02]
|
||||
data.write Bytes[0x38, 0x01]
|
||||
data.write Bytes[0x60, 0x01]
|
||||
data.write Bytes[0x6a, 0x00]
|
||||
|
||||
meta.write(Bytes[0x7a, cursor.size])
|
||||
meta.print(cursor)
|
||||
data.write_byte 0x7a
|
||||
VarInt.to_io(data, cursor.bytesize)
|
||||
data.print cursor
|
||||
|
||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
||||
data.write Bytes[0xb8, 0x01, 0x00]
|
||||
|
||||
meta.rewind
|
||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
||||
meta = URI.escape(meta)
|
||||
data.rewind
|
||||
data = Base64.urlsafe_encode(data)
|
||||
continuation = URI.escape(data)
|
||||
|
||||
continuation = IO::Memory.new
|
||||
continuation.write(Bytes[0x12, ucid.size])
|
||||
continuation.print(ucid)
|
||||
data = IO::Memory.new
|
||||
|
||||
continuation.write(Bytes[0x1a])
|
||||
continuation.write(write_var_int(meta.size))
|
||||
continuation.print(meta)
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, ucid.bytesize)
|
||||
data.print ucid
|
||||
|
||||
continuation.rewind
|
||||
continuation = continuation.gets_to_end
|
||||
data.write_byte 0x1a
|
||||
VarInt.to_io(data, continuation.bytesize)
|
||||
data.print continuation
|
||||
|
||||
wrapper = IO::Memory.new
|
||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
|
||||
wrapper.write(write_var_int(continuation.size))
|
||||
wrapper.print(continuation)
|
||||
wrapper.rewind
|
||||
data.rewind
|
||||
|
||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
||||
wrapper = URI.escape(wrapper)
|
||||
buffer = IO::Memory.new
|
||||
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
|
||||
end
|
||||
|
||||
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)
|
||||
wrapper = Base64.decode(wrapper)
|
||||
continuation = URI.unescape(continuation)
|
||||
data = IO::Memory.new(Base64.decode(continuation))
|
||||
|
||||
# 0xe2 0xa9 0x85 0xb2 0x02
|
||||
wrapper += 5
|
||||
data.pos += 5
|
||||
|
||||
continuation_size = read_var_int(wrapper[0, 4])
|
||||
wrapper += write_var_int(continuation_size).size
|
||||
continuation = wrapper[0, continuation_size]
|
||||
continuation = Bytes.new(data.read_bytes(VarInt))
|
||||
data.read continuation
|
||||
data = IO::Memory.new(continuation)
|
||||
|
||||
# 0x12
|
||||
continuation += 1
|
||||
ucid_size = continuation[0]
|
||||
continuation += 1
|
||||
ucid = continuation[0, ucid_size]
|
||||
continuation += ucid_size
|
||||
data.read_byte # => 0x12
|
||||
ucid = Bytes.new(data.read_bytes(VarInt))
|
||||
data.read ucid
|
||||
|
||||
# 0x1a
|
||||
continuation += 1
|
||||
meta_size = read_var_int(continuation[0, 4])
|
||||
continuation += write_var_int(meta_size).size
|
||||
meta = continuation[0, meta_size]
|
||||
continuation += meta_size
|
||||
data.read_byte # => 0x1a
|
||||
inner_continuation = Bytes.new(data.read_bytes(VarInt))
|
||||
data.read inner_continuation
|
||||
|
||||
meta = String.new(meta)
|
||||
meta = URI.unescape(meta)
|
||||
meta = Base64.decode(meta)
|
||||
continuation = String.new(inner_continuation)
|
||||
continuation = URI.unescape(continuation)
|
||||
data = IO::Memory.new(Base64.decode(continuation))
|
||||
|
||||
# 0x12 0x09 playlists
|
||||
meta += 11
|
||||
data.pos += 11
|
||||
|
||||
until meta[0] == 0x7a
|
||||
tag = read_var_int(meta[0, 4])
|
||||
meta += write_var_int(tag).size
|
||||
value = meta[0]
|
||||
meta += 1
|
||||
until data.peek[0] == 0x7a
|
||||
key = data.read_bytes(VarInt)
|
||||
value = data.read_bytes(VarInt)
|
||||
end
|
||||
|
||||
# 0x7a
|
||||
meta += 1
|
||||
cursor_size = meta[0]
|
||||
meta += 1
|
||||
cursor = meta[0, cursor_size]
|
||||
|
||||
data.pos += 1 # => 0x7a
|
||||
cursor = Bytes.new(data.read_bytes(VarInt))
|
||||
data.read cursor
|
||||
cursor = String.new(cursor)
|
||||
|
||||
if !auto_generated
|
||||
@ -564,6 +613,310 @@ def extract_channel_playlists_cursor(url, auto_generated)
|
||||
return cursor
|
||||
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)
|
||||
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")]))
|
||||
error_message = translate(locale, "This channel does not exist.")
|
||||
|
||||
raise error_message
|
||||
end
|
||||
|
||||
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 ||= translate(locale, "Could not get channel info.")
|
||||
|
||||
raise error_message
|
||||
end
|
||||
|
||||
@ -594,8 +945,63 @@ def get_about_info(ucid, locale)
|
||||
sub_count ||= 0
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
# https://support.google.com/youtube/answer/2579942
|
||||
auto_generated = false
|
||||
@ -604,10 +1010,28 @@ def get_about_info(ucid, locale)
|
||||
auto_generated = true
|
||||
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
|
||||
|
||||
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
|
||||
videos = [] of SearchVideo
|
||||
|
||||
@ -629,7 +1053,7 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
|
||||
if auto_generated
|
||||
videos += extract_videos(nodeset)
|
||||
else
|
||||
videos += extract_videos(nodeset, ucid)
|
||||
videos += extract_videos(nodeset, ucid, author)
|
||||
end
|
||||
else
|
||||
break
|
||||
|
@ -22,6 +22,7 @@ class RedditComment
|
||||
replies: RedditThing | String,
|
||||
score: Int32,
|
||||
depth: Int32,
|
||||
permalink: String,
|
||||
created_utc: {
|
||||
type: Time,
|
||||
converter: RedditComment::TimeConverter,
|
||||
@ -56,14 +57,14 @@ class RedditListing
|
||||
})
|
||||
end
|
||||
|
||||
def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode, region, sort_by = "top")
|
||||
video = fetch_video(id, proxies, region: region)
|
||||
def fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by = "top")
|
||||
video = fetch_video(id, region)
|
||||
session_token = video.info["session_token"]?
|
||||
|
||||
ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
|
||||
continuation ||= ctoken
|
||||
|
||||
if !continuation || !session_token
|
||||
if !continuation || continuation.empty? || !session_token
|
||||
if format == "json"
|
||||
return {"comments" => [] of String}.to_json
|
||||
else
|
||||
@ -72,11 +73,10 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
end
|
||||
|
||||
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["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-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)
|
||||
|
||||
if !response["response"]["continuationContents"]?
|
||||
@ -112,10 +112,13 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
end
|
||||
end
|
||||
|
||||
comments = JSON.build do |json|
|
||||
response = JSON.build do |json|
|
||||
json.object do
|
||||
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
|
||||
end
|
||||
|
||||
@ -139,16 +142,9 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
node_comment = node["commentRenderer"]
|
||||
end
|
||||
|
||||
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff')
|
||||
if content_html
|
||||
content_html = HTML.escape(content_html)
|
||||
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 ||= ""
|
||||
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
|
||||
content_to_comment_html(node_comment["contentText"]["runs"].as_a).try &.to_s || ""
|
||||
author = node_comment["authorText"]?.try &.["simpleText"]? || ""
|
||||
|
||||
json.field "author", author
|
||||
json.field "authorThumbnails" do
|
||||
@ -180,10 +176,12 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
json.field "isEdited", false
|
||||
end
|
||||
|
||||
json.field "content", content
|
||||
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", node_comment["likeCount"]
|
||||
json.field "commentId", node_comment["commentId"]
|
||||
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
|
||||
@ -199,13 +197,8 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
end
|
||||
|
||||
if node_replies && !response["commentRepliesContinuation"]?
|
||||
reply_count = node_replies["moreText"]["simpleText"].as_s.delete("View all reply replies,")
|
||||
if reply_count.empty?
|
||||
reply_count = 1
|
||||
else
|
||||
reply_count = reply_count.try &.to_i?
|
||||
reply_count ||= 1
|
||||
end
|
||||
reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
|
||||
.try &.as_s.gsub(/\D/, "").to_i? || 1
|
||||
|
||||
continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
|
||||
continuation ||= ""
|
||||
@ -230,15 +223,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
end
|
||||
|
||||
if format == "html"
|
||||
comments = JSON.parse(comments)
|
||||
content_html = template_youtube_comments(comments, locale, thin_mode)
|
||||
response = JSON.parse(response)
|
||||
content_html = template_youtube_comments(response, locale, thin_mode)
|
||||
|
||||
comments = JSON.build do |json|
|
||||
response = JSON.build do |json|
|
||||
json.object do
|
||||
json.field "contentHtml", content_html
|
||||
|
||||
if comments["commentCount"]?
|
||||
json.field "commentCount", comments["commentCount"]
|
||||
if response["commentCount"]?
|
||||
json.field "commentCount", response["commentCount"]
|
||||
else
|
||||
json.field "commentCount", 0
|
||||
end
|
||||
@ -246,14 +239,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
|
||||
end
|
||||
end
|
||||
|
||||
return comments
|
||||
return response
|
||||
end
|
||||
|
||||
def fetch_reddit_comments(id, sort_by = "confidence")
|
||||
client = make_client(REDDIT_URL)
|
||||
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)
|
||||
|
||||
if search_results.status_code == 200
|
||||
@ -282,56 +276,110 @@ def fetch_reddit_comments(id, sort_by = "confidence")
|
||||
end
|
||||
|
||||
def template_youtube_comments(comments, locale, thin_mode)
|
||||
html = ""
|
||||
|
||||
root = comments["comments"].as_a
|
||||
root.each do |child|
|
||||
if child["replies"]?
|
||||
replies_html = <<-END_HTML
|
||||
<div id="replies" class="pure-g">
|
||||
<div class="pure-u-1-24"></div>
|
||||
<div class="pure-u-23-24">
|
||||
<p>
|
||||
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
|
||||
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", child["replies"]["replyCount"].to_s)}</a>
|
||||
</p>
|
||||
String.build do |html|
|
||||
root = comments["comments"].as_a
|
||||
root.each do |child|
|
||||
if child["replies"]?
|
||||
replies_html = <<-END_HTML
|
||||
<div id="replies" class="pure-g">
|
||||
<div class="pure-u-1-24"></div>
|
||||
<div class="pure-u-23-24">
|
||||
<p>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
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_HTML
|
||||
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)}">
|
||||
<div class="creator-heart">
|
||||
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
|
||||
@ -340,84 +388,77 @@ def template_youtube_comments(comments, locale, thin_mode)
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
END_HTML
|
||||
end
|
||||
|
||||
html << <<-END_HTML
|
||||
</p>
|
||||
#{replies_html}
|
||||
</div>
|
||||
</div>
|
||||
END_HTML
|
||||
end
|
||||
|
||||
html += <<-END_HTML
|
||||
</p>
|
||||
#{replies_html}
|
||||
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>
|
||||
</div>
|
||||
END_HTML
|
||||
END_HTML
|
||||
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
|
||||
|
||||
def template_reddit_comments(root, locale)
|
||||
html = ""
|
||||
root.each do |child|
|
||||
if child.data.is_a?(RedditComment)
|
||||
child = child.data.as(RedditComment)
|
||||
author = child.author
|
||||
score = child.score
|
||||
body_html = HTML.unescape(child.body_html)
|
||||
String.build do |html|
|
||||
root.each do |child|
|
||||
if child.data.is_a?(RedditComment)
|
||||
child = child.data.as(RedditComment)
|
||||
body_html = HTML.unescape(child.body_html)
|
||||
|
||||
replies_html = ""
|
||||
if child.replies.is_a?(RedditThing)
|
||||
replies = child.replies.as(RedditThing)
|
||||
replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
|
||||
end
|
||||
replies_html = ""
|
||||
if child.replies.is_a?(RedditThing)
|
||||
replies = child.replies.as(RedditThing)
|
||||
replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
|
||||
end
|
||||
|
||||
content = <<-END_HTML
|
||||
<p>
|
||||
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
|
||||
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
|
||||
#{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
|
||||
if child.depth > 0
|
||||
html << <<-END_HTML
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1-24">
|
||||
</div>
|
||||
<div class="pure-u-23-24">
|
||||
#{content}
|
||||
</div>
|
||||
</div>
|
||||
END_HTML
|
||||
else
|
||||
html += <<-END_HTML
|
||||
END_HTML
|
||||
else
|
||||
html << <<-END_HTML
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1">
|
||||
#{content}
|
||||
</div>
|
||||
</div>
|
||||
END_HTML
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
return html
|
||||
end
|
||||
|
||||
def replace_links(html)
|
||||
@ -517,114 +558,111 @@ def content_to_comment_html(content)
|
||||
end
|
||||
|
||||
text
|
||||
end.join.rchop('\ufeff')
|
||||
end.join("").delete('\ufeff')
|
||||
|
||||
return comment_html
|
||||
end
|
||||
|
||||
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])
|
||||
continuation.print(video_id)
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, video_id.bytesize)
|
||||
data.print video_id
|
||||
|
||||
continuation.write(Bytes[0xc0, 0x01, 0x01])
|
||||
continuation.write(Bytes[0xc8, 0x01, 0x01])
|
||||
continuation.write(Bytes[0xe0, 0x01, 0x01])
|
||||
data.write Bytes[0xc0, 0x01, 0x01]
|
||||
data.write Bytes[0xc8, 0x01, 0x01]
|
||||
data.write Bytes[0xe0, 0x01, 0x01]
|
||||
|
||||
continuation.write(Bytes[0xa2, 0x02, 0x0d])
|
||||
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01])
|
||||
data.write Bytes[0xa2, 0x02, 0x0d]
|
||||
data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
|
||||
|
||||
continuation.write(Bytes[0x40, 0x00])
|
||||
continuation.write(Bytes[0x18, 0x06])
|
||||
data.write Bytes[0x40, 0x00]
|
||||
data.write Bytes[0x18, 0x06]
|
||||
|
||||
if cursor.empty?
|
||||
continuation.write(Bytes[0x32])
|
||||
continuation.write(write_var_int(video_id.size + 8))
|
||||
data.write Bytes[0x32]
|
||||
VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 8)
|
||||
|
||||
continuation.write(Bytes[0x22, video_id.size + 4])
|
||||
continuation.write(Bytes[0x22, video_id.size])
|
||||
continuation.print(video_id)
|
||||
data.write Bytes[0x22, video_id.bytesize + 4]
|
||||
data.write Bytes[0x22, video_id.bytesize]
|
||||
data.print video_id
|
||||
|
||||
case sort_by
|
||||
when "top"
|
||||
continuation.write(Bytes[0x30, 0x00])
|
||||
data.write Bytes[0x30, 0x00]
|
||||
when "new", "newest"
|
||||
continuation.write(Bytes[0x30, 0x01])
|
||||
data.write Bytes[0x30, 0x01]
|
||||
end
|
||||
|
||||
continuation.write(Bytes[0x78, 0x02])
|
||||
data.write(Bytes[0x78, 0x02])
|
||||
else
|
||||
continuation.write(Bytes[0x32])
|
||||
continuation.write(write_var_int(cursor.size + video_id.size + 11))
|
||||
data.write Bytes[0x32]
|
||||
VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 11)
|
||||
|
||||
continuation.write(Bytes[0x0a])
|
||||
continuation.write(write_var_int(cursor.size))
|
||||
continuation.print(cursor)
|
||||
data.write_byte 0x0a
|
||||
VarInt.to_io(data, cursor.bytesize)
|
||||
data.print cursor
|
||||
|
||||
continuation.write(Bytes[0x22, video_id.size + 4])
|
||||
continuation.write(Bytes[0x22, video_id.size])
|
||||
continuation.print(video_id)
|
||||
data.write Bytes[0x22, video_id.bytesize + 4]
|
||||
data.write Bytes[0x22, video_id.bytesize]
|
||||
data.print video_id
|
||||
|
||||
case sort_by
|
||||
when "top"
|
||||
continuation.write(Bytes[0x30, 0x00])
|
||||
data.write Bytes[0x30, 0x00]
|
||||
when "new", "newest"
|
||||
continuation.write(Bytes[0x30, 0x01])
|
||||
data.write Bytes[0x30, 0x01]
|
||||
end
|
||||
|
||||
continuation.write(Bytes[0x28, 0x14])
|
||||
data.write Bytes[0x28, 0x14]
|
||||
end
|
||||
|
||||
continuation.rewind
|
||||
continuation = continuation.gets_to_end
|
||||
|
||||
continuation = Base64.urlsafe_encode(continuation.to_slice)
|
||||
continuation = Base64.urlsafe_encode(data)
|
||||
continuation = URI.escape(continuation)
|
||||
|
||||
return continuation
|
||||
end
|
||||
|
||||
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])
|
||||
continuation.print(video_id)
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, video_id.size)
|
||||
data.print video_id
|
||||
|
||||
continuation.write(Bytes[0xc0, 0x01, 0x01])
|
||||
continuation.write(Bytes[0xc8, 0x01, 0x01])
|
||||
continuation.write(Bytes[0xe0, 0x01, 0x01])
|
||||
data.write Bytes[0xc0, 0x01, 0x01]
|
||||
data.write Bytes[0xc8, 0x01, 0x01]
|
||||
data.write Bytes[0xe0, 0x01, 0x01]
|
||||
|
||||
continuation.write(Bytes[0xa2, 0x02, 0x0d])
|
||||
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01])
|
||||
data.write Bytes[0xa2, 0x02, 0x0d]
|
||||
data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
|
||||
|
||||
continuation.write(Bytes[0x40, 0x00])
|
||||
continuation.write(Bytes[0x18, 0x06])
|
||||
data.write Bytes[0x40, 0x00]
|
||||
data.write Bytes[0x18, 0x06]
|
||||
|
||||
continuation.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[0x32, ucid.size + video_id.size + comment_id.size + 16])
|
||||
data.write(Bytes[0x1a, ucid.size + video_id.size + comment_id.size + 14])
|
||||
|
||||
continuation.write(Bytes[0x12, comment_id.size])
|
||||
continuation.print(comment_id)
|
||||
data.write_byte 0x12
|
||||
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])
|
||||
continuation.write(Bytes[ucid.size])
|
||||
continuation.print(ucid)
|
||||
continuation.write(Bytes[0x32, video_id.size])
|
||||
continuation.print(video_id)
|
||||
continuation.write(Bytes[0x40, 0x01])
|
||||
continuation.write(Bytes[0x48, 0x0a])
|
||||
data.write(Bytes[ucid.size + video_id.size + 7])
|
||||
data.write(Bytes[ucid.size])
|
||||
data.print(ucid)
|
||||
data.write(Bytes[0x32, video_id.size])
|
||||
data.print(video_id)
|
||||
data.write(Bytes[0x40, 0x01])
|
||||
data.write(Bytes[0x48, 0x0a])
|
||||
|
||||
continuation.rewind
|
||||
continuation = continuation.gets_to_end
|
||||
|
||||
continuation = Base64.urlsafe_encode(continuation.to_slice)
|
||||
continuation = Base64.urlsafe_encode(data.to_slice)
|
||||
continuation = URI.escape(continuation)
|
||||
|
||||
return continuation
|
||||
|
@ -176,3 +176,41 @@ class HTTP::Client
|
||||
response
|
||||
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
|
||||
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
|
||||
Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple)
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder)
|
||||
value.to_yaml(yaml)
|
||||
module FamilyConverter
|
||||
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
|
||||
|
||||
@ -105,7 +146,6 @@ struct Config
|
||||
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
|
||||
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"},
|
||||
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
|
||||
top_enabled: {type: Bool, default: true},
|
||||
@ -119,11 +159,13 @@ struct Config
|
||||
default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple),
|
||||
converter: ConfigPreferencesConverter,
|
||||
},
|
||||
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.
|
||||
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.
|
||||
hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
@ -147,7 +189,7 @@ def rank_videos(db, n)
|
||||
published = rs.read(Time)
|
||||
|
||||
# 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}
|
||||
end
|
||||
end
|
||||
@ -161,40 +203,42 @@ def rank_videos(db, n)
|
||||
return top[0..n - 1]
|
||||
end
|
||||
|
||||
def login_req(login_form, f_req)
|
||||
def login_req(f_req)
|
||||
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",
|
||||
"checkConnection" => "youtube",
|
||||
"checkedDomains" => "youtube",
|
||||
"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,
|
||||
"flowName" => "GlifWebSignIn",
|
||||
"flowEntry" => "ServiceLogin",
|
||||
# "cookiesDisabled" => "false",
|
||||
# "gmscoreversion" => "undefined",
|
||||
# "continue" => "https://accounts.google.com/ManageAccount",
|
||||
# "azt" => "",
|
||||
# "bgHash" => "",
|
||||
}
|
||||
|
||||
data = login_form.merge(data)
|
||||
|
||||
return HTTP::Params.encode(data)
|
||||
end
|
||||
|
||||
def html_to_content(description_html)
|
||||
if !description_html
|
||||
description = ""
|
||||
description_html = ""
|
||||
else
|
||||
description_html = description_html.to_s
|
||||
description = description_html.gsub("<br>", "\n")
|
||||
description = description.gsub("<br/>", "\n")
|
||||
def html_to_content(description_html : String)
|
||||
description = description_html.gsub(/(<br>)|(<br\/>)/, {
|
||||
"<br>": "\n",
|
||||
"<br/>": "\n",
|
||||
})
|
||||
|
||||
if description.empty?
|
||||
description = ""
|
||||
else
|
||||
description = XML.parse_html(description).content.strip("\n ")
|
||||
end
|
||||
if !description.empty?
|
||||
description = XML.parse_html(description).content.strip("\n ")
|
||||
end
|
||||
|
||||
return description_html, description
|
||||
return description
|
||||
end
|
||||
|
||||
def extract_videos(nodeset, ucid = nil, author_name = nil)
|
||||
@ -231,8 +275,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
||||
author ||= ""
|
||||
author_id ||= ""
|
||||
|
||||
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
|
||||
description_html, description = html_to_content(description_html)
|
||||
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || ""
|
||||
|
||||
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
|
||||
if !tile
|
||||
@ -331,7 +374,6 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
|
||||
author_thumbnail: author_thumbnail,
|
||||
subscriber_count: subscriber_count,
|
||||
video_count: video_count,
|
||||
description: description,
|
||||
description_html: description_html
|
||||
)
|
||||
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)
|
||||
rescue ex
|
||||
end
|
||||
published ||= Time.now
|
||||
published ||= Time.utc
|
||||
|
||||
begin
|
||||
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,
|
||||
published: published,
|
||||
views: view_count,
|
||||
description: description,
|
||||
description_html: description_html,
|
||||
length_seconds: length_seconds,
|
||||
live_now: live_now,
|
||||
@ -523,7 +564,7 @@ def analyze_table(db, logger, table_name, struct_type = nil)
|
||||
begin
|
||||
db.exec("SELECT * FROM #{table_name} LIMIT 0")
|
||||
rescue ex
|
||||
logger.write("CREATE TABLE #{table_name}\n")
|
||||
logger.puts("CREATE TABLE #{table_name}")
|
||||
|
||||
db.using_connection do |conn|
|
||||
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 !column_array[i]?
|
||||
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}")
|
||||
next
|
||||
end
|
||||
@ -565,26 +606,29 @@ def analyze_table(db, logger, table_name, struct_type = nil)
|
||||
|
||||
# There's a column we didn't expect
|
||||
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")
|
||||
|
||||
column_array = get_column_array(db, table_name)
|
||||
next
|
||||
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}")
|
||||
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]}")
|
||||
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")
|
||||
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]}")
|
||||
|
||||
column_array = get_column_array(db, table_name)
|
||||
end
|
||||
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")
|
||||
end
|
||||
end
|
||||
@ -635,52 +679,25 @@ def cache_annotation(db, id, annotations)
|
||||
end
|
||||
end
|
||||
|
||||
def proxy_file(response, env)
|
||||
if !response.body_io?
|
||||
return
|
||||
end
|
||||
def create_notification_stream(env, config, kemal_config, decrypt_function, topics, connection_channel)
|
||||
connection = Channel(PQ::Notification).new(8)
|
||||
connection_channel.send({true, connection})
|
||||
|
||||
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]?
|
||||
|
||||
env.response.content_type = "text/event-stream"
|
||||
|
||||
since = env.params.query["since"]?.try &.to_i?
|
||||
id = 0
|
||||
|
||||
begin
|
||||
id = 0
|
||||
|
||||
if topics.includes? "debug"
|
||||
spawn do
|
||||
if topics.includes? "debug"
|
||||
spawn do
|
||||
begin
|
||||
loop do
|
||||
time_span = [0, 0, 0, 0]
|
||||
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 = get_video(video_id, PG_DB, proxies)
|
||||
video = get_video(video_id, PG_DB)
|
||||
video.published = published
|
||||
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
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
spawn do
|
||||
spawn do
|
||||
begin
|
||||
if since
|
||||
topics.try &.each do |topic|
|
||||
case topic
|
||||
@ -735,14 +756,24 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
|
||||
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)
|
||||
topic = notification["topic"].as_s
|
||||
video_id = notification["videoId"].as_s
|
||||
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)
|
||||
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
|
||||
|
||||
if topics.try &.includes? topic
|
||||
env.response.puts "id: #{id}"
|
||||
env.response.puts "data: #{response.to_json}"
|
||||
env.response.puts
|
||||
env.response.flush
|
||||
env.response.puts "id: #{id}"
|
||||
env.response.puts "data: #{response.to_json}"
|
||||
env.response.puts
|
||||
env.response.flush
|
||||
|
||||
id += 1
|
||||
end
|
||||
id += 1
|
||||
end
|
||||
rescue ex
|
||||
ensure
|
||||
connection_channel.send({false, connection})
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
# Send heartbeat
|
||||
loop do
|
||||
env.response.puts ":keepalive #{Time.now.to_unix}"
|
||||
env.response.puts ":keepalive #{Time.utc.to_unix}"
|
||||
env.response.puts
|
||||
env.response.flush
|
||||
sleep (20 + rand(11)).seconds
|
||||
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
|
||||
|
@ -22,12 +22,12 @@ def refresh_channels(db, logger, config)
|
||||
begin
|
||||
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
|
||||
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
|
||||
logger.write("#{id} : #{ex.message}\n")
|
||||
logger.puts("#{id} : #{ex.message}")
|
||||
end
|
||||
|
||||
active_channel.send(true)
|
||||
@ -36,6 +36,7 @@ def refresh_channels(db, logger, config)
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
@ -43,66 +44,6 @@ def refresh_channels(db, logger, config)
|
||||
end
|
||||
|
||||
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
|
||||
spawn do
|
||||
max_threads = max_channel.receive
|
||||
@ -110,7 +51,7 @@ def refresh_feeds(db, logger, config)
|
||||
active_channel = Channel(Bool).new
|
||||
|
||||
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
|
||||
email = rs.read(String)
|
||||
view_name = "subscriptions_#{sha256(email)}"
|
||||
@ -128,33 +69,37 @@ def refresh_feeds(db, logger, config)
|
||||
column_array = get_column_array(db, view_name)
|
||||
ChannelVideo.to_type_tuple.each_with_index do |name, 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}")
|
||||
raise "view does not exist"
|
||||
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("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
rescue ex
|
||||
# Rename old views
|
||||
begin
|
||||
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
|
||||
|
||||
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}")
|
||||
rescue ex
|
||||
begin
|
||||
# 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)
|
||||
logger.write("CREATE #{view_name}\n")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
||||
SELECT * FROM channel_videos WHERE \
|
||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
|
||||
ORDER BY published DESC;")
|
||||
logger.puts("CREATE #{view_name}")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
|
||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||
end
|
||||
rescue ex
|
||||
logger.write("REFRESH #{email} : #{ex.message}\n")
|
||||
logger.puts("REFRESH #{email} : #{ex.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -164,7 +109,8 @@ def refresh_feeds(db, logger, config)
|
||||
end
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
sleep 5.seconds
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
@ -204,7 +150,7 @@ def subscribe_to_feeds(db, logger, key, config)
|
||||
response = subscribe_pubsub(ucid, key, config)
|
||||
|
||||
if response.status_code >= 400
|
||||
logger.write("#{ucid} : #{response.body}\n")
|
||||
logger.puts("#{ucid} : #{response.body}")
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
@ -215,6 +161,7 @@ def subscribe_to_feeds(db, logger, key, config)
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
@ -227,12 +174,16 @@ def pull_top_videos(config, db)
|
||||
begin
|
||||
top = rank_videos(db, 40)
|
||||
rescue ex
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
if top.size > 0
|
||||
args = arg_array(top)
|
||||
else
|
||||
if top.size == 0
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
@ -247,22 +198,23 @@ def pull_top_videos(config, db)
|
||||
end
|
||||
|
||||
yield videos
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
def pull_popular_videos(db)
|
||||
loop do
|
||||
subscriptions = db.query_all("SELECT channel FROM \
|
||||
(SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String)
|
||||
|
||||
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
|
||||
videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE ucid IN \
|
||||
(SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
|
||||
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) \
|
||||
ORDER BY ucid, published DESC", as: ChannelVideo).sort_by { |video| video.published }.reverse
|
||||
|
||||
yield videos
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
@ -270,12 +222,13 @@ def update_decrypt_function
|
||||
loop do
|
||||
begin
|
||||
decrypt_function = fetch_decrypt_function
|
||||
yield decrypt_function
|
||||
rescue ex
|
||||
next
|
||||
end
|
||||
|
||||
yield decrypt_function
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
@ -290,5 +243,6 @@ def find_working_proxies(regions)
|
||||
end
|
||||
|
||||
sleep 1.minute
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
@ -1,13 +1,20 @@
|
||||
require "logger"
|
||||
|
||||
enum LogLevel
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
end
|
||||
|
||||
class Invidious::LogHandler < Kemal::BaseLogHandler
|
||||
def initialize(@io : IO = STDOUT)
|
||||
def initialize(@io : IO = STDOUT, @level = LogLevel::Warn)
|
||||
end
|
||||
|
||||
def call(context : HTTP::Server::Context)
|
||||
time = Time.now
|
||||
time = Time.utc
|
||||
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'
|
||||
|
||||
@ -18,7 +25,15 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
|
||||
context
|
||||
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
|
||||
|
||||
if @io.is_a? File
|
||||
@ -26,6 +41,29 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
|
||||
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)
|
||||
millis = elapsed.total_milliseconds
|
||||
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)
|
||||
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 = {
|
||||
"session" => session,
|
||||
@ -18,7 +18,7 @@ def generate_token(email, scopes, expire, key, db)
|
||||
end
|
||||
|
||||
def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
|
||||
expire = Time.now + expire
|
||||
expire = Time.utc + expire
|
||||
|
||||
token = {
|
||||
"session" => session,
|
||||
@ -85,8 +85,8 @@ def validate_request(token, session, request, key, db, locale = nil)
|
||||
end
|
||||
|
||||
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
|
||||
if nonce[1] > Time.now
|
||||
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
|
||||
if nonce[1] > Time.utc
|
||||
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
|
||||
else
|
||||
raise translate(locale, "Erroneous token")
|
||||
end
|
||||
@ -100,7 +100,7 @@ def validate_request(token, session, request, key, db, locale = nil)
|
||||
end
|
||||
|
||||
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")
|
||||
end
|
||||
|
||||
|
@ -18,24 +18,14 @@ def elapsed_text(elapsed)
|
||||
"#{(millis * 1000).round(2)}µs"
|
||||
end
|
||||
|
||||
def make_client(url : URI, proxies = {} of String => Array({ip: String, port: Int32}), region = nil)
|
||||
context = nil
|
||||
|
||||
if url.scheme == "https"
|
||||
context = OpenSSL::SSL::Context::Client.new
|
||||
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
|
||||
def make_client(url : URI, region = nil)
|
||||
client = HTTPClient.new(url)
|
||||
client.family = CONFIG.force_resolve
|
||||
client.read_timeout = 15.seconds
|
||||
client.connect_timeout = 15.seconds
|
||||
|
||||
if region
|
||||
proxies[region]?.try &.sample(40).each do |proxy|
|
||||
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||
begin
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
@ -90,7 +80,7 @@ def decode_time(string)
|
||||
millis = /(?<millis>\d+)ms/.match(string).try &.["millis"].try &.to_f
|
||||
millis ||= 0
|
||||
|
||||
time = hours * 3600 + minutes * 60 + seconds + millis / 1000
|
||||
time = hours * 3600 + minutes * 60 + seconds + millis // 1000
|
||||
end
|
||||
|
||||
return time
|
||||
@ -99,7 +89,7 @@ end
|
||||
def decode_date(string : String)
|
||||
# String matches 'YYYY'
|
||||
if string.match(/^\d{4}/)
|
||||
return Time.new(string.to_i, 1, 1)
|
||||
return Time.utc(string.to_i, 1, 1)
|
||||
end
|
||||
|
||||
# Try to parse as format Jul 10, 2000
|
||||
@ -110,9 +100,9 @@ def decode_date(string : String)
|
||||
|
||||
case string
|
||||
when "today"
|
||||
return Time.now
|
||||
return Time.utc
|
||||
when "yesterday"
|
||||
return Time.now - 1.day
|
||||
return Time.utc - 1.day
|
||||
end
|
||||
|
||||
# String matches format "20 hours ago", "4 months ago"...
|
||||
@ -138,18 +128,18 @@ def decode_date(string : String)
|
||||
raise "Could not parse #{string}"
|
||||
end
|
||||
|
||||
return Time.now - delta
|
||||
return Time.utc - delta
|
||||
end
|
||||
|
||||
def recode_date(time : Time, locale)
|
||||
span = Time.now - time
|
||||
span = Time.utc - time
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
|
||||
elsif span.total_minutes > 60.0
|
||||
@ -194,11 +184,11 @@ def number_to_short_text(number)
|
||||
|
||||
text = text.rchop(".0")
|
||||
|
||||
if number / 1_000_000_000 != 0
|
||||
if number // 1_000_000_000 != 0
|
||||
text += "B"
|
||||
elsif number / 1_000_000 != 0
|
||||
elsif number // 1_000_000 != 0
|
||||
text += "M"
|
||||
elsif number / 1000 != 0
|
||||
elsif number // 1000 != 0
|
||||
text += "K"
|
||||
end
|
||||
|
||||
@ -243,7 +233,7 @@ def make_host_url(config, kemal_config)
|
||||
return "#{scheme}#{host}#{port}"
|
||||
end
|
||||
|
||||
def get_referer(env, fallback = "/")
|
||||
def get_referer(env, fallback = "/", unroll = true)
|
||||
referer = env.params.query["referer"]?
|
||||
referer ||= env.request.headers["referer"]?
|
||||
referer ||= fallback
|
||||
@ -251,16 +241,18 @@ def get_referer(env, fallback = "/")
|
||||
referer = URI.parse(referer)
|
||||
|
||||
# "Unroll" nested referrers
|
||||
loop do
|
||||
if referer.query
|
||||
params = HTTP::Params.parse(referer.query.not_nil!)
|
||||
if params["referer"]?
|
||||
referer = URI.parse(URI.unescape(params["referer"]))
|
||||
if unroll
|
||||
loop do
|
||||
if referer.query
|
||||
params = HTTP::Params.parse(referer.query.not_nil!)
|
||||
if params["referer"]?
|
||||
referer = URI.parse(URI.unescape(params["referer"]))
|
||||
else
|
||||
break
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
@ -274,50 +266,40 @@ def get_referer(env, fallback = "/")
|
||||
return referer
|
||||
end
|
||||
|
||||
def read_var_int(bytes)
|
||||
num_read = 0
|
||||
result = 0
|
||||
struct VarInt
|
||||
def self.from_io(io : IO, format = IO::ByteFormat::BigEndian) : Int32
|
||||
result = 0_i32
|
||||
num_read = 0
|
||||
|
||||
read = bytes[num_read]
|
||||
|
||||
if bytes.size == 1
|
||||
result = bytes[0].to_i32
|
||||
else
|
||||
while ((read & 0b10000000) != 0)
|
||||
read = bytes[num_read].to_u64
|
||||
value = (read & 0b01111111)
|
||||
result |= (value << (7 * num_read))
|
||||
loop do
|
||||
byte = io.read_byte
|
||||
raise "Invalid VarInt" if !byte
|
||||
value = byte & 0x7f
|
||||
|
||||
result |= value.to_i32 << (7 * num_read)
|
||||
num_read += 1
|
||||
if num_read > 5
|
||||
raise "VarInt is too big"
|
||||
end
|
||||
|
||||
break if byte & 0x80 == 0
|
||||
raise "Invalid VarInt" if num_read > 5
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
def self.to_io(io : IO, value : Int32)
|
||||
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
|
||||
temp = (value & 0b01111111).to_u8
|
||||
value = value >> 7
|
||||
byte = (value & 0x7f).to_u8
|
||||
value >>= 7
|
||||
|
||||
if value != 0
|
||||
temp |= 0b10000000
|
||||
byte |= 0x80
|
||||
end
|
||||
|
||||
bytes << temp
|
||||
io.write_byte byte
|
||||
end
|
||||
end
|
||||
|
||||
return Slice.new(bytes.to_unsafe, bytes.size)
|
||||
end
|
||||
|
||||
def sha256(text)
|
||||
@ -325,3 +307,52 @@ def sha256(text)
|
||||
digest << text
|
||||
return digest.hexdigest
|
||||
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,
|
||||
length_seconds: Int32,
|
||||
index: Int32,
|
||||
mixes: Array(String),
|
||||
rdid: String,
|
||||
})
|
||||
end
|
||||
|
||||
@ -28,18 +28,13 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
||||
end
|
||||
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>.*);/)
|
||||
if yt_data
|
||||
yt_data = JSON.parse(yt_data["data"].rchop(";"))
|
||||
else
|
||||
initial_data = extract_initial_data(response.body)
|
||||
|
||||
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
||||
raise translate(locale, "Could not create mix.")
|
||||
end
|
||||
|
||||
if !yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
||||
raise translate(locale, "Could not create mix.")
|
||||
end
|
||||
|
||||
playlist = yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
|
||||
playlist = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
|
||||
mix_title = playlist["title"].as_s
|
||||
|
||||
contents = playlist["contents"].as_a
|
||||
@ -70,7 +65,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
||||
ucid,
|
||||
length_seconds,
|
||||
index,
|
||||
[rdid]
|
||||
rdid
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -1,4 +1,32 @@
|
||||
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({
|
||||
title: String,
|
||||
id: String,
|
||||
@ -6,7 +34,7 @@ struct PlaylistVideo
|
||||
ucid: String,
|
||||
length_seconds: Int32,
|
||||
published: Time,
|
||||
playlists: Array(String),
|
||||
plid: String,
|
||||
index: Int32,
|
||||
live_now: Bool,
|
||||
})
|
||||
@ -19,7 +47,6 @@ struct Playlist
|
||||
author: String,
|
||||
author_thumbnail: String,
|
||||
ucid: String,
|
||||
description: String,
|
||||
description_html: String,
|
||||
video_count: Int32,
|
||||
views: Int64,
|
||||
@ -114,8 +141,8 @@ def extract_playlist(plid, nodeset, index)
|
||||
author: author,
|
||||
ucid: ucid,
|
||||
length_seconds: length_seconds,
|
||||
published: Time.now,
|
||||
playlists: [plid],
|
||||
published: Time.utc,
|
||||
plid: plid,
|
||||
index: index + offset,
|
||||
live_now: live_now
|
||||
)
|
||||
@ -130,37 +157,44 @@ def produce_playlist_url(id, index)
|
||||
end
|
||||
ucid = "VL" + id
|
||||
|
||||
meta = IO::Memory.new
|
||||
meta.write(Bytes[0x08])
|
||||
meta.write(write_var_int(index))
|
||||
data = IO::Memory.new
|
||||
data.write_byte 0x08
|
||||
VarInt.to_io(data, index)
|
||||
|
||||
meta.rewind
|
||||
meta = Base64.urlsafe_encode(meta.to_slice, false)
|
||||
meta = "PT:#{meta}"
|
||||
data.rewind
|
||||
data = Base64.urlsafe_encode(data, false)
|
||||
data = "PT:#{data}"
|
||||
|
||||
continuation = IO::Memory.new
|
||||
continuation.write(Bytes[0x7a, meta.size])
|
||||
continuation.print(meta)
|
||||
continuation.write_byte 0x7a
|
||||
VarInt.to_io(continuation, data.bytesize)
|
||||
continuation.print data
|
||||
|
||||
continuation.rewind
|
||||
meta = Base64.urlsafe_encode(continuation.to_slice)
|
||||
meta = URI.escape(meta)
|
||||
data = Base64.urlsafe_encode(continuation)
|
||||
cursor = URI.escape(data)
|
||||
|
||||
continuation = IO::Memory.new
|
||||
continuation.write(Bytes[0x12, ucid.size])
|
||||
continuation.print(ucid)
|
||||
continuation.write(Bytes[0x1a, meta.size])
|
||||
continuation.print(meta)
|
||||
data = IO::Memory.new
|
||||
|
||||
wrapper = IO::Memory.new
|
||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
||||
wrapper.print(continuation)
|
||||
wrapper.rewind
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, ucid.bytesize)
|
||||
data.print ucid
|
||||
|
||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
||||
wrapper = URI.escape(wrapper)
|
||||
data.write_byte 0x1a
|
||||
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
|
||||
end
|
||||
@ -186,9 +220,8 @@ def fetch_playlist(plid, locale)
|
||||
end
|
||||
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"]))
|
||||
description_html, description = html_to_content(description_html)
|
||||
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])).try &.to_s ||
|
||||
document.xpath_node(%q(//span[@class="pl-header-description-text"])).try &.to_s || ""
|
||||
|
||||
# YouTube allows anonymous playlists, so most of this can be empty or optional
|
||||
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
|
||||
@ -208,7 +241,7 @@ def fetch_playlist(plid, locale)
|
||||
if updated
|
||||
updated = decode_date(updated)
|
||||
else
|
||||
updated = Time.now
|
||||
updated = Time.utc
|
||||
end
|
||||
|
||||
playlist = Playlist.new(
|
||||
@ -217,7 +250,6 @@ def fetch_playlist(plid, locale)
|
||||
author: author,
|
||||
author_thumbnail: author_thumbnail,
|
||||
ucid: ucid,
|
||||
description: description,
|
||||
description_html: description_html,
|
||||
video_count: video_count,
|
||||
views: views,
|
||||
|
@ -1,4 +1,92 @@
|
||||
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({
|
||||
title: String,
|
||||
id: String,
|
||||
@ -6,7 +94,6 @@ struct SearchVideo
|
||||
ucid: String,
|
||||
published: Time,
|
||||
views: Int64,
|
||||
description: String,
|
||||
description_html: String,
|
||||
length_seconds: Int32,
|
||||
live_now: Bool,
|
||||
@ -25,6 +112,45 @@ struct SearchPlaylistVideo
|
||||
end
|
||||
|
||||
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({
|
||||
title: String,
|
||||
id: String,
|
||||
@ -37,13 +163,50 @@ struct SearchPlaylist
|
||||
end
|
||||
|
||||
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({
|
||||
author: String,
|
||||
ucid: String,
|
||||
author_thumbnail: String,
|
||||
subscriber_count: Int32,
|
||||
video_count: Int32,
|
||||
description: String,
|
||||
description_html: String,
|
||||
})
|
||||
end
|
||||
@ -93,8 +256,8 @@ def channel_search(query, page, channel)
|
||||
return count, items
|
||||
end
|
||||
|
||||
def search(query, page = 1, search_params = produce_search_params(content_type: "all"), proxies = nil, region = nil)
|
||||
client = make_client(YT_URL, proxies, region)
|
||||
def search(query, page = 1, search_params = produce_search_params(content_type: "all"), region = nil)
|
||||
client = make_client(YT_URL, region)
|
||||
if query.empty?
|
||||
return {0, [] of SearchItem}
|
||||
end
|
||||
@ -211,45 +374,51 @@ end
|
||||
def produce_channel_search_url(ucid, query, page)
|
||||
page = "#{page}"
|
||||
|
||||
meta = IO::Memory.new
|
||||
meta.write(Bytes[0x12, 0x06])
|
||||
meta.print("search")
|
||||
data = IO::Memory.new
|
||||
data.write_byte 0x12
|
||||
data.write_byte 0x06
|
||||
data.print "search"
|
||||
|
||||
meta.write(Bytes[0x30, 0x02])
|
||||
meta.write(Bytes[0x38, 0x01])
|
||||
meta.write(Bytes[0x60, 0x01])
|
||||
meta.write(Bytes[0x6a, 0x00])
|
||||
meta.write(Bytes[0xb8, 0x01, 0x00])
|
||||
data.write Bytes[0x30, 0x02]
|
||||
data.write Bytes[0x38, 0x01]
|
||||
data.write Bytes[0x60, 0x01]
|
||||
data.write Bytes[0x6a, 0x00]
|
||||
data.write Bytes[0xb8, 0x01, 0x00]
|
||||
|
||||
meta.write(Bytes[0x7a, page.size])
|
||||
meta.print(page)
|
||||
data.write_byte 0x7a
|
||||
VarInt.to_io(data, page.bytesize)
|
||||
data.print page
|
||||
|
||||
meta.rewind
|
||||
meta = Base64.urlsafe_encode(meta.to_slice)
|
||||
meta = URI.escape(meta)
|
||||
data.rewind
|
||||
data = Base64.urlsafe_encode(data)
|
||||
continuation = URI.escape(data)
|
||||
|
||||
continuation = IO::Memory.new
|
||||
continuation.write(Bytes[0x12, ucid.size])
|
||||
continuation.print(ucid)
|
||||
data = IO::Memory.new
|
||||
|
||||
continuation.write(Bytes[0x1a, meta.size])
|
||||
continuation.print(meta)
|
||||
data.write_byte 0x12
|
||||
VarInt.to_io(data, ucid.bytesize)
|
||||
data.print ucid
|
||||
|
||||
continuation.write(Bytes[0x5a, query.size])
|
||||
continuation.print(query)
|
||||
data.write_byte 0x1a
|
||||
VarInt.to_io(data, continuation.bytesize)
|
||||
data.print continuation
|
||||
|
||||
continuation.rewind
|
||||
continuation = continuation.gets_to_end
|
||||
data.write_byte 0x5a
|
||||
VarInt.to_io(data, query.bytesize)
|
||||
data.print query
|
||||
|
||||
wrapper = IO::Memory.new
|
||||
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
|
||||
wrapper.print(continuation)
|
||||
wrapper.rewind
|
||||
data.rewind
|
||||
|
||||
wrapper = Base64.urlsafe_encode(wrapper.to_slice)
|
||||
wrapper = URI.escape(wrapper)
|
||||
buffer = IO::Memory.new
|
||||
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
|
||||
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)
|
||||
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"
|
||||
@ -14,14 +14,9 @@ def fetch_trending(trending_type, proxies, region, locale)
|
||||
|
||||
response = client.get("/feed/trending?gl=#{region}&hl=en", headers).body
|
||||
|
||||
yt_data = response.match(/window\["ytInitialData"\] = (?<data>.*);/)
|
||||
if yt_data
|
||||
yt_data = JSON.parse(yt_data["data"].rchop(";"))
|
||||
else
|
||||
raise translate(locale, "Could not pull trending pages.")
|
||||
end
|
||||
initial_data = extract_initial_data(response)
|
||||
|
||||
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]?
|
||||
|
||||
if url
|
||||
|
@ -1,5 +1,8 @@
|
||||
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
|
||||
module PreferencesConverter
|
||||
def self.from_rs(rs)
|
||||
@ -20,9 +23,10 @@ struct User
|
||||
type: Preferences,
|
||||
converter: PreferencesConverter,
|
||||
},
|
||||
password: String?,
|
||||
token: String,
|
||||
watched: Array(String),
|
||||
password: String?,
|
||||
token: String,
|
||||
watched: Array(String),
|
||||
feed_needs_update: Bool?,
|
||||
})
|
||||
end
|
||||
|
||||
@ -40,10 +44,10 @@ struct Preferences
|
||||
begin
|
||||
result = [] of String
|
||||
value.read_array do
|
||||
result << HTML.escape(value.read_string)
|
||||
result << HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
result = [HTML.escape(value.read_string), ""]
|
||||
result = [HTML.escape(value.read_string[0, 100]), ""]
|
||||
end
|
||||
|
||||
result
|
||||
@ -69,11 +73,11 @@ struct Preferences
|
||||
node.raise "Expected scalar, not #{item.class}"
|
||||
end
|
||||
|
||||
result << HTML.escape(item.value)
|
||||
result << HTML.escape(item.value[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
result = [HTML.escape(node.value), ""]
|
||||
result = [HTML.escape(node.value[0, 100]), ""]
|
||||
else
|
||||
result = ["", ""]
|
||||
end
|
||||
@ -83,13 +87,13 @@ struct Preferences
|
||||
end
|
||||
end
|
||||
|
||||
module EscapeString
|
||||
module ProcessString
|
||||
def self.to_json(value : String, json : JSON::Builder)
|
||||
json.string value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : String
|
||||
HTML.escape(value.read_string)
|
||||
HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
|
||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||
@ -97,7 +101,25 @@ struct Preferences
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@ -113,13 +135,13 @@ struct Preferences
|
||||
latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
|
||||
listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
|
||||
local: {type: Bool, default: CONFIG.default_user_preferences.local},
|
||||
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: EscapeString},
|
||||
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results},
|
||||
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString},
|
||||
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt},
|
||||
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},
|
||||
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},
|
||||
thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode},
|
||||
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)
|
||||
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_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)
|
||||
|
||||
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
|
||||
view_name = "subscriptions_#{sha256(user.email)}"
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
||||
SELECT * FROM channel_videos WHERE \
|
||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
|
||||
ORDER BY published DESC;")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||
rescue ex
|
||||
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)
|
||||
|
||||
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
|
||||
view_name = "subscriptions_#{sha256(user.email)}"
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \
|
||||
SELECT * FROM channel_videos WHERE \
|
||||
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
|
||||
ORDER BY published DESC;")
|
||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
@ -205,7 +221,7 @@ def fetch_user(sid, headers, db)
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
@ -213,7 +229,7 @@ def create_user(sid, email, password)
|
||||
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
||||
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
|
||||
end
|
||||
@ -313,10 +329,108 @@ def subscribe_ajax(channel_id, action, env_headers)
|
||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
post_req = {
|
||||
"session_token" => session_token,
|
||||
session_token: session_token,
|
||||
}
|
||||
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
|
||||
|
||||
client.post(post_url, headers, form: post_req)
|
||||
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"},
|
||||
"136" => {"ext" => "mp4", "height" => 720, "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"},
|
||||
"212" => {"ext" => "mp4", "height" => 480, "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},
|
||||
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
|
||||
"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
|
||||
@ -273,190 +279,209 @@ struct Video
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(locale, config, kemal_config, decrypt_function)
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
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
|
||||
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "type", "video"
|
||||
|
||||
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 "descriptionHtml", description_html
|
||||
json.field "published", self.published.to_unix
|
||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||
json.field "keywords", self.keywords
|
||||
json.field "description", html_to_content(self.description_html)
|
||||
json.field "descriptionHtml", self.description_html
|
||||
json.field "published", self.published.to_unix
|
||||
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
||||
json.field "keywords", self.keywords
|
||||
|
||||
json.field "viewCount", self.views
|
||||
json.field "likeCount", self.likes
|
||||
json.field "dislikeCount", self.dislikes
|
||||
json.field "viewCount", self.views
|
||||
json.field "likeCount", self.likes
|
||||
json.field "dislikeCount", self.dislikes
|
||||
|
||||
json.field "paid", self.paid
|
||||
json.field "premium", self.premium
|
||||
json.field "isFamilyFriendly", self.is_family_friendly
|
||||
json.field "allowedRegions", self.allowed_regions
|
||||
json.field "genre", self.genre
|
||||
json.field "genreUrl", self.genre_url
|
||||
json.field "paid", self.paid
|
||||
json.field "premium", self.premium
|
||||
json.field "isFamilyFriendly", self.is_family_friendly
|
||||
json.field "allowedRegions", self.allowed_regions
|
||||
json.field "genre", self.genre
|
||||
json.field "genreUrl", self.genre_url
|
||||
|
||||
json.field "author", self.author
|
||||
json.field "authorId", self.ucid
|
||||
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||
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}
|
||||
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("=s48-", "=s#{quality}-")
|
||||
json.field "width", quality
|
||||
json.field "height", quality
|
||||
end
|
||||
qualities.each do |quality|
|
||||
json.object do
|
||||
json.field "url", self.author_thumbnail.gsub("=s48-", "=s#{quality}-")
|
||||
json.field "width", quality
|
||||
json.field "height", quality
|
||||
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 "allowRatings", self.allow_ratings
|
||||
json.field "rating", self.info["avg_rating"].to_f32
|
||||
json.field "isListed", self.is_listed
|
||||
json.field "liveNow", self.live_now
|
||||
json.field "isUpcoming", self.is_upcoming
|
||||
json.field "lengthSeconds", self.length_seconds
|
||||
json.field "allowRatings", self.allow_ratings
|
||||
json.field "rating", self.info["avg_rating"].to_f32
|
||||
json.field "isListed", self.is_listed
|
||||
json.field "liveNow", self.live_now
|
||||
json.field "isUpcoming", self.is_upcoming
|
||||
|
||||
if self.premiere_timestamp
|
||||
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
|
||||
end
|
||||
if self.premiere_timestamp
|
||||
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
|
||||
end
|
||||
|
||||
if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
||||
host_url = make_host_url(config, kemal_config)
|
||||
if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
|
||||
host_url = make_host_url(config, kemal_config)
|
||||
|
||||
hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s
|
||||
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
||||
hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s
|
||||
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
||||
|
||||
json.field "hlsUrl", hlsvp
|
||||
end
|
||||
json.field "hlsUrl", hlsvp
|
||||
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.array do
|
||||
self.adaptive_fmts(decrypt_function).each do |fmt|
|
||||
json.object do
|
||||
json.field "index", fmt["index"]
|
||||
json.field "bitrate", fmt["bitrate"]
|
||||
json.field "init", fmt["init"]
|
||||
json.field "url", fmt["url"]
|
||||
json.field "itag", fmt["itag"]
|
||||
json.field "type", fmt["type"]
|
||||
json.field "clen", fmt["clen"]
|
||||
json.field "lmt", fmt["lmt"]
|
||||
json.field "projectionType", fmt["projection_type"]
|
||||
json.field "adaptiveFormats" do
|
||||
json.array do
|
||||
self.adaptive_fmts(decrypt_function).each do |fmt|
|
||||
json.object do
|
||||
json.field "index", fmt["index"]
|
||||
json.field "bitrate", fmt["bitrate"]
|
||||
json.field "init", fmt["init"]
|
||||
json.field "url", fmt["url"]
|
||||
json.field "itag", fmt["itag"]
|
||||
json.field "type", fmt["type"]
|
||||
json.field "clen", fmt["clen"]
|
||||
json.field "lmt", fmt["lmt"]
|
||||
json.field "projectionType", fmt["projection_type"]
|
||||
|
||||
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"]
|
||||
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"
|
||||
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
|
||||
quality_label = "#{fmt_info["height"]}p"
|
||||
if fps > 30
|
||||
quality_label += "60"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "qualityLabel", quality_label
|
||||
|
||||
json.field "formatStreams" do
|
||||
json.array do
|
||||
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
|
||||
if fmt_info["width"]?
|
||||
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
||||
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
|
||||
|
||||
json.field "formatStreams" do
|
||||
json.array do
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
|
||||
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
|
||||
|
||||
if fmt_stream["width"]?
|
||||
@ -610,8 +642,14 @@ struct Video
|
||||
fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
|
||||
fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
|
||||
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"]?
|
||||
fmt["index"] = "#{index["start"]}-#{index["end"]}"
|
||||
end
|
||||
@ -786,18 +824,23 @@ struct Video
|
||||
end
|
||||
|
||||
def short_description
|
||||
description = self.description.gsub("<br>", " ")
|
||||
description = description.gsub("<br/>", " ")
|
||||
description = XML.parse_html(description).content[0..200].gsub('"', """).gsub("\n", " ").strip(" ")
|
||||
if description.empty?
|
||||
description = " "
|
||||
short_description = self.description_html.gsub(/(<br>)|(<br\/>|"|\n)/, {
|
||||
"<br>": " ",
|
||||
"<br/>": " ",
|
||||
"\"": """,
|
||||
"\n": " ",
|
||||
})
|
||||
short_description = XML.parse_html(short_description).content[0..200].strip(" ")
|
||||
|
||||
if short_description.empty?
|
||||
short_description = " "
|
||||
end
|
||||
|
||||
return description
|
||||
return short_description
|
||||
end
|
||||
|
||||
def length_seconds
|
||||
return self.info["length_seconds"].to_i
|
||||
self.player_response["videoDetails"]["lengthSeconds"].as_s.to_i
|
||||
end
|
||||
|
||||
db_mapping({
|
||||
@ -845,14 +888,16 @@ end
|
||||
class VideoRedirect < Exception
|
||||
end
|
||||
|
||||
def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32}), refresh = true, region = nil, force_refresh = false)
|
||||
if db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", id, as: Bool) && !region
|
||||
video = db.query_one("SELECT * FROM videos WHERE id = $1", id, as: Video)
|
||||
|
||||
# If record was last updated over 10 minutes ago, refresh (expire param in response lasts for 6 hours)
|
||||
if (refresh && Time.now - video.updated > 10.minutes) || force_refresh
|
||||
def get_video(id, db, refresh = true, region = nil, force_refresh = false)
|
||||
if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region
|
||||
# If record was last updated over 10 minutes ago, or video has since premiered,
|
||||
# refresh (expire param in response lasts for 6 hours)
|
||||
if (refresh &&
|
||||
(Time.utc - video.updated > 10.minutes) ||
|
||||
(video.premiere_timestamp && video.premiere_timestamp.as(Time) < Time.utc)) ||
|
||||
force_refresh
|
||||
begin
|
||||
video = fetch_video(id, proxies, region: region)
|
||||
video = fetch_video(id, region)
|
||||
video_array = video.to_a
|
||||
|
||||
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
|
||||
else
|
||||
video = fetch_video(id, proxies, region: region)
|
||||
video = fetch_video(id, region)
|
||||
video_array = video.to_a
|
||||
|
||||
args = arg_array(video_array)
|
||||
@ -893,7 +938,7 @@ def extract_polymer_config(body, html)
|
||||
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"]?
|
||||
.try &.["twoColumnWatchNextResults"]?
|
||||
@ -971,7 +1016,7 @@ def extract_polymer_config(body, html)
|
||||
if published
|
||||
params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s
|
||||
else
|
||||
params["published"] = Time.new(1990, 1, 1).to_unix.to_s
|
||||
params["published"] = Time.utc(1990, 1, 1).to_unix.to_s
|
||||
end
|
||||
|
||||
params["description_html"] = "<p></p>"
|
||||
@ -1071,8 +1116,8 @@ def extract_player_config(body, html)
|
||||
return params
|
||||
end
|
||||
|
||||
def fetch_video(id, proxies, region)
|
||||
client = make_client(YT_URL, proxies, region)
|
||||
def fetch_video(id, 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")
|
||||
|
||||
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"
|
||||
bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new
|
||||
|
||||
proxies.each do |proxy_region, list|
|
||||
PROXY_LIST.each do |proxy_region, list|
|
||||
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_html = XML.parse_html(proxy_response.body)
|
||||
@ -1105,7 +1150,7 @@ def fetch_video(id, proxies, region)
|
||||
end
|
||||
end
|
||||
|
||||
proxies.size.times do
|
||||
PROXY_LIST.size.times do
|
||||
response = bypass_channel.receive
|
||||
if response
|
||||
html, info = response
|
||||
@ -1130,41 +1175,38 @@ def fetch_video(id, proxies, region)
|
||||
end
|
||||
end
|
||||
|
||||
if info["errorcode"]?.try &.== "2"
|
||||
if info["errorcode"]?.try &.== "2" || !info["player_response"]
|
||||
raise "Video unavailable."
|
||||
end
|
||||
|
||||
if !info["title"]?
|
||||
raise "Video unavailable."
|
||||
if info["reason"]?
|
||||
raise info["reason"]
|
||||
end
|
||||
|
||||
title = info["title"]
|
||||
author = info["author"]
|
||||
ucid = info["ucid"]
|
||||
player_json = JSON.parse(info["player_response"])
|
||||
|
||||
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 = views.try &.["content"].to_i64?
|
||||
views ||= 0_i64
|
||||
.try &.["content"].to_i64? || 0_i64
|
||||
|
||||
likes = html.xpath_node(%q(//button[@title="I like this"]/span))
|
||||
likes = likes.try &.content.delete(",").try &.to_i?
|
||||
likes ||= 0
|
||||
.try &.content.delete(",").try &.to_i? || 0
|
||||
|
||||
dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span))
|
||||
dislikes = dislikes.try &.content.delete(",").try &.to_i?
|
||||
dislikes ||= 0
|
||||
.try &.content.delete(",").try &.to_i? || 0
|
||||
|
||||
avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1)
|
||||
avg_rating = avg_rating.nan? ? 0.0 : avg_rating
|
||||
info["avg_rating"] = "#{avg_rating}"
|
||||
|
||||
description = html.xpath_node(%q(//p[@id="eow-description"]))
|
||||
description = description ? description.to_xml(options: XML::SaveOptions::NO_DECL) : ""
|
||||
|
||||
description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || ""
|
||||
wilson_score = ci_lower_bound(likes, likes + dislikes)
|
||||
|
||||
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)
|
||||
|
||||
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 ||= ""
|
||||
|
||||
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
|
||||
case genre
|
||||
@ -1193,30 +1236,12 @@ def fetch_video(id, proxies, region)
|
||||
when "Trailers"
|
||||
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
|
||||
end
|
||||
genre_url ||= ""
|
||||
|
||||
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li))
|
||||
if license
|
||||
license = license.content
|
||||
else
|
||||
license = ""
|
||||
end
|
||||
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || ""
|
||||
sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")])).try &.["title"]? || "0"
|
||||
author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || ""
|
||||
|
||||
sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")]))
|
||||
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,
|
||||
video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html,
|
||||
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail)
|
||||
|
||||
return video
|
||||
@ -1233,12 +1258,12 @@ def process_video_params(query, preferences)
|
||||
continue = query["continue"]?.try &.to_i?
|
||||
continue_autoplay = query["continue_autoplay"]?.try &.to_i?
|
||||
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 }
|
||||
quality = query["quality"]?
|
||||
region = query["region"]?
|
||||
related_videos = query["related_videos"]?
|
||||
speed = query["speed"]?.try &.to_f?
|
||||
related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe
|
||||
speed = query["speed"]?.try &.rchop("x").to_f?
|
||||
video_loop = query["loop"]?.try &.to_i?
|
||||
volume = query["volume"]?.try &.to_i?
|
||||
|
||||
@ -1282,6 +1307,14 @@ def process_video_params(query, preferences)
|
||||
related_videos = related_videos == 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"]?
|
||||
video_start = decode_time(query["t"])
|
||||
end
|
||||
|
Loading…
x
Reference in New Issue
Block a user