Merge branch 'master' into api-only

This commit is contained in:
Omar Roth 2019-07-29 20:08:04 -05:00
commit 4dc95b18d2
No known key found for this signature in database
GPG Key ID: B8254FB7EC3D37F2
39 changed files with 3490 additions and 1573 deletions

View File

@ -1,3 +1,129 @@
# 0.19.0 (2019-07-13)
# Version 0.19.0: Communities
Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
## Communities
Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
## For Developers
For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
## For Administrators
It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
## Finances
### Donations
- [Patreon](https://www.patreon.com/omarroth) : \$38.39
- [Liberapay](https://liberapay.com/omarroth) : \$84.85
- Crypto : ~\$0.00 (converted from BCH, BTC)
- Total : \$123.24
### Expenses
- invidious-load1 (nyc1) : \$10.00 (load balancer)
- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
- Total : \$105.00
The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
As always I'm grateful for everyone's support and feedback. I'll see you all next month.
# 0.18.0 (2019-06-06)
# Version 0.18.0: Native Notifications and Optimizations
Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
## For Developers
`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
## For Administrators
There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
## Native Notifications
[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
## Finances
### Donations
- [Patreon](https://www.patreon.com/omarroth) : \$49.73
- [Liberapay](https://liberapay.com/omarroth) : \$100.57
- Crypto : ~\$11.12 (converted from BCH, BTC)
- Total : \$161.42
### Expenses
- invidious-load1 (nyc1) : \$10.00 (load balancer)
- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
- Total : \$85.00
See you all next month!
# 0.17.0 (2019-05-06) # 0.17.0 (2019-05-06)
# Version 0.17.0: Player and Authentication API # Version 0.17.0: Player and Authentication API

View File

@ -27,12 +27,16 @@ Patreon: https://patreon.com/omarroth
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
Onion links: ## Invidious Instances
- kgg2m7yk5aybusll.onion See [Invidious Instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) for a full list of publicly available instances.
- axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
[Alternative Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances) ### Official Instances
- [invidio.us](https://invidio.us) 🇺🇸
Issuer: Let's Encrypt, [SSLLabs Verification](https://www.ssllabs.com/ssltest/analyze.html?d=invidio.us)
- [kgg2m7yk5aybusll.onion](http://kgg2m7yk5aybusll.onion)
- [axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion](http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion)
## Screenshots ## Screenshots

View File

@ -0,0 +1,3 @@
#!/bin/sh
psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean"

View File

@ -13,7 +13,7 @@ services:
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "127.0.0.1:3000:3000"
depends_on: depends_on:
- postgres - postgres

View File

@ -6,7 +6,7 @@
"Unsubscribe": "إلغاء الإشتراك", "Unsubscribe": "إلغاء الإشتراك",
"Subscribe": "إشتراك", "Subscribe": "إشتراك",
"View channel on YouTube": "زيارة القناة على موقع يوتيوب", "View channel on YouTube": "زيارة القناة على موقع يوتيوب",
"View playlist on YouTube": "", "View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب",
"newest": "الأجدد", "newest": "الأجدد",
"oldest": "الأقدم", "oldest": "الأقدم",
"popular": "الاكثر شعبية", "popular": "الاكثر شعبية",
@ -56,7 +56,7 @@
"Play next by default: ": "شغل الفيديو التالى تلقائيا", "Play next by default: ": "شغل الفيديو التالى تلقائيا",
"Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)", "Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)",
"Listen by default: ": "تشغيل النسخة السمعية تلقائى: ", "Listen by default: ": "تشغيل النسخة السمعية تلقائى: ",
"Proxy videos? ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟", "Proxy videos: ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟",
"Default speed: ": "السرعة الإفتراضية: ", "Default speed: ": "السرعة الإفتراضية: ",
"Preferred video quality: ": "الجودة المفضلة للفيديوهات: ", "Preferred video quality: ": "الجودة المفضلة للفيديوهات: ",
"Player volume: ": "صوت المشغل: ", "Player volume: ": "صوت المشغل: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "الترجمات الإفتراضية: ", "Default captions: ": "الترجمات الإفتراضية: ",
"Fallback captions: ": "الترجمات المصاحبة: ", "Fallback captions: ": "الترجمات المصاحبة: ",
"Show related videos? ": "عرض مقاطع الفيديو ذات الصلة؟", "Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟",
"Show annotations by default? ": "عرض الملاحظات فى الفيديو تلقائيا ؟", "Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟",
"Visual preferences": "التفضيلات المرئية", "Visual preferences": "التفضيلات المرئية",
"Dark mode: ": "الوضع الليلى: ", "Dark mode: ": "الوضع الليلى: ",
"Thin mode: ": "الوضع الخفيف: ", "Thin mode: ": "الوضع الخفيف: ",
"Subscription preferences": "تفضيلات الإشتراك", "Subscription preferences": "تفضيلات الإشتراك",
"Show annotations by default for subscribed channels? ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟", "Show annotations by default for subscribed channels: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟",
"Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ",
"Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ",
"Sort videos by: ": "ترتيب الفيديو بـ: ", "Sort videos by: ": "ترتيب الفيديو بـ: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ", "Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
"Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ", "Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ",
"Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ", "Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
"Enable web notifications": "تفعيل إشعارات المتصفح",
"`x` uploaded a video": "`x` رفع فيديو",
"`x` is live": "`x` فى بث مباشر",
"Data preferences": "إعدادات التفضيلات", "Data preferences": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة", "Clear watch history": "حذف سجل المشاهدة",
"Import/export data": "إضافة\\إستخراج البيانات", "Import/export data": "إضافة\\إستخراج البيانات",
@ -96,11 +99,11 @@
"Administrator preferences": "إعدادات المدير", "Administrator preferences": "إعدادات المدير",
"Default homepage: ": "الصفحة الرئيسية الافتراضية ", "Default homepage: ": "الصفحة الرئيسية الافتراضية ",
"Feed menu: ": "قائمة التغذية", "Feed menu: ": "قائمة التغذية",
"Top enabled? ": "تفعيل 'الأفضل' ؟ ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ",
"CAPTCHA enabled? ": "تفعيل الكابتشا ؟", "CAPTCHA enabled: ": "تفعيل الكابتشا ؟",
"Login enabled? ": "تفعيل تسجيل الدخول ؟", "Login enabled: ": "تفعيل تسجيل الدخول ؟",
"Registration enabled? ": "تفعيل التسجيل ؟", "Registration enabled: ": "تفعيل التسجيل ؟",
"Report statistics? ": "إبلاغ الإحصائيات", "Report statistics: ": "إبلاغ الإحصائيات",
"Save preferences": "حفظ التفضيلات", "Save preferences": "حفظ التفضيلات",
"Subscription manager": "مدير الإشتراكات", "Subscription manager": "مدير الإشتراكات",
"Token manager": "إداره الرمز", "Token manager": "إداره الرمز",
@ -133,6 +136,7 @@
"Shared `x`": "شارك منذ `x`", "Shared `x`": "شارك منذ `x`",
"`x` views": "`x` مشاهدون", "`x` views": "`x` مشاهدون",
"Premieres in `x`": "يعرض فى `x`", "Premieres in `x`": "يعرض فى `x`",
"Premieres `x`": "يعرض `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.",
"View YouTube comments": "عرض تعليقات اليوتيوب", "View YouTube comments": "عرض تعليقات اليوتيوب",
"View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "(تم تعديلة)", "(edited)": "(تم تعديلة)",
"YouTube comment permalink": "رابط التعليق على اليوتيوب", "YouTube comment permalink": "رابط التعليق على اليوتيوب",
"permalink": "الرابط",
"`x` marked it with a ❤": "`x` اعجب بهذا", "`x` marked it with a ❤": "`x` اعجب بهذا",
"Audio mode": "الوضع الصوتى", "Audio mode": "الوضع الصوتى",
"Video mode": "وضع الفيديو", "Video mode": "وضع الفيديو",
"Videos": "الفيديوهات", "Videos": "الفيديوهات",
"Playlists": "قوائم التشغيل", "Playlists": "قوائم التشغيل",
"Community": "المجتمع",
"Current version: ": "الإصدار الحالى" "Current version: ": "الإصدار الحالى"
} }

View File

@ -6,7 +6,7 @@
"Unsubscribe": "Abbestellen", "Unsubscribe": "Abbestellen",
"Subscribe": "Abonnieren", "Subscribe": "Abonnieren",
"View channel on YouTube": "Kanal auf YouTube anzeigen", "View channel on YouTube": "Kanal auf YouTube anzeigen",
"View playlist on YouTube": "", "View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen",
"newest": "neueste", "newest": "neueste",
"oldest": "älteste", "oldest": "älteste",
"popular": "beliebt", "popular": "beliebt",
@ -56,7 +56,7 @@
"Play next by default: ": "Standardmäßig als nächstes abspielen: ", "Play next by default: ": "Standardmäßig als nächstes abspielen: ",
"Autoplay next video: ": "nächstes Video automatisch abspielen: ", "Autoplay next video: ": "nächstes Video automatisch abspielen: ",
"Listen by default: ": "Nur Ton als Standard: ", "Listen by default: ": "Nur Ton als Standard: ",
"Proxy videos? ": "Proxy-Videos? ", "Proxy videos: ": "Proxy-Videos? ",
"Default speed: ": "Standardgeschwindigkeit: ", "Default speed: ": "Standardgeschwindigkeit: ",
"Preferred video quality: ": "Bevorzugte Videoqualität: ", "Preferred video quality: ": "Bevorzugte Videoqualität: ",
"Player volume: ": "Playerlautstärke: ", "Player volume: ": "Playerlautstärke: ",
@ -65,13 +65,13 @@
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "Standarduntertitel: ", "Default captions: ": "Standarduntertitel: ",
"Fallback captions: ": "Ersatzuntertitel: ", "Fallback captions: ": "Ersatzuntertitel: ",
"Show related videos? ": "Ähnliche Videos anzeigen? ", "Show related videos: ": "Ähnliche Videos anzeigen? ",
"Show annotations by default? ": "Standardmäßig Anmerkungen anzeigen? ", "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ",
"Visual preferences": "Anzeigeeinstellungen", "Visual preferences": "Anzeigeeinstellungen",
"Dark mode: ": "Nachtmodus: ", "Dark mode: ": "Nachtmodus: ",
"Thin mode: ": "Schlanker Modus: ", "Thin mode: ": "Schlanker Modus: ",
"Subscription preferences": "Abonnementeinstellungen", "Subscription preferences": "Abonnementeinstellungen",
"Show annotations by default for subscribed channels? ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ",
"Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ",
"Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ", "Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ",
"Sort videos by: ": "Videos sortieren nach: ", "Sort videos by: ": "Videos sortieren nach: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ", "Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ",
"Only show unwatched: ": "Nur ungesehene anzeigen: ", "Only show unwatched: ": "Nur ungesehene anzeigen: ",
"Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ", "Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
"Enable web notifications": "Webbenachrichtigungen aktivieren",
"`x` uploaded a video": "`x` hat ein Video hochgeladen",
"`x` is live": "`x` ist live",
"Data preferences": "Dateneinstellungen", "Data preferences": "Dateneinstellungen",
"Clear watch history": "Verlauf löschen", "Clear watch history": "Verlauf löschen",
"Import/export data": "Daten im- exportieren", "Import/export data": "Daten im- exportieren",
@ -96,11 +99,11 @@
"Administrator preferences": "Administratoreinstellungen", "Administrator preferences": "Administratoreinstellungen",
"Default homepage: ": "Standard-Homepage: ", "Default homepage: ": "Standard-Homepage: ",
"Feed menu: ": "Feed-Menü: ", "Feed menu: ": "Feed-Menü: ",
"Top enabled? ": "Top aktiviert? ", "Top enabled: ": "Top aktiviert? ",
"CAPTCHA enabled? ": "CAPTCHA aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ",
"Login enabled? ": "Login aktiviert? ", "Login enabled: ": "Login aktiviert? ",
"Registration enabled? ": "Registrierung aktiviert? ", "Registration enabled: ": "Registrierung aktiviert? ",
"Report statistics? ": "Statistiken berichten? ", "Report statistics: ": "Statistiken berichten? ",
"Save preferences": "Einstellungen speichern", "Save preferences": "Einstellungen speichern",
"Subscription manager": "Abonnementverwaltung", "Subscription manager": "Abonnementverwaltung",
"Token manager": "Token-Manager", "Token manager": "Token-Manager",
@ -133,6 +136,7 @@
"Shared `x`": "Geteilt `x`", "Shared `x`": "Geteilt `x`",
"`x` views": "`x` Ansichten", "`x` views": "`x` Ansichten",
"Premieres in `x`": "Premieren in `x`", "Premieres in `x`": "Premieren in `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.",
"View YouTube comments": "YouTube Kommentare anzeigen", "View YouTube comments": "YouTube Kommentare anzeigen",
"View more comments on Reddit": "Mehr Kommentare auf Reddit anzeigen", "View more comments on Reddit": "Mehr Kommentare auf Reddit anzeigen",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editiert)", "(edited)": "(editiert)",
"YouTube comment permalink": "YouTube-Kommentar Permalink", "YouTube comment permalink": "YouTube-Kommentar Permalink",
"permalink": "",
"`x` marked it with a ❤": "`x` markierte es mit einem ❤", "`x` marked it with a ❤": "`x` markierte es mit einem ❤",
"Audio mode": "Audiomodus", "Audio mode": "Audiomodus",
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Videos", "Videos": "Videos",
"Playlists": "Wiedergabelisten", "Playlists": "Wiedergabelisten",
"Community": "",
"Current version: ": "Aktuelle Version: " "Current version: ": "Aktuelle Version: "
} }

View File

@ -62,7 +62,7 @@
"Play next by default: ": "Αναπαραγωγή επόμενου: ", "Play next by default: ": "Αναπαραγωγή επόμενου: ",
"Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ", "Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ",
"Listen by default: ": "Φόρτωση μόνο ήχου: ", "Listen by default: ": "Φόρτωση μόνο ήχου: ",
"Proxy videos? ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ", "Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ",
"Default speed: ": "Προεπιλεγμένη ταχύτητα: ", "Default speed: ": "Προεπιλεγμένη ταχύτητα: ",
"Preferred video quality: ": "Προτιμώμενη ανάλυση: ", "Preferred video quality: ": "Προτιμώμενη ανάλυση: ",
"Player volume: ": "Ένταση αναπαραγωγής: ", "Player volume: ": "Ένταση αναπαραγωγής: ",
@ -71,13 +71,13 @@
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ", "Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ",
"Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ", "Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ",
"Show related videos? ": "Προβολή σχετικών βίντεο; ", "Show related videos: ": "Προβολή σχετικών βίντεο; ",
"Show annotations by default? ": "Αυτόματη προβολή σημειώσεων; :", "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων; :",
"Visual preferences": "Προτιμήσεις εμφάνισης", "Visual preferences": "Προτιμήσεις εμφάνισης",
"Dark mode: ": "Σκοτεινή λειτουργία: ", "Dark mode: ": "Σκοτεινή λειτουργία: ",
"Thin mode: ": "Ελαφριά λειτουργία: ", "Thin mode: ": "Ελαφριά λειτουργία: ",
"Subscription preferences": "Προτιμήσεις συνδρομών", "Subscription preferences": "Προτιμήσεις συνδρομών",
"Show annotations by default for subscribed channels? ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ",
"Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ", "Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ",
"Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", "Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ",
"Sort videos by: ": "Ταξινόμηση ανά: ", "Sort videos by: ": "Ταξινόμηση ανά: ",
@ -91,6 +91,9 @@
"Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ", "Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ",
"Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ", "Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ",
"Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", "Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Προτιμήσεις δεδομένων", "Data preferences": "Προτιμήσεις δεδομένων",
"Clear watch history": "Εκκαθάριση ιστορικού προβολής", "Clear watch history": "Εκκαθάριση ιστορικού προβολής",
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων", "Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",
@ -102,11 +105,11 @@
"Administrator preferences": "Προτιμήσεις διαχειριστή", "Administrator preferences": "Προτιμήσεις διαχειριστή",
"Default homepage: ": "Προεπιλεγμένη αρχική: ", "Default homepage: ": "Προεπιλεγμένη αρχική: ",
"Feed menu: ": "Μενού ροής συνδρομών: ", "Feed menu: ": "Μενού ροής συνδρομών: ",
"Top enabled? ": "Ενεργοποίηση κορυφαίων; ", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ",
"CAPTCHA enabled? ": "Ενεργοποίηση CAPTCHA; ", "CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ",
"Login enabled? ": "Ενεργοποίηση σύνδεσης; ", "Login enabled: ": "Ενεργοποίηση σύνδεσης; ",
"Registration enabled? ": "Ενεργοποίηση εγγραφής; ", "Registration enabled: ": "Ενεργοποίηση εγγραφής; ",
"Report statistics? ": "Αναφορά στατιστικών; ", "Report statistics: ": "Αναφορά στατιστικών; ",
"Save preferences": "Αποθήκευση προτιμήσεων", "Save preferences": "Αποθήκευση προτιμήσεων",
"Subscription manager": "Διαχειριστής συνδρομών", "Subscription manager": "Διαχειριστής συνδρομών",
"Token manager": "Διαχειριστής διασυνδέσεων", "Token manager": "Διαχειριστής διασυνδέσεων",
@ -151,6 +154,7 @@
"": "`x` προβολές" "": "`x` προβολές"
}, },
"Premieres in `x`": "Πρώτη προβολή σε `x`", "Premieres in `x`": "Πρώτη προβολή σε `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά. ",
"View YouTube comments": "Προβολή σχολίων από το YouTube", "View YouTube comments": "Προβολή σχολίων από το YouTube",
"View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit", "View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit",
@ -351,10 +355,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(τροποποιημένο)", "(edited)": "(τροποποιημένο)",
"YouTube comment permalink": "Σύνδεσμος YouTube σχολίου", "YouTube comment permalink": "Σύνδεσμος YouTube σχολίου",
"permalink": "",
"`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤", "`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤",
"Audio mode": "Λειτουργία ήχου", "Audio mode": "Λειτουργία ήχου",
"Video mode": "Λειτουργία βίντεο", "Video mode": "Λειτουργία βίντεο",
"Videos": "Βίντεο", "Videos": "Βίντεο",
"Playlists": "Λίστες Αναπαραγωγής", "Playlists": "Λίστες Αναπαραγωγής",
"Community": "",
"Current version: ": "Τρέχουσα έκδοση: " "Current version: ": "Τρέχουσα έκδοση: "
} }

View File

@ -62,7 +62,7 @@
"Play next by default: ": "Play next by default: ", "Play next by default: ": "Play next by default: ",
"Autoplay next video: ": "Autoplay next video: ", "Autoplay next video: ": "Autoplay next video: ",
"Listen by default: ": "Listen by default: ", "Listen by default: ": "Listen by default: ",
"Proxy videos? ": "Proxy videos? ", "Proxy videos: ": "Proxy videos: ",
"Default speed: ": "Default speed: ", "Default speed: ": "Default speed: ",
"Preferred video quality: ": "Preferred video quality: ", "Preferred video quality: ": "Preferred video quality: ",
"Player volume: ": "Player volume: ", "Player volume: ": "Player volume: ",
@ -71,13 +71,13 @@
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "Default captions: ", "Default captions: ": "Default captions: ",
"Fallback captions: ": "Fallback captions: ", "Fallback captions: ": "Fallback captions: ",
"Show related videos? ": "Show related videos? ", "Show related videos: ": "Show related videos: ",
"Show annotations by default? ": "Show annotations by default? ", "Show annotations by default: ": "Show annotations by default: ",
"Visual preferences": "Visual preferences", "Visual preferences": "Visual preferences",
"Dark mode: ": "Dark mode: ", "Dark mode: ": "Dark mode: ",
"Thin mode: ": "Thin mode: ", "Thin mode: ": "Thin mode: ",
"Subscription preferences": "Subscription preferences", "Subscription preferences": "Subscription preferences",
"Show annotations by default for subscribed channels? ": "Show annotations by default for subscribed channels? ", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ",
"Redirect homepage to feed: ": "Redirect homepage to feed: ", "Redirect homepage to feed: ": "Redirect homepage to feed: ",
"Number of videos shown in feed: ": "Number of videos shown in feed: ", "Number of videos shown in feed: ": "Number of videos shown in feed: ",
"Sort videos by: ": "Sort videos by: ", "Sort videos by: ": "Sort videos by: ",
@ -91,6 +91,9 @@
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ", "Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
"Only show unwatched: ": "Only show unwatched: ", "Only show unwatched: ": "Only show unwatched: ",
"Only show notifications (if there are any): ": "Only show notifications (if there are any): ", "Only show notifications (if there are any): ": "Only show notifications (if there are any): ",
"Enable web notifications": "Enable web notifications",
"`x` uploaded a video": "`x` uploaded a video",
"`x` is live": "`x` is live",
"Data preferences": "Data preferences", "Data preferences": "Data preferences",
"Clear watch history": "Clear watch history", "Clear watch history": "Clear watch history",
"Import/export data": "Import/export data", "Import/export data": "Import/export data",
@ -102,11 +105,11 @@
"Administrator preferences": "Administrator preferences", "Administrator preferences": "Administrator preferences",
"Default homepage: ": "Default homepage: ", "Default homepage: ": "Default homepage: ",
"Feed menu: ": "Feed menu: ", "Feed menu: ": "Feed menu: ",
"Top enabled? ": "Top enabled? ", "Top enabled: ": "Top enabled: ",
"CAPTCHA enabled? ": "CAPTCHA enabled? ", "CAPTCHA enabled: ": "CAPTCHA enabled: ",
"Login enabled? ": "Login enabled? ", "Login enabled: ": "Login enabled? ",
"Registration enabled? ": "Registration enabled? ", "Registration enabled: ": "Registration enabled? ",
"Report statistics? ": "Report statistics? ", "Report statistics: ": "Report statistics? ",
"Save preferences": "Save preferences", "Save preferences": "Save preferences",
"Subscription manager": "Subscription manager", "Subscription manager": "Subscription manager",
"Token manager": "Token manager", "Token manager": "Token manager",
@ -151,6 +154,7 @@
"": "`x` views" "": "`x` views"
}, },
"Premieres in `x`": "Premieres in `x`", "Premieres in `x`": "Premieres in `x`",
"Premieres `x`": "Premieres `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.",
"View YouTube comments": "View YouTube comments", "View YouTube comments": "View YouTube comments",
"View more comments on Reddit": "View more comments on Reddit", "View more comments on Reddit": "View more comments on Reddit",
@ -351,10 +355,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(edited)", "(edited)": "(edited)",
"YouTube comment permalink": "YouTube comment permalink", "YouTube comment permalink": "YouTube comment permalink",
"permalink": "",
"`x` marked it with a ❤": "`x` marked it with a ❤", "`x` marked it with a ❤": "`x` marked it with a ❤",
"Audio mode": "Audio mode", "Audio mode": "Audio mode",
"Video mode": "Video mode", "Video mode": "Video mode",
"Videos": "Videos", "Videos": "Videos",
"Playlists": "Playlists", "Playlists": "Playlists",
"Community": "Community",
"Current version: ": "Current version: " "Current version: ": "Current version: "
} }

View File

@ -6,7 +6,7 @@
"Unsubscribe": "Malaboni", "Unsubscribe": "Malaboni",
"Subscribe": "Aboni", "Subscribe": "Aboni",
"View channel on YouTube": "Vidi kanalon en YouTube", "View channel on YouTube": "Vidi kanalon en YouTube",
"View playlist on YouTube": "", "View playlist on YouTube": "Vidi ludliston en YouTube",
"newest": "pli novaj", "newest": "pli novaj",
"oldest": "pli malnovaj", "oldest": "pli malnovaj",
"popular": "popularaj", "popular": "popularaj",
@ -56,7 +56,7 @@
"Play next by default: ": "Ludi sekvan defaŭlte: ", "Play next by default: ": "Ludi sekvan defaŭlte: ",
"Autoplay next video: ": "Aŭtomate ludi sekvan videon: ", "Autoplay next video: ": "Aŭtomate ludi sekvan videon: ",
"Listen by default: ": "Aŭskulti defaŭlte: ", "Listen by default: ": "Aŭskulti defaŭlte: ",
"Proxy videos? ": "Ĉu uzi prokuran servilon por videoj? ", "Proxy videos: ": "Ĉu uzi prokuran servilon por videoj? ",
"Default speed: ": "Defaŭlta rapido: ", "Default speed: ": "Defaŭlta rapido: ",
"Preferred video quality: ": "Preferita videkvalito: ", "Preferred video quality: ": "Preferita videkvalito: ",
"Player volume: ": "Ludila sonforteco: ", "Player volume: ": "Ludila sonforteco: ",
@ -65,13 +65,13 @@
"reddit": "reddit", "reddit": "reddit",
"Default captions: ": "Defaŭltaj subtekstoj: ", "Default captions: ": "Defaŭltaj subtekstoj: ",
"Fallback captions: ": "Retrodefaŭltaj subtekstoj: ", "Fallback captions: ": "Retrodefaŭltaj subtekstoj: ",
"Show related videos? ": "Ĉu montri rilatajn videojn? ", "Show related videos: ": "Ĉu montri rilatajn videojn? ",
"Show annotations by default? ": "Ĉu montri prinotojn defaŭlte? ", "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ",
"Visual preferences": "Vidaj preferoj", "Visual preferences": "Vidaj preferoj",
"Dark mode: ": "Malhela reĝimo: ", "Dark mode: ": "Malhela reĝimo: ",
"Thin mode: ": "Maldika reĝimo: ", "Thin mode: ": "Maldika reĝimo: ",
"Subscription preferences": "Abonaj agordoj", "Subscription preferences": "Abonaj agordoj",
"Show annotations by default for subscribed channels? ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ",
"Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ",
"Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ", "Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ",
"Sort videos by: ": "Ordi videojn laŭ: ", "Sort videos by: ": "Ordi videojn laŭ: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ", "Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ",
"Only show unwatched: ": "Nur montri malviditajn: ", "Only show unwatched: ": "Nur montri malviditajn: ",
"Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ", "Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
"Enable web notifications": "Ebligi retejajn sciigojn",
"`x` uploaded a video": "`x` alŝutis videon",
"`x` is live": "`x` estas nuna",
"Data preferences": "Datumagordoj", "Data preferences": "Datumagordoj",
"Clear watch history": "Forigi vidohistorion", "Clear watch history": "Forigi vidohistorion",
"Import/export data": "Importi/Eksporti datumojn", "Import/export data": "Importi/Eksporti datumojn",
@ -96,11 +99,11 @@
"Administrator preferences": "Agordoj de administranto", "Administrator preferences": "Agordoj de administranto",
"Default homepage: ": "Defaŭlta hejmpaĝo: ", "Default homepage: ": "Defaŭlta hejmpaĝo: ",
"Feed menu: ": "Flua menuo: ", "Feed menu: ": "Flua menuo: ",
"Top enabled? ": "Ĉu pli bonaj ŝaltitaj? ", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ",
"CAPTCHA enabled? ": "Ĉu CAPTCHA ŝaltita? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ",
"Login enabled? ": "Ĉu ensaluto aktivita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ",
"Registration enabled? ": "Ĉu registriĝo aktivita? ", "Registration enabled: ": "Ĉu registriĝo aktivita? ",
"Report statistics? ": "Ĉu raporti statistikojn? ", "Report statistics: ": "Ĉu raporti statistikojn? ",
"Save preferences": "Konservi agordojn", "Save preferences": "Konservi agordojn",
"Subscription manager": "Administrilo de abonoj", "Subscription manager": "Administrilo de abonoj",
"Token manager": "Ĵetona administrilo", "Token manager": "Ĵetona administrilo",
@ -133,6 +136,7 @@
"Shared `x`": "Konigita `x`", "Shared `x`": "Konigita `x`",
"`x` views": "`x` spektaĵoj", "`x` views": "`x` spektaĵoj",
"Premieres in `x`": "Premieras en `x`", "Premieres in `x`": "Premieras en `x`",
"Premieres `x`": "Premieras `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.",
"View YouTube comments": "Vidi komentojn de YouTube", "View YouTube comments": "Vidi komentojn de YouTube",
"View more comments on Reddit": "Vidi pli komentoj en Reddit", "View more comments on Reddit": "Vidi pli komentoj en Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%A %-d de %B %Y", "%A %B %-d, %Y": "%A %-d de %B %Y",
"(edited)": "(redaktita)", "(edited)": "(redaktita)",
"YouTube comment permalink": "Fiksligilo de la komento en YouTube", "YouTube comment permalink": "Fiksligilo de la komento en YouTube",
"permalink": "konstanta ligilo",
"`x` marked it with a ❤": "`x` markis ĝin per ❤", "`x` marked it with a ❤": "`x` markis ĝin per ❤",
"Audio mode": "Aŭda reĝimo", "Audio mode": "Aŭda reĝimo",
"Video mode": "Videa reĝimo", "Video mode": "Videa reĝimo",
"Videos": "Videoj", "Videos": "Videoj",
"Playlists": "Ludlistoj", "Playlists": "Ludlistoj",
"Community": "Komunumo",
"Current version: ": "Nuna versio: " "Current version: ": "Nuna versio: "
} }

View File

@ -56,7 +56,7 @@
"Play next by default: ": "Reproducir siguiente por defecto: ", "Play next by default: ": "Reproducir siguiente por defecto: ",
"Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ", "Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ",
"Listen by default: ": "Activar el sonido por defecto: ", "Listen by default: ": "Activar el sonido por defecto: ",
"Proxy videos? ": "¿Usar un proxy para los vídeos? ", "Proxy videos: ": "¿Usar un proxy para los vídeos? ",
"Default speed: ": "Velocidad por defecto: ", "Default speed: ": "Velocidad por defecto: ",
"Preferred video quality: ": "Calidad de vídeo preferida: ", "Preferred video quality: ": "Calidad de vídeo preferida: ",
"Player volume: ": "Volumen del reproductor: ", "Player volume: ": "Volumen del reproductor: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Subtítulos por defecto: ", "Default captions: ": "Subtítulos por defecto: ",
"Fallback captions: ": "Subtítulos alternativos: ", "Fallback captions: ": "Subtítulos alternativos: ",
"Show related videos? ": "¿Mostrar vídeos relacionados? ", "Show related videos: ": "¿Mostrar vídeos relacionados? ",
"Show annotations by default? ": "¿Mostrar anotaciones por defecto? ", "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ",
"Visual preferences": "Preferencias visuales", "Visual preferences": "Preferencias visuales",
"Dark mode: ": "Modo oscuro: ", "Dark mode: ": "Modo oscuro: ",
"Thin mode: ": "Modo compacto: ", "Thin mode: ": "Modo compacto: ",
"Subscription preferences": "Preferencias de la suscripción", "Subscription preferences": "Preferencias de la suscripción",
"Show annotations by default for subscribed channels? ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ",
"Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ",
"Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ", "Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ",
"Sort videos by: ": "Ordenar los vídeos por: ", "Sort videos by: ": "Ordenar los vídeos por: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ", "Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
"Only show unwatched: ": "Mostrar solo los no vistos: ", "Only show unwatched: ": "Mostrar solo los no vistos: ",
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ", "Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferencias de los datos", "Data preferences": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción", "Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos", "Import/export data": "Importar/Exportar datos",
@ -96,11 +99,11 @@
"Administrator preferences": "Preferencias de administrador", "Administrator preferences": "Preferencias de administrador",
"Default homepage: ": "Página de inicio por defecto: ", "Default homepage: ": "Página de inicio por defecto: ",
"Feed menu: ": "Menú de fuentes: ", "Feed menu: ": "Menú de fuentes: ",
"Top enabled? ": "¿Habilitar los destacados? ", "Top enabled: ": "¿Habilitar los destacados? ",
"CAPTCHA enabled? ": "¿Habilitar los CAPTCHA? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ",
"Login enabled? ": "¿Habilitar el inicio de sesión? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ",
"Registration enabled? ": "¿Habilitar el registro? ", "Registration enabled: ": "¿Habilitar el registro? ",
"Report statistics? ": "¿Enviar estadísticas? ", "Report statistics: ": "¿Enviar estadísticas? ",
"Save preferences": "Guardar las preferencias", "Save preferences": "Guardar las preferencias",
"Subscription manager": "Gestor de suscripciones", "Subscription manager": "Gestor de suscripciones",
"Token manager": "Gestor de tokens", "Token manager": "Gestor de tokens",
@ -133,6 +136,7 @@
"Shared `x`": "Compartido `x`", "Shared `x`": "Compartido `x`",
"`x` views": "`x` visualizaciones", "`x` views": "`x` visualizaciones",
"Premieres in `x`": "Se estrena en `x`", "Premieres in `x`": "Se estrena en `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.",
"View YouTube comments": "Ver los comentarios de YouTube", "View YouTube comments": "Ver los comentarios de YouTube",
"View more comments on Reddit": "Ver más comentarios en Reddit", "View more comments on Reddit": "Ver más comentarios en Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(editado)", "(edited)": "(editado)",
"YouTube comment permalink": "Enlace permanente de YouTube del comentario", "YouTube comment permalink": "Enlace permanente de YouTube del comentario",
"permalink": "",
"`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "`x` marked it with a ❤": "`x` lo ha marcado con un ❤",
"Audio mode": "Modo de audio", "Audio mode": "Modo de audio",
"Video mode": "Modo de vídeo", "Video mode": "Modo de vídeo",
"Videos": "Vídeos", "Videos": "Vídeos",
"Playlists": "Listas de reproducción", "Playlists": "Listas de reproducción",
"Community": "",
"Current version: ": "Versión actual: " "Current version: ": "Versión actual: "
} }

View File

@ -56,7 +56,7 @@
"Play next by default: ": "", "Play next by default: ": "",
"Autoplay next video: ": "", "Autoplay next video: ": "",
"Listen by default: ": "", "Listen by default: ": "",
"Proxy videos? ": "", "Proxy videos: ": "",
"Default speed: ": "", "Default speed: ": "",
"Preferred video quality: ": "", "Preferred video quality: ": "",
"Player volume: ": "", "Player volume: ": "",
@ -65,13 +65,13 @@
"reddit": "", "reddit": "",
"Default captions: ": "", "Default captions: ": "",
"Fallback captions: ": "", "Fallback captions: ": "",
"Show related videos? ": "", "Show related videos: ": "",
"Show annotations by default? ": "", "Show annotations by default: ": "",
"Visual preferences": "", "Visual preferences": "",
"Dark mode: ": "", "Dark mode: ": "",
"Thin mode: ": "", "Thin mode: ": "",
"Subscription preferences": "", "Subscription preferences": "",
"Show annotations by default for subscribed channels? ": "", "Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "", "Redirect homepage to feed: ": "",
"Number of videos shown in feed: ": "", "Number of videos shown in feed: ": "",
"Sort videos by: ": "", "Sort videos by: ": "",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "", "Only show latest unwatched video from channel: ": "",
"Only show unwatched: ": "", "Only show unwatched: ": "",
"Only show notifications (if there are any): ": "", "Only show notifications (if there are any): ": "",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "", "Data preferences": "",
"Clear watch history": "", "Clear watch history": "",
"Import/export data": "", "Import/export data": "",
@ -96,11 +99,11 @@
"Administrator preferences": "", "Administrator preferences": "",
"Default homepage: ": "", "Default homepage: ": "",
"Feed menu: ": "", "Feed menu: ": "",
"Top enabled? ": "", "Top enabled: ": "",
"CAPTCHA enabled? ": "", "CAPTCHA enabled: ": "",
"Login enabled? ": "", "Login enabled: ": "",
"Registration enabled? ": "", "Registration enabled: ": "",
"Report statistics? ": "", "Report statistics: ": "",
"Save preferences": "", "Save preferences": "",
"Subscription manager": "", "Subscription manager": "",
"Token manager": "", "Token manager": "",
@ -306,6 +309,7 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "", "(edited)": "",
"YouTube comment permalink": "", "YouTube comment permalink": "",
"permalink": "",
"`x` marked it with a ❤": "", "`x` marked it with a ❤": "",
"Audio mode": "", "Audio mode": "",
"Video mode": "", "Video mode": "",

View File

@ -56,7 +56,7 @@
"Play next by default: ": "Jouer suirvante par défaut : ", "Play next by default: ": "Jouer suirvante par défaut : ",
"Autoplay next video: ": "Lire automatiquement la vidéo suivante : ", "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ",
"Listen by default: ": "Audio uniquement : ", "Listen by default: ": "Audio uniquement : ",
"Proxy videos? ": "Charger les vidéos à travers un proxy ? ", "Proxy videos: ": "Charger les vidéos à travers un proxy ? ",
"Default speed: ": "Vitesse par défaut : ", "Default speed: ": "Vitesse par défaut : ",
"Preferred video quality: ": "Qualité vidéo souhaitée : ", "Preferred video quality: ": "Qualité vidéo souhaitée : ",
"Player volume: ": "Volume du lecteur : ", "Player volume: ": "Volume du lecteur : ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Sous-titres par défaut : ", "Default captions: ": "Sous-titres par défaut : ",
"Fallback captions: ": "Sous-titres de repli : ", "Fallback captions: ": "Sous-titres de repli : ",
"Show related videos? ": "Voir les vidéos liées ? ", "Show related videos: ": "Voir les vidéos liées ? ",
"Show annotations by default? ": "Voir les annotations par défaut ? ", "Show annotations by default: ": "Voir les annotations par défaut ? ",
"Visual preferences": "Préférences du site", "Visual preferences": "Préférences du site",
"Dark mode: ": "Mode Sombre : ", "Dark mode: ": "Mode Sombre : ",
"Thin mode: ": "Mode Simplifié : ", "Thin mode: ": "Mode Simplifié : ",
"Subscription preferences": "Préférences de la page d'abonnements", "Subscription preferences": "Préférences de la page d'abonnements",
"Show annotations by default for subscribed channels? ": "Voir les annotations par défaut sur les chaînes suivies ? ", "Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies ? ",
"Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ",
"Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ", "Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ",
"Sort videos by: ": "Trier les vidéos par : ", "Sort videos by: ": "Trier les vidéos par : ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ", "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
"Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ", "Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ",
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Préférences liées aux données", "Data preferences": "Préférences liées aux données",
"Clear watch history": "Supprimer l'historique des vidéos regardées", "Clear watch history": "Supprimer l'historique des vidéos regardées",
"Import/export data": "Importer/exporter les données", "Import/export data": "Importer/exporter les données",
@ -96,11 +99,11 @@
"Administrator preferences": "Préferences d'Administrateur", "Administrator preferences": "Préferences d'Administrateur",
"Default homepage: ": "Page d'accueil par défaut : ", "Default homepage: ": "Page d'accueil par défaut : ",
"Feed menu: ": "Menu des Flux : ", "Feed menu: ": "Menu des Flux : ",
"Top enabled? ": "Top activé ? ", "Top enabled: ": "Top activé ? ",
"CAPTCHA enabled? ": "CAPTCHA activé ? ", "CAPTCHA enabled: ": "CAPTCHA activé ? ",
"Login enabled? ": "Connexion activé ? ", "Login enabled: ": "Connexion activé ? ",
"Registration enabled? ": "Inscription activée ? ", "Registration enabled: ": "Inscription activée ? ",
"Report statistics? ": "Télémétrie activé ? ", "Report statistics: ": "Télémétrie activé ? ",
"Save preferences": "Enregistrer les préférences", "Save preferences": "Enregistrer les préférences",
"Subscription manager": "Gestionnaire d'abonnement", "Subscription manager": "Gestionnaire d'abonnement",
"Token manager": "Gestionnaire de tokens", "Token manager": "Gestionnaire de tokens",
@ -133,6 +136,7 @@
"Shared `x`": "Ajoutée le `x`", "Shared `x`": "Ajoutée le `x`",
"`x` views": "`x` vues", "`x` views": "`x` vues",
"Premieres in `x`": "Première dans `x`", "Premieres in `x`": "Première dans `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires sans. Gardez à l'esprit que le chargement peut prendre plus de temps.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires sans. Gardez à l'esprit que le chargement peut prendre plus de temps.",
"View YouTube comments": "Voir les commentaires YouTube", "View YouTube comments": "Voir les commentaires YouTube",
"View more comments on Reddit": "Voir plus de commentaires sur Reddit", "View more comments on Reddit": "Voir plus de commentaires sur Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(modifié)", "(edited)": "(modifié)",
"YouTube comment permalink": "Lien YouTube permanent vers le commentaire", "YouTube comment permalink": "Lien YouTube permanent vers le commentaire",
"permalink": "",
"`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "`x` marked it with a ❤": "`x` l'a marqué d'un ❤",
"Audio mode": "Mode Audio", "Audio mode": "Mode Audio",
"Video mode": "Mode Vidéo", "Video mode": "Mode Vidéo",
"Videos": "Vidéos", "Videos": "Vidéos",
"Playlists": "Liste de lecture", "Playlists": "Liste de lecture",
"Community": "",
"Current version: ": "Version actuelle : " "Current version: ": "Version actuelle : "
} }

319
locales/is.json Normal file
View 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: "
}

View File

@ -56,7 +56,7 @@
"Play next by default: ": "Riproduzione successiva per impostazione predefinita: ", "Play next by default: ": "Riproduzione successiva per impostazione predefinita: ",
"Autoplay next video: ": "Riproduci automaticamente il prossimo video: ", "Autoplay next video: ": "Riproduci automaticamente il prossimo video: ",
"Listen by default: ": "Modalità solo audio come predefinita: ", "Listen by default: ": "Modalità solo audio come predefinita: ",
"Proxy videos? ": "", "Proxy videos: ": "",
"Default speed: ": "Velocità di riproduzione predefinita: ", "Default speed: ": "Velocità di riproduzione predefinita: ",
"Preferred video quality: ": "Preferenza sulla qualità video: ", "Preferred video quality: ": "Preferenza sulla qualità video: ",
"Player volume: ": "Volume di riproduzione: ", "Player volume: ": "Volume di riproduzione: ",
@ -65,13 +65,13 @@
"reddit": "", "reddit": "",
"Default captions: ": "Sottotitoli predefiniti: ", "Default captions: ": "Sottotitoli predefiniti: ",
"Fallback captions: ": "Sottotitoli alternativi: ", "Fallback captions: ": "Sottotitoli alternativi: ",
"Show related videos? ": "Mostra video correlati? ", "Show related videos: ": "Mostra video correlati? ",
"Show annotations by default? ": "Mostra le annotazioni per impostazione predefinita? ", "Show annotations by default: ": "Mostra le annotazioni per impostazione predefinita? ",
"Visual preferences": "Preferenze grafiche", "Visual preferences": "Preferenze grafiche",
"Dark mode: ": "Tema scuro: ", "Dark mode: ": "Tema scuro: ",
"Thin mode: ": "Modalità per connessioni lente: ", "Thin mode: ": "Modalità per connessioni lente: ",
"Subscription preferences": "Preferenze iscrizioni", "Subscription preferences": "Preferenze iscrizioni",
"Show annotations by default for subscribed channels? ": "", "Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ",
"Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ", "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ",
"Sort videos by: ": "Ordinare i video per: ", "Sort videos by: ": "Ordinare i video per: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ", "Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ",
"Only show unwatched: ": "Mostra solo i video non guardati: ", "Only show unwatched: ": "Mostra solo i video non guardati: ",
"Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ", "Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferenze dati", "Data preferences": "Preferenze dati",
"Clear watch history": "Cancella la cronologia dei video guardati", "Clear watch history": "Cancella la cronologia dei video guardati",
"Import/export data": "Importazione/esportazione dati", "Import/export data": "Importazione/esportazione dati",
@ -96,11 +99,11 @@
"Administrator preferences": "", "Administrator preferences": "",
"Default homepage: ": "", "Default homepage: ": "",
"Feed menu: ": "", "Feed menu: ": "",
"Top enabled? ": "", "Top enabled: ": "",
"CAPTCHA enabled? ": "", "CAPTCHA enabled: ": "",
"Login enabled? ": "", "Login enabled: ": "",
"Registration enabled? ": "", "Registration enabled: ": "",
"Report statistics? ": "", "Report statistics: ": "",
"Save preferences": "Salva le preferenze", "Save preferences": "Salva le preferenze",
"Subscription manager": "Gestisci le iscrizioni", "Subscription manager": "Gestisci le iscrizioni",
"Token manager": "", "Token manager": "",
@ -306,10 +309,12 @@
"%A %B %-d, %Y": "%A %-d %B %Y", "%A %B %-d, %Y": "%A %-d %B %Y",
"(edited)": "(modificato)", "(edited)": "(modificato)",
"YouTube comment permalink": "Link permanente al commento di YouTube", "YouTube comment permalink": "Link permanente al commento di YouTube",
"permalink": "",
"`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤",
"Audio mode": "Modalità audio", "Audio mode": "Modalità audio",
"Video mode": "Modalità video", "Video mode": "Modalità video",
"Videos": "", "Videos": "",
"Playlists": "", "Playlists": "",
"Community": "",
"Current version: ": "" "Current version: ": ""
} }

View File

@ -6,7 +6,7 @@
"Unsubscribe": "Opphev abonnement", "Unsubscribe": "Opphev abonnement",
"Subscribe": "Abonner", "Subscribe": "Abonner",
"View channel on YouTube": "Vis kanal på YouTube", "View channel on YouTube": "Vis kanal på YouTube",
"View playlist on YouTube": "", "View playlist on YouTube": "Vis spilleliste på YouTube",
"newest": "nyeste", "newest": "nyeste",
"oldest": "eldste", "oldest": "eldste",
"popular": "populært", "popular": "populært",
@ -56,7 +56,7 @@
"Play next by default: ": "Spill neste som forvalg: ", "Play next by default: ": "Spill neste som forvalg: ",
"Autoplay next video: ": "Autospill neste video: ", "Autoplay next video: ": "Autospill neste video: ",
"Listen by default: ": "Lytt som forvalg: ", "Listen by default: ": "Lytt som forvalg: ",
"Proxy videos? ": "Mellomtjen videoer? ", "Proxy videos: ": "Mellomtjen videoer? ",
"Default speed: ": "Forvalgt hastighet: ", "Default speed: ": "Forvalgt hastighet: ",
"Preferred video quality: ": "Foretrukket videokvalitet: ", "Preferred video quality: ": "Foretrukket videokvalitet: ",
"Player volume: ": "Avspillerlydstyrke: ", "Player volume: ": "Avspillerlydstyrke: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Forvalgte undertitler: ", "Default captions: ": "Forvalgte undertitler: ",
"Fallback captions: ": "Tilbakefallsundertitler: ", "Fallback captions: ": "Tilbakefallsundertitler: ",
"Show related videos? ": "Vis relaterte videoer? ", "Show related videos: ": "Vis relaterte videoer? ",
"Show annotations by default? ": "Vis merknader som forvalg? ", "Show annotations by default: ": "Vis merknader som forvalg? ",
"Visual preferences": "Visuelle innstillinger", "Visual preferences": "Visuelle innstillinger",
"Dark mode: ": "Mørk drakt: ", "Dark mode: ": "Mørk drakt: ",
"Thin mode: ": "Tynt modus: ", "Thin mode: ": "Tynt modus: ",
"Subscription preferences": "Abonnementsinnstillinger", "Subscription preferences": "Abonnementsinnstillinger",
"Show annotations by default for subscribed channels? ": "Vis merknader som forvalg for kanaler det abonneres på? ", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ",
"Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ", "Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ",
"Number of videos shown in feed: ": "Antall videoer å vise i flyt: ", "Number of videos shown in feed: ": "Antall videoer å vise i flyt: ",
"Sort videos by: ": "Sorter videoer etter: ", "Sort videos by: ": "Sorter videoer etter: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ", "Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
"Only show unwatched: ": "Kun vis usette: ", "Only show unwatched: ": "Kun vis usette: ",
"Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ", "Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
"Enable web notifications": "Skru på nettmerknader",
"`x` uploaded a video": "`x` lastet opp en video",
"`x` is live": "`x` er pålogget",
"Data preferences": "Datainnstillinger", "Data preferences": "Datainnstillinger",
"Clear watch history": "Tøm visningshistorikk", "Clear watch history": "Tøm visningshistorikk",
"Import/export data": "Importer/eksporter data", "Import/export data": "Importer/eksporter data",
@ -96,11 +99,11 @@
"Administrator preferences": "Administratorinnstillinger", "Administrator preferences": "Administratorinnstillinger",
"Default homepage: ": "Forvalgt hjemmeside: ", "Default homepage: ": "Forvalgt hjemmeside: ",
"Feed menu: ": "Flyt-meny: ", "Feed menu: ": "Flyt-meny: ",
"Top enabled? ": "Topp påskrudd? ", "Top enabled: ": "Topp påskrudd? ",
"CAPTCHA enabled? ": "CAPTCHA påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ",
"Login enabled? ": "Innlogging påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ",
"Registration enabled? ": "Registrering påskrudd? ", "Registration enabled: ": "Registrering påskrudd? ",
"Report statistics? ": "Innrapporter statistikk? ", "Report statistics: ": "Innrapporter statistikk? ",
"Save preferences": "Lagre innstillinger", "Save preferences": "Lagre innstillinger",
"Subscription manager": "Abonnementsbehandler", "Subscription manager": "Abonnementsbehandler",
"Token manager": "Symbolbehandler", "Token manager": "Symbolbehandler",
@ -133,6 +136,7 @@
"Shared `x`": "Delt `x`", "Shared `x`": "Delt `x`",
"`x` views": "`x` visninger", "`x` views": "`x` visninger",
"Premieres in `x`": "Premiere om `x`", "Premieres in `x`": "Premiere om `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.",
"View YouTube comments": "Vis YouTube-kommentarer", "View YouTube comments": "Vis YouTube-kommentarer",
"View more comments on Reddit": "Vis flere kommenterer på Reddit", "View more comments on Reddit": "Vis flere kommenterer på Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "(redigert)", "(edited)": "(redigert)",
"YouTube comment permalink": "Permanent YouTube-lenke til innholdet", "YouTube comment permalink": "Permanent YouTube-lenke til innholdet",
"permalink": "permanent lenke",
"`x` marked it with a ❤": "`x` levnet et ❤", "`x` marked it with a ❤": "`x` levnet et ❤",
"Audio mode": "Lydmodus", "Audio mode": "Lydmodus",
"Video mode": "Video-modus", "Video mode": "Video-modus",
"Videos": "Videoer", "Videos": "Videoer",
"Playlists": "Spillelister", "Playlists": "Spillelister",
"Community": "",
"Current version: ": "Nåværende versjon: " "Current version: ": "Nåværende versjon: "
} }

View File

@ -56,7 +56,7 @@
"Play next by default: ": "Standaard volgende video afspelen: ", "Play next by default: ": "Standaard volgende video afspelen: ",
"Autoplay next video: ": "Volgende video automatisch afspelen: ", "Autoplay next video: ": "Volgende video automatisch afspelen: ",
"Listen by default: ": "Standaard luisteren: ", "Listen by default: ": "Standaard luisteren: ",
"Proxy videos? ": "Video's afspelen via proxy? ", "Proxy videos: ": "Video's afspelen via proxy? ",
"Default speed: ": "Standaard afspeelsnelheid: ", "Default speed: ": "Standaard afspeelsnelheid: ",
"Preferred video quality: ": "Voorkeurskwaliteit: ", "Preferred video quality: ": "Voorkeurskwaliteit: ",
"Player volume: ": "Spelervolume: ", "Player volume: ": "Spelervolume: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Standaard ondertiteling: ", "Default captions: ": "Standaard ondertiteling: ",
"Fallback captions: ": "Alternatieve ondertiteling: ", "Fallback captions: ": "Alternatieve ondertiteling: ",
"Show related videos? ": "Gerelateerde video's tonen? ", "Show related videos: ": "Gerelateerde video's tonen? ",
"Show annotations by default? ": "Standaard annotaties tonen? ", "Show annotations by default: ": "Standaard annotaties tonen? ",
"Visual preferences": "Visuele instellingen", "Visual preferences": "Visuele instellingen",
"Dark mode: ": "Donkere modus: ", "Dark mode: ": "Donkere modus: ",
"Thin mode: ": "Smalle modus: ", "Thin mode: ": "Smalle modus: ",
"Subscription preferences": "Abonnementsinstellingen", "Subscription preferences": "Abonnementsinstellingen",
"Show annotations by default for subscribed channels? ": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ",
"Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ",
"Number of videos shown in feed: ": "Aantal te tonen video's in feed: ", "Number of videos shown in feed: ": "Aantal te tonen video's in feed: ",
"Sort videos by: ": "Video's sorteren op: ", "Sort videos by: ": "Video's sorteren op: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ", "Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
"Only show unwatched: ": "Alleen niet-bekeken videos tonen: ", "Only show unwatched: ": "Alleen niet-bekeken videos tonen: ",
"Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ", "Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ",
"Enable web notifications": "Systemmeldingen inschakelen",
"`x` uploaded a video": "`x` heeft een video geüpload",
"`x` is live": "`x` zendt nu live uit",
"Data preferences": "Gegevensinstellingen", "Data preferences": "Gegevensinstellingen",
"Clear watch history": "Kijkgeschiedenis wissen", "Clear watch history": "Kijkgeschiedenis wissen",
"Import/export data": "Gegevens im-/exporteren", "Import/export data": "Gegevens im-/exporteren",
@ -96,11 +99,11 @@
"Administrator preferences": "Beheerdersinstellingen", "Administrator preferences": "Beheerdersinstellingen",
"Default homepage: ": "Standaard startpagina: ", "Default homepage: ": "Standaard startpagina: ",
"Feed menu: ": "Feedmenu:", "Feed menu: ": "Feedmenu:",
"Top enabled? ": "Bovenkant inschakelen? ", "Top enabled: ": "Bovenkant inschakelen? ",
"CAPTCHA enabled? ": "CAPTCHA gebruiken? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ",
"Login enabled? ": "Inloggen toestaan? ", "Login enabled: ": "Inloggen toestaan? ",
"Registration enabled? ": "Registratie toestaan? ", "Registration enabled: ": "Registratie toestaan? ",
"Report statistics? ": "Statistieken bijhouden? ", "Report statistics: ": "Statistieken bijhouden? ",
"Save preferences": "Instellingen opslaan", "Save preferences": "Instellingen opslaan",
"Subscription manager": "Abonnementen beheren", "Subscription manager": "Abonnementen beheren",
"Token manager": "Toegangssleutels beheren", "Token manager": "Toegangssleutels beheren",
@ -114,13 +117,13 @@
"`x` unseen notifications": "`x` ongelezen meldingen", "`x` unseen notifications": "`x` ongelezen meldingen",
"search": "zoeken", "search": "zoeken",
"Log out": "Uitloggen", "Log out": "Uitloggen",
"Released under the AGPLv3 by Omar Roth.": "Uitgegeven onder de AGPLv3-licentie door Omar Roth.", "Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.",
"Source available here.": "De broncode is hier beschikbaar.", "Source available here.": "De broncode is hier beschikbaar.",
"View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.",
"View privacy policy.": "Privacybeleid tonen", "View privacy policy.": "Privacybeleid tonen",
"Trending": "Uitgelicht", "Trending": "Uitgelicht",
"Unlisted": "Verborgen", "Unlisted": "Verborgen",
"Watch on YouTube": "Bekijk video op YouTube", "Watch on YouTube": "Video bekijken op YouTube",
"Hide annotations": "Annotaties verbergen", "Hide annotations": "Annotaties verbergen",
"Show annotations": "Annotaties tonen", "Show annotations": "Annotaties tonen",
"Genre: ": "Genre: ", "Genre: ": "Genre: ",
@ -133,6 +136,7 @@
"Shared `x`": "`x` gedeeld", "Shared `x`": "`x` gedeeld",
"`x` views": "`x` weergaven", "`x` views": "`x` weergaven",
"Premieres in `x`": "Verschijnt over `x`", "Premieres in `x`": "Verschijnt over `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.",
"View YouTube comments": "YouTube-reacties tonen", "View YouTube comments": "YouTube-reacties tonen",
"View more comments on Reddit": "Meer reacties bekijken op Reddit", "View more comments on Reddit": "Meer reacties bekijken op Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%A %B %-d, %Y", "%A %B %-d, %Y": "%A %B %-d, %Y",
"(edited)": "(bewerkt)", "(edited)": "(bewerkt)",
"YouTube comment permalink": "Link naar YouTube-reactie", "YouTube comment permalink": "Link naar YouTube-reactie",
"permalink": "",
"`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤", "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤",
"Audio mode": "Audiomodus", "Audio mode": "Audiomodus",
"Video mode": "Videomodus", "Video mode": "Videomodus",
"Videos": "Video's", "Videos": "Video's",
"Playlists": "Afspeellijsten", "Playlists": "Afspeellijsten",
"Community": "",
"Current version: ": "Huidige versie: " "Current version: ": "Huidige versie: "
} }

View File

@ -56,7 +56,7 @@
"Play next by default: ": "", "Play next by default: ": "",
"Autoplay next video: ": "Odtwórz następny film: ", "Autoplay next video: ": "Odtwórz następny film: ",
"Listen by default: ": "Tryb dźwiękowy: ", "Listen by default: ": "Tryb dźwiękowy: ",
"Proxy videos? ": "Filmy przez proxy? ", "Proxy videos: ": "Filmy przez proxy? ",
"Default speed: ": "Domyślna prędkość: ", "Default speed: ": "Domyślna prędkość: ",
"Preferred video quality: ": "Preferowana jakość filmów: ", "Preferred video quality: ": "Preferowana jakość filmów: ",
"Player volume: ": "Głośność odtwarzacza: ", "Player volume: ": "Głośność odtwarzacza: ",
@ -65,13 +65,13 @@
"reddit": "", "reddit": "",
"Default captions: ": "Domyślne napisy: ", "Default captions: ": "Domyślne napisy: ",
"Fallback captions: ": "Zastępcze napisy: ", "Fallback captions: ": "Zastępcze napisy: ",
"Show related videos? ": "Pokaż powiązane filmy? ", "Show related videos: ": "Pokaż powiązane filmy? ",
"Show annotations by default? ": "", "Show annotations by default: ": "",
"Visual preferences": "Preferencje Wizualne", "Visual preferences": "Preferencje Wizualne",
"Dark mode: ": "Ciemny motyw: ", "Dark mode: ": "Ciemny motyw: ",
"Thin mode: ": "Tryb minimalny: ", "Thin mode: ": "Tryb minimalny: ",
"Subscription preferences": "Preferencje subskrybcji", "Subscription preferences": "Preferencje subskrybcji",
"Show annotations by default for subscribed channels? ": "", "Show annotations by default for subscribed channels: ": "",
"Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ",
"Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ",
"Sort videos by: ": "Sortuj filmy: ", "Sort videos by: ": "Sortuj filmy: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ", "Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ", "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ", "Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferencje danych", "Data preferences": "Preferencje danych",
"Clear watch history": "Wyczyść historię", "Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych", "Import/export data": "Import/Eksport danych",
@ -96,11 +99,11 @@
"Administrator preferences": "Preferencje administratora", "Administrator preferences": "Preferencje administratora",
"Default homepage: ": "Domyślna strona główna: ", "Default homepage: ": "Domyślna strona główna: ",
"Feed menu: ": "", "Feed menu: ": "",
"Top enabled? ": "", "Top enabled: ": "",
"CAPTCHA enabled? ": "CAPTCHA aktywna? ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ",
"Login enabled? ": "Logowanie włączone? ", "Login enabled: ": "Logowanie włączone? ",
"Registration enabled? ": "Rejestracja włączona? ", "Registration enabled: ": "Rejestracja włączona? ",
"Report statistics? ": "Raportować statystyki? ", "Report statistics: ": "Raportować statystyki? ",
"Save preferences": "Zapisz preferencje", "Save preferences": "Zapisz preferencje",
"Subscription manager": "Manager subskrybcji", "Subscription manager": "Manager subskrybcji",
"Token manager": "", "Token manager": "",
@ -133,6 +136,7 @@
"Shared `x`": "Udostępniono `x`", "Shared `x`": "Udostępniono `x`",
"`x` views": "`x` wyświetleń", "`x` views": "`x` wyświetleń",
"Premieres in `x`": "Publikacja za `x`", "Premieres in `x`": "Publikacja za `x`",
"Premieres `x`": "",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.",
"View YouTube comments": "Wyświetl komentarze z YouTube", "View YouTube comments": "Wyświetl komentarze z YouTube",
"View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie", "View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "", "%A %B %-d, %Y": "",
"(edited)": "(edytowany)", "(edited)": "(edytowany)",
"YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube",
"permalink": "",
"`x` marked it with a ❤": "`x` oznaczonych ❤", "`x` marked it with a ❤": "`x` oznaczonych ❤",
"Audio mode": "Tryb audio", "Audio mode": "Tryb audio",
"Video mode": "Tryb wideo", "Video mode": "Tryb wideo",
"Videos": "Filmy", "Videos": "Filmy",
"Playlists": "Playlisty", "Playlists": "Playlisty",
"Community": "",
"Current version: ": "Aktualna wersja: " "Current version: ": "Aktualna wersja: "
} }

View File

@ -6,7 +6,7 @@
"Unsubscribe": "Отписаться", "Unsubscribe": "Отписаться",
"Subscribe": "Подписаться", "Subscribe": "Подписаться",
"View channel on YouTube": "Смотреть канал на YouTube", "View channel on YouTube": "Смотреть канал на YouTube",
"View playlist on YouTube": "", "View playlist on YouTube": "Посмотреть плейлист на YouTube",
"newest": "самые свежие", "newest": "самые свежие",
"oldest": "самые старые", "oldest": "самые старые",
"popular": "популярные", "popular": "популярные",
@ -56,7 +56,7 @@
"Play next by default: ": "Всегда включать следующее видео? ", "Play next by default: ": "Всегда включать следующее видео? ",
"Autoplay next video: ": "Автопроигрывание следующего видео: ", "Autoplay next video: ": "Автопроигрывание следующего видео: ",
"Listen by default: ": "Режим «только аудио» по умолчанию: ", "Listen by default: ": "Режим «только аудио» по умолчанию: ",
"Proxy videos? ": "Проигрывать видео через прокси? ", "Proxy videos: ": "Проигрывать видео через прокси? ",
"Default speed: ": "Скорость видео по умолчанию: ", "Default speed: ": "Скорость видео по умолчанию: ",
"Preferred video quality: ": "Предпочтительное качество видео: ", "Preferred video quality: ": "Предпочтительное качество видео: ",
"Player volume: ": "Громкость видео: ", "Player volume: ": "Громкость видео: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Основной язык субтитров: ", "Default captions: ": "Основной язык субтитров: ",
"Fallback captions: ": "Дополнительный язык субтитров: ", "Fallback captions: ": "Дополнительный язык субтитров: ",
"Show related videos? ": "Показывать похожие видео? ", "Show related videos: ": "Показывать похожие видео? ",
"Show annotations by default? ": "Всегда показывать аннотации? ", "Show annotations by default: ": "Всегда показывать аннотации? ",
"Visual preferences": "Настройки сайта", "Visual preferences": "Настройки сайта",
"Dark mode: ": "Тёмное оформление: ", "Dark mode: ": "Тёмное оформление: ",
"Thin mode: ": "Облегчённое оформление: ", "Thin mode: ": "Облегчённое оформление: ",
"Subscription preferences": "Настройки подписок", "Subscription preferences": "Настройки подписок",
"Show annotations by default for subscribed channels? ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ",
"Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ",
"Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ", "Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ",
"Sort videos by: ": "Сортировать видео: ", "Sort videos by: ": "Сортировать видео: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ", "Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ",
"Only show unwatched: ": "Показывать только непросмотренные видео: ", "Only show unwatched: ": "Показывать только непросмотренные видео: ",
"Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ", "Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ",
"Enable web notifications": "Включить уведомления в браузере",
"`x` uploaded a video": "`x` разместил видео",
"`x` is live": "`x` в прямом эфире",
"Data preferences": "Настройки данных", "Data preferences": "Настройки данных",
"Clear watch history": "Очистить историю просмотров", "Clear watch history": "Очистить историю просмотров",
"Import/export data": "Импорт/Экспорт данных", "Import/export data": "Импорт/Экспорт данных",
@ -96,11 +99,11 @@
"Administrator preferences": "Администраторские настройки", "Administrator preferences": "Администраторские настройки",
"Default homepage: ": "Главная страница по умолчанию: ", "Default homepage: ": "Главная страница по умолчанию: ",
"Feed menu: ": "Меню ленты видео: ", "Feed menu: ": "Меню ленты видео: ",
"Top enabled? ": "Включить топ видео? ", "Top enabled: ": "Включить топ видео? ",
"CAPTCHA enabled? ": "Включить капчу? ", "CAPTCHA enabled: ": "Включить капчу? ",
"Login enabled? ": "Включить авторизацию? ", "Login enabled: ": "Включить авторизацию? ",
"Registration enabled? ": "Включить регистрацию? ", "Registration enabled: ": "Включить регистрацию? ",
"Report statistics? ": "Сообщать статистику? ", "Report statistics: ": "Сообщать статистику? ",
"Save preferences": "Сохранить настройки", "Save preferences": "Сохранить настройки",
"Subscription manager": "Менеджер подписок", "Subscription manager": "Менеджер подписок",
"Token manager": "Менеджер токенов", "Token manager": "Менеджер токенов",
@ -133,6 +136,7 @@
"Shared `x`": "Опубликовано `x`", "Shared `x`": "Опубликовано `x`",
"`x` views": "`x` просмотров", "`x` views": "`x` просмотров",
"Premieres in `x`": "Премьера через `x`", "Premieres in `x`": "Премьера через `x`",
"Premieres `x`": "Премьера `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.",
"View YouTube comments": "Смотреть комментарии с YouTube", "View YouTube comments": "Смотреть комментарии с YouTube",
"View more comments on Reddit": "Посмотреть больше комментариев на Reddit", "View more comments on Reddit": "Посмотреть больше комментариев на Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%-d %B %Y, %A", "%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(изменено)", "(edited)": "(изменено)",
"YouTube comment permalink": "Прямая ссылка на YouTube", "YouTube comment permalink": "Прямая ссылка на YouTube",
"permalink": "",
"`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "`x` marked it with a ❤": "❤ от автора канала \"`x`\"",
"Audio mode": "Аудио режим", "Audio mode": "Аудио режим",
"Video mode": "Видео режим", "Video mode": "Видео режим",
"Videos": "Видео", "Videos": "Видео",
"Playlists": "Плейлисты", "Playlists": "Плейлисты",
"Community": "",
"Current version: ": "Текущая версия: " "Current version: ": "Текущая версия: "
} }

View File

@ -6,7 +6,7 @@
"Unsubscribe": "Відписатися", "Unsubscribe": "Відписатися",
"Subscribe": "Підписатися", "Subscribe": "Підписатися",
"View channel on YouTube": "Подивитися канал на YouTube", "View channel on YouTube": "Подивитися канал на YouTube",
"View playlist on YouTube": "", "View playlist on YouTube": "Подивитися плейлист на YouTube",
"newest": "найновіше", "newest": "найновіше",
"oldest": "найстаріше", "oldest": "найстаріше",
"popular": "популярне", "popular": "популярне",
@ -56,7 +56,7 @@
"Play next by default: ": "Завжди вмикати наступне відео: ", "Play next by default: ": "Завжди вмикати наступне відео: ",
"Autoplay next video: ": "Автовідтворення наступного відео: ", "Autoplay next video: ": "Автовідтворення наступного відео: ",
"Listen by default: ": "Режим «тільки звук» як усталений: ", "Listen by default: ": "Режим «тільки звук» як усталений: ",
"Proxy videos? ": "Програвати відео через проксі? ", "Proxy videos: ": "Програвати відео через проксі? ",
"Default speed: ": "Усталена швидкість відео: ", "Default speed: ": "Усталена швидкість відео: ",
"Preferred video quality: ": "Пріорітетна якість відео: ", "Preferred video quality: ": "Пріорітетна якість відео: ",
"Player volume: ": "Гучність відео: ", "Player volume: ": "Гучність відео: ",
@ -65,13 +65,13 @@
"reddit": "Reddit", "reddit": "Reddit",
"Default captions: ": "Основна мова субтитрів: ", "Default captions: ": "Основна мова субтитрів: ",
"Fallback captions: ": "Запасна мова субтитрів: ", "Fallback captions: ": "Запасна мова субтитрів: ",
"Show related videos? ": "Показувати схожі відео? ", "Show related videos: ": "Показувати схожі відео? ",
"Show annotations by default? ": "Завжди показувати анотації? ", "Show annotations by default: ": "Завжди показувати анотації? ",
"Visual preferences": "Налаштування сайту", "Visual preferences": "Налаштування сайту",
"Dark mode: ": "Темне оформлення: ", "Dark mode: ": "Темне оформлення: ",
"Thin mode: ": "Полегшене оформлення: ", "Thin mode: ": "Полегшене оформлення: ",
"Subscription preferences": "Налаштування підписок", "Subscription preferences": "Налаштування підписок",
"Show annotations by default for subscribed channels? ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ",
"Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ",
"Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ", "Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ",
"Sort videos by: ": "Сортувати відео: ", "Sort videos by: ": "Сортувати відео: ",
@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ", "Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ",
"Only show unwatched: ": "Показувати тільки непереглянуті відео: ", "Only show unwatched: ": "Показувати тільки непереглянуті відео: ",
"Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ", "Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ",
"Enable web notifications": "Ввімкнути сповіщення в браузері",
"`x` uploaded a video": "`x` розмістив відео",
"`x` is live": "`x` у прямому ефірі",
"Data preferences": "Налаштування даних", "Data preferences": "Налаштування даних",
"Clear watch history": "Очистити історію переглядів", "Clear watch history": "Очистити історію переглядів",
"Import/export data": "Імпорт і експорт даних", "Import/export data": "Імпорт і експорт даних",
@ -96,11 +99,11 @@
"Administrator preferences": "Адміністраторські налаштування", "Administrator preferences": "Адміністраторські налаштування",
"Default homepage: ": "Усталена домашня сторінка: ", "Default homepage: ": "Усталена домашня сторінка: ",
"Feed menu: ": "Меню потоку з відео: ", "Feed menu: ": "Меню потоку з відео: ",
"Top enabled? ": "Увімкнути топ відео? ", "Top enabled: ": "Увімкнути топ відео? ",
"CAPTCHA enabled? ": "Увімкнути капчу? ", "CAPTCHA enabled: ": "Увімкнути капчу? ",
"Login enabled? ": "Увімкнути авторизацію? ", "Login enabled: ": "Увімкнути авторизацію? ",
"Registration enabled? ": "Увімкнути реєстрацію? ", "Registration enabled: ": "Увімкнути реєстрацію? ",
"Report statistics? ": "Повідомляти статистику? ", "Report statistics: ": "Повідомляти статистику? ",
"Save preferences": "Зберегти налаштування", "Save preferences": "Зберегти налаштування",
"Subscription manager": "Менеджер підписок", "Subscription manager": "Менеджер підписок",
"Token manager": "Менеджер токенів", "Token manager": "Менеджер токенів",
@ -133,6 +136,7 @@
"Shared `x`": "Розміщено `x`", "Shared `x`": "Розміщено `x`",
"`x` views": "`x` переглядів", "`x` views": "`x` переглядів",
"Premieres in `x`": "Прем’єра через `x`", "Premieres in `x`": "Прем’єра через `x`",
"Premieres `x`": "Прем’єра `x`",
"Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.",
"View YouTube comments": "Переглянути коментарі з YouTube", "View YouTube comments": "Переглянути коментарі з YouTube",
"View more comments on Reddit": "Переглянути більше коментарів на Reddit", "View more comments on Reddit": "Переглянути більше коментарів на Reddit",
@ -306,10 +310,12 @@
"%A %B %-d, %Y": "%-d %B %Y, %A", "%A %B %-d, %Y": "%-d %B %Y, %A",
"(edited)": "(змінено)", "(edited)": "(змінено)",
"YouTube comment permalink": "Пряме посилання на коментар в YouTube", "YouTube comment permalink": "Пряме посилання на коментар в YouTube",
"permalink": "",
"`x` marked it with a ❤": "❤ цьому від каналу `x`", "`x` marked it with a ❤": "❤ цьому від каналу `x`",
"Audio mode": "Аудіорежим", "Audio mode": "Аудіорежим",
"Video mode": "Відеорежим", "Video mode": "Відеорежим",
"Videos": "Відео", "Videos": "Відео",
"Playlists": "Плейлисти", "Playlists": "Плейлисти",
"Community": "",
"Current version: ": "Поточна версія: " "Current version: ": "Поточна версія: "
} }

321
locales/zh-CN.json Normal file
View 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: ": "当前版本:"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,5 +1,5 @@
name: invidious name: invidious
version: 0.17.0 version: 0.19.0
authors: authors:
- Omar Roth <omarroth@protonmail.com> - Omar Roth <omarroth@protonmail.com>
@ -9,13 +9,13 @@ targets:
main: src/invidious.cr main: src/invidious.cr
dependencies: dependencies:
kemal:
github: kemalcr/kemal
pg: pg:
github: will/crystal-pg github: will/crystal-pg
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
kemal:
github: kemalcr/kemal
crystal: 0.28.0 crystal: 0.29.0
license: AGPLv3 license: AGPLv3

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,8 @@ end
struct ChannelVideo struct ChannelVideo
def to_json(locale, config, kemal_config, json : JSON::Builder) def to_json(locale, config, kemal_config, json : JSON::Builder)
json.object do json.object do
json.field "type", "shortVideo"
json.field "title", self.title json.field "title", self.title
json.field "videoId", self.id json.field "videoId", self.id
json.field "videoThumbnails" do json.field "videoThumbnails" do
@ -39,6 +41,48 @@ struct ChannelVideo
end end
end end
def to_xml(locale, host_url, xml : XML::Builder)
xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{self.id}" }
xml.element("yt:videoId") { xml.text self.id }
xml.element("yt:channelId") { xml.text self.ucid }
xml.element("title") { xml.text self.title }
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}")
xml.element("author") do
xml.element("name") { xml.text self.author }
xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" }
end
xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
end
end
end
xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") }
xml.element("media:group") do
xml.element("media:title") { xml.text self.title }
xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg",
width: "320", height: "180")
end
end
end
def to_xml(locale, config, kemal_config, xml : XML::Builder | Nil = nil)
if xml
to_xml(locale, config, kemal_config, xml)
else
XML.build do |xml|
to_xml(locale, config, kemal_config, xml)
end
end
end
db_mapping({ db_mapping({
id: String, id: String,
title: String, title: String,
@ -53,6 +97,36 @@ struct ChannelVideo
}) })
end end
struct AboutRelatedChannel
db_mapping({
ucid: String,
author: String,
author_url: String,
author_thumbnail: String,
})
end
# TODO: Refactor into either SearchChannel or InvidiousChannel
struct AboutChannel
db_mapping({
ucid: String,
author: String,
auto_generated: Bool,
author_url: String,
author_thumbnail: String,
banner: String?,
description_html: String,
paid: Bool,
total_views: Int64,
sub_count: Int64,
joined: Time,
is_family_friendly: Bool,
allowed_regions: Array(String),
related_channels: Array(AboutRelatedChannel),
tabs: Array(String),
})
end
def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
finished_channel = Channel(String | Nil).new finished_channel = Channel(String | Nil).new
@ -91,10 +165,8 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
end end
def get_channel(id, db, refresh = true, pull_all_videos = true) def get_channel(id, db, refresh = true, pull_all_videos = true)
if db.query_one?("SELECT EXISTS (SELECT true FROM channels WHERE id = $1)", id, as: Bool) if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
channel = db.query_one("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) if refresh && Time.utc - channel.updated > 10.minutes
if refresh && Time.now - channel.updated > 10.minutes
channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
channel_array = channel.to_a channel_array = channel.to_a
args = arg_array(channel_array) args = arg_array(channel_array)
@ -175,7 +247,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
id: video_id, id: video_id,
title: title, title: title,
published: published, published: published,
updated: Time.now, updated: Time.utc,
ucid: ucid, ucid: ucid,
author: author, author: author,
length_seconds: length_seconds, length_seconds: length_seconds,
@ -184,7 +256,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
views: views, views: views,
) )
users = db.query_all("UPDATE users SET notifications = notifications || $1 \ emails = db.query_all("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
video.id, video.published, ucid, as: String) video.id, video.published, ucid, as: String)
@ -198,13 +270,14 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
updated = $4, ucid = $5, author = $6, length_seconds = $7, \ updated = $4, ucid = $5, author = $6, length_seconds = $7, \
live_now = $8, views = $10", video_array) live_now = $8, views = $10", video_array)
users.each do |user| # Update all users affected by insert
payload = { if emails.empty?
"email" => user, values = "'{}'"
"action" => "refresh", else
}.to_json values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
end end
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
end end
if pull_all_videos if pull_all_videos
@ -237,7 +310,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
id: video.id, id: video.id,
title: video.title, title: video.title,
published: video.published, published: video.published,
updated: Time.now, updated: Time.utc,
ucid: video.ucid, ucid: video.ucid,
author: video.author, author: video.author,
length_seconds: video.length_seconds, length_seconds: video.length_seconds,
@ -251,8 +324,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
# We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # We are notified of Red videos elsewhere (PubSub), which includes a correct published date,
# so since they don't provide a published date here we can safely ignore them. # so since they don't provide a published date here we can safely ignore them.
if Time.now - video.published > 1.minute if Time.utc - video.published > 1.minute
users = db.query_all("UPDATE users SET notifications = notifications || $1 \ emails = db.query_all("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email",
video.id, video.published, video.ucid, as: String) video.id, video.published, video.ucid, as: String)
@ -266,13 +339,13 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
live_now = $8, views = $10", video_array) live_now = $8, views = $10", video_array)
# Update all users affected by insert # Update all users affected by insert
users.each do |user| if emails.empty?
payload = { values = "'{}'"
"email" => user, else
"action" => "refresh", values = "VALUES #{emails.map { |id| %(('#{id}')) }.join(",")}"
}.to_json
PG_DB.exec("NOTIFY feeds, E'#{payload}'")
end end
db.exec("UPDATE users SET feed_needs_update = true WHERE email = ANY(#{values})")
end end
end end
@ -287,31 +360,11 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid) db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
end end
channel = InvidiousChannel.new(ucid, author, Time.now, false, nil) channel = InvidiousChannel.new(ucid, author, Time.utc, false, nil)
return channel return channel
end end
def subscribe_pubsub(ucid, key, config)
client = make_client(PUBSUB_URL)
time = Time.now.to_unix.to_s
nonce = Random::Secure.hex(4)
signature = "#{time}:#{nonce}"
host_url = make_host_url(config, Kemal.config)
body = {
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?channel_id=#{ucid}",
"hub.verify" => "async",
"hub.mode" => "subscribe",
"hub.lease_seconds" => "432000",
"hub.secret" => key.to_s,
}
return client.post("/subscribe", form: body)
end
def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by) def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
client = make_client(YT_URL) client = make_client(YT_URL)
@ -322,13 +375,14 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
json = JSON.parse(response.body) json = JSON.parse(response.body)
if json["load_more_widget_html"].as_s.empty? if json["load_more_widget_html"].as_s.empty?
return [] of SearchItem, nil continuation = nil
end else
continuation = XML.parse_html(json["load_more_widget_html"].as_s)
continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href]))
continuation = XML.parse_html(json["load_more_widget_html"].as_s) if continuation
continuation = continuation.xpath_node(%q(//button[@data-uix-load-more-href])) continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
if continuation end
continuation = extract_channel_playlists_cursor(continuation["data-uix-load-more-href"], auto_generated)
end end
html = XML.parse_html(json["content_html"].as_s) html = XML.parse_html(json["content_html"].as_s)
@ -375,7 +429,7 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
if auto_generated if auto_generated
seed = Time.unix(1525757349) seed = Time.unix(1525757349)
until seed >= Time.now until seed >= Time.utc
seed += 1.month seed += 1.month
end end
timestamp = seed - (page - 1).months timestamp = seed - (page - 1).months
@ -387,53 +441,57 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "
switch = 0x00 switch = 0x00
end end
meta = IO::Memory.new data = IO::Memory.new
meta.write(Bytes[0x12, 0x06]) data.write_byte 0x12
meta.print("videos") data.write_byte 0x06
data.print "videos"
meta.write(Bytes[0x30, 0x02]) data.write Bytes[0x30, 0x02]
meta.write(Bytes[0x38, 0x01]) data.write Bytes[0x38, 0x01]
meta.write(Bytes[0x60, 0x01]) data.write Bytes[0x60, 0x01]
meta.write(Bytes[0x6a, 0x00]) data.write Bytes[0x6a, 0x00]
meta.write(Bytes[0xb8, 0x01, 0x00]) data.write Bytes[0xb8, 0x01, 0x00]
meta.write(Bytes[0x20, switch]) data.write Bytes[0x20, switch]
meta.write(Bytes[0x7a, page.size]) data.write_byte 0x7a
meta.print(page) VarInt.to_io(data, page.bytesize)
data.print page
case sort_by case sort_by
when "newest" when "newest"
# Empty tags can be omitted # Empty tags can be omitted
# meta.write(Bytes[0x18,0x00]) # meta.write(Bytes[0x18,0x00])
when "popular" when "popular"
meta.write(Bytes[0x18, 0x01]) data.write Bytes[0x18, 0x01]
when "oldest" when "oldest"
meta.write(Bytes[0x18, 0x02]) data.write Bytes[0x18, 0x02]
end end
meta.rewind data = Base64.urlsafe_encode(data)
meta = Base64.urlsafe_encode(meta.to_slice) cursor = URI.escape(data)
meta = URI.escape(meta)
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a, meta.size]) data.write_byte 0x12
continuation.print(meta) VarInt.to_io(data, ucid.bytesize)
data.print ucid
continuation.rewind data.write_byte 0x1a
continuation = continuation.gets_to_end VarInt.to_io(data, cursor.bytesize)
data.print cursor
wrapper = IO::Memory.new data.rewind
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
wrapper.print(continuation)
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice) buffer = IO::Memory.new
wrapper = URI.escape(wrapper) buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
VarInt.to_io(buffer, data.bytesize)
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en" IO.copy data, buffer
continuation = Base64.urlsafe_encode(buffer)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
return url return url
end end
@ -443,117 +501,108 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated
cursor = Base64.urlsafe_encode(cursor, false) cursor = Base64.urlsafe_encode(cursor, false)
end end
meta = IO::Memory.new data = IO::Memory.new
if auto_generated if auto_generated
meta.write(Bytes[0x08, 0x0a]) data.write Bytes[0x08, 0x0a]
end end
meta.write(Bytes[0x12, 0x09]) data.write Bytes[0x12, 0x09]
meta.print("playlists") data.print "playlists"
if auto_generated if auto_generated
meta.write(Bytes[0x20, 0x32]) data.write Bytes[0x20, 0x32]
else else
# TODO: Look at 0x01, 0x00 # TODO: Look at 0x01, 0x00
case sort case sort
when "oldest", "oldest_created" when "oldest", "oldest_created"
meta.write(Bytes[0x18, 0x02]) data.write Bytes[0x18, 0x02]
when "newest", "newest_created" when "newest", "newest_created"
meta.write(Bytes[0x18, 0x03]) data.write Bytes[0x18, 0x03]
when "last", "last_added" when "last", "last_added"
meta.write(Bytes[0x18, 0x04]) data.write Bytes[0x18, 0x04]
end end
meta.write(Bytes[0x20, 0x01]) data.write Bytes[0x20, 0x01]
end end
meta.write(Bytes[0x30, 0x02]) data.write Bytes[0x30, 0x02]
meta.write(Bytes[0x38, 0x01]) data.write Bytes[0x38, 0x01]
meta.write(Bytes[0x60, 0x01]) data.write Bytes[0x60, 0x01]
meta.write(Bytes[0x6a, 0x00]) data.write Bytes[0x6a, 0x00]
meta.write(Bytes[0x7a, cursor.size]) data.write_byte 0x7a
meta.print(cursor) VarInt.to_io(data, cursor.bytesize)
data.print cursor
meta.write(Bytes[0xb8, 0x01, 0x00]) data.write Bytes[0xb8, 0x01, 0x00]
meta.rewind data.rewind
meta = Base64.urlsafe_encode(meta.to_slice) data = Base64.urlsafe_encode(data)
meta = URI.escape(meta) continuation = URI.escape(data)
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a]) data.write_byte 0x12
continuation.write(write_var_int(meta.size)) VarInt.to_io(data, ucid.bytesize)
continuation.print(meta) data.print ucid
continuation.rewind data.write_byte 0x1a
continuation = continuation.gets_to_end VarInt.to_io(data, continuation.bytesize)
data.print continuation
wrapper = IO::Memory.new data.rewind
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02])
wrapper.write(write_var_int(continuation.size))
wrapper.print(continuation)
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice) buffer = IO::Memory.new
wrapper = URI.escape(wrapper) buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
VarInt.to_io(buffer, data.bytesize)
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en" IO.copy data, buffer
continuation = Base64.urlsafe_encode(buffer)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
return url return url
end end
def extract_channel_playlists_cursor(url, auto_generated) def extract_channel_playlists_cursor(url, auto_generated)
wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"] continuation = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"]
wrapper = URI.unescape(wrapper) continuation = URI.unescape(continuation)
wrapper = Base64.decode(wrapper) data = IO::Memory.new(Base64.decode(continuation))
# 0xe2 0xa9 0x85 0xb2 0x02 # 0xe2 0xa9 0x85 0xb2 0x02
wrapper += 5 data.pos += 5
continuation_size = read_var_int(wrapper[0, 4]) continuation = Bytes.new(data.read_bytes(VarInt))
wrapper += write_var_int(continuation_size).size data.read continuation
continuation = wrapper[0, continuation_size] data = IO::Memory.new(continuation)
# 0x12 data.read_byte # => 0x12
continuation += 1 ucid = Bytes.new(data.read_bytes(VarInt))
ucid_size = continuation[0] data.read ucid
continuation += 1
ucid = continuation[0, ucid_size]
continuation += ucid_size
# 0x1a data.read_byte # => 0x1a
continuation += 1 inner_continuation = Bytes.new(data.read_bytes(VarInt))
meta_size = read_var_int(continuation[0, 4]) data.read inner_continuation
continuation += write_var_int(meta_size).size
meta = continuation[0, meta_size]
continuation += meta_size
meta = String.new(meta) continuation = String.new(inner_continuation)
meta = URI.unescape(meta) continuation = URI.unescape(continuation)
meta = Base64.decode(meta) data = IO::Memory.new(Base64.decode(continuation))
# 0x12 0x09 playlists # 0x12 0x09 playlists
meta += 11 data.pos += 11
until meta[0] == 0x7a until data.peek[0] == 0x7a
tag = read_var_int(meta[0, 4]) key = data.read_bytes(VarInt)
meta += write_var_int(tag).size value = data.read_bytes(VarInt)
value = meta[0]
meta += 1
end end
# 0x7a data.pos += 1 # => 0x7a
meta += 1 cursor = Bytes.new(data.read_bytes(VarInt))
cursor_size = meta[0] data.read cursor
meta += 1
cursor = meta[0, cursor_size]
cursor = String.new(cursor) cursor = String.new(cursor)
if !auto_generated if !auto_generated
@ -564,6 +613,310 @@ def extract_channel_playlists_cursor(url, auto_generated)
return cursor return cursor
end end
# TODO: Add "sort_by"
def fetch_channel_community(ucid, continuation, locale, config, kemal_config, format, thin_mode)
client = make_client(YT_URL)
headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
response = client.get("/channel/#{ucid}/community?gl=US&hl=en", headers)
if response.status_code == 404
response = client.get("/user/#{ucid}/community?gl=US&hl=en", headers)
end
if response.status_code == 404
error_message = translate(locale, "This channel does not exist.")
raise error_message
end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
if !continuation || continuation.empty?
initial_data = extract_initial_data(response.body)
body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
if !body
raise "Could not extract community tab."
end
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
else
continuation = produce_channel_community_continuation(ucid, continuation)
headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"]
headers["content-type"] = "application/x-www-form-urlencoded"
headers["x-client-data"] = "CIi2yQEIpbbJAQipncoBCNedygEIqKPKAQ=="
headers["x-spf-previous"] = ""
headers["x-spf-referer"] = ""
headers["x-youtube-client-name"] = "1"
headers["x-youtube-client-version"] = "2.20180719"
session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[A-Za-z0-9\_\-\=]+)"/).try &.["session_token"]? || ""
post_req = {
session_token: session_token,
}
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
body = JSON.parse(response.body)
body = body["response"]["continuationContents"]["itemSectionContinuation"]? ||
body["response"]["continuationContents"]["backstageCommentsContinuation"]?
if !body
raise "Could not extract continuation."
end
end
continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s
posts = body["contents"].as_a
if message = posts[0]["messageRenderer"]?
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || ""
raise error_message
end
response = JSON.build do |json|
json.object do
json.field "authorId", ucid
json.field "comments" do
json.array do
posts.each do |post|
comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? ||
post["backstageCommentsContinuation"]?
post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? ||
post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]?
if !post
next
end
if !post["contentText"]?
content_html = ""
else
content_html = post["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
content_to_comment_html(post["contentText"]["runs"].as_a).try &.to_s || ""
end
author = post["authorText"]?.try &.["simpleText"]? || ""
json.object do
json.field "author", author
json.field "authorThumbnails" do
json.array do
qualities = {32, 48, 76, 100, 176, 512}
author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s
qualities.each do |quality|
json.object do
json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
if post["authorEndpoint"]?
json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"]
json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
else
json.field "authorId", ""
json.field "authorUrl", ""
end
published_text = post["publishedTimeText"]["runs"][0]["text"].as_s
published = decode_date(published_text.rchop(" (edited)"))
if published_text.includes?(" (edited)")
json.field "isEdited", true
else
json.field "isEdited", false
end
like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"]
.try &.as_s.gsub(/\D/, "").to_i? || 0
json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html
json.field "published", published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
json.field "likeCount", like_count
json.field "commentId", post["postId"]? || post["commentId"]? || ""
json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid
if attachment = post["backstageAttachment"]?
json.field "attachment" do
json.object do
case attachment.as_h
when .has_key?("videoRenderer")
attachment = attachment["videoRenderer"]
json.field "type", "video"
if !attachment["videoId"]?
error_message = (attachment["title"]["simpleText"]? ||
attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?)
json.field "error", error_message
else
video_id = attachment["videoId"].as_s
json.field "title", attachment["title"]["simpleText"].as_s
json.field "videoId", video_id
json.field "videoThumbnails" do
generate_thumbnails(json, video_id, config, kemal_config)
end
json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s)
author_info = attachment["ownerText"]["runs"][0].as_h
json.field "author", author_info["text"].as_s
json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"]
json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"]
# TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers"
# TODO: json.field "authorVerified", "ownerBadges"
published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s)
json.field "published", published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
view_count = attachment["viewCountText"]["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
json.field "viewCount", view_count
json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
end
when .has_key?("backstageImageRenderer")
attachment = attachment["backstageImageRenderer"]
json.field "type", "image"
json.field "imageThumbnails" do
json.array do
thumbnail = attachment["image"]["thumbnails"][0].as_h
width = thumbnail["width"].as_i
height = thumbnail["height"].as_i
aspect_ratio = (width.to_f / height.to_f)
qualities = {320, 560, 640, 1280, 2000}
qualities.each do |quality|
json.object do
json.field "url", thumbnail["url"].as_s.gsub("=s640-", "=s#{quality}-")
json.field "width", quality
json.field "height", (quality / aspect_ratio).ceil.to_i
end
end
end
end
# TODO
# when .has_key?("pollRenderer")
# attachment = attachment["pollRenderer"]
# json.field "type", "poll"
else
json.field "type", "unknown"
json.field "error", "Unrecognized attachment type."
end
end
end
end
if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? ||
comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s.gsub(/\D/, "").to_i?)
continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
continuation ||= ""
json.field "replies" do
json.object do
json.field "replyCount", reply_count
json.field "continuation", extract_channel_community_cursor(continuation)
end
end
end
end
end
end
end
if body["continuations"]?
continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s
json.field "continuation", extract_channel_community_cursor(continuation)
end
end
end
if format == "html"
response = JSON.parse(response)
content_html = template_youtube_comments(response, locale, thin_mode)
response = JSON.build do |json|
json.object do
json.field "contentHtml", content_html
end
end
end
return response
end
def produce_channel_community_continuation(ucid, cursor)
cursor = URI.escape(cursor)
data = IO::Memory.new
data.write_byte 0x12
VarInt.to_io(data, ucid.bytesize)
data.print ucid
data.write_byte 0x1a
VarInt.to_io(data, cursor.bytesize)
data.print cursor
data.rewind
buffer = IO::Memory.new
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
VarInt.to_io(buffer, data.size)
IO.copy data, buffer
continuation = Base64.urlsafe_encode(buffer)
continuation = URI.escape(continuation)
return continuation
end
def extract_channel_community_cursor(continuation)
continuation = URI.unescape(continuation)
data = IO::Memory.new(Base64.decode(continuation))
# 0xe2 0xa9 0x85 0xb2 0x02
data.pos += 5
continuation = Bytes.new(data.read_bytes(VarInt))
data.read continuation
data = IO::Memory.new(continuation)
data.read_byte # => 0x12
ucid = Bytes.new(data.read_bytes(VarInt))
data.read ucid
data.read_byte # => 0x1a
until data.peek[0] == 'E'.ord
data.read_byte
end
return URI.unescape(data.gets_to_end)
end
def get_about_info(ucid, locale) def get_about_info(ucid, locale)
client = make_client(YT_URL) client = make_client(YT_URL)
@ -576,14 +929,12 @@ def get_about_info(ucid, locale)
if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
error_message = translate(locale, "This channel does not exist.") error_message = translate(locale, "This channel does not exist.")
raise error_message raise error_message
end end
if about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).try &.content.empty? if about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).try &.content.empty?
error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
error_message ||= translate(locale, "Could not get channel info.") error_message ||= translate(locale, "Could not get channel info.")
raise error_message raise error_message
end end
@ -594,8 +945,63 @@ def get_about_info(ucid, locale)
sub_count ||= 0 sub_count ||= 0
author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content
author_url = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!["href"]
author_thumbnail = about.xpath_node(%q(//img[@class="channel-header-profile-image"])).not_nil!["src"]
ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"] ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
banner = about.xpath_node(%q(//div[@id="gh-banner"]/style)).not_nil!.content
banner = "https:" + banner.match(/background-image: url\((?<url>[^)]+)\)/).not_nil!["url"]
if banner.includes? "channels/c4/default_banner"
banner = nil
end
description_html = about.xpath_node(%q(//div[contains(@class,"about-description")])).try &.to_s || ""
paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True"
is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
related_channels = about.xpath_nodes(%q(//div[contains(@class, "branded-page-related-channels")]/ul/li))
related_channels = related_channels.map do |node|
related_id = node["data-external-id"]?
related_id ||= ""
anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
related_title = anchor.try &.["title"]
related_title ||= ""
related_author_url = anchor.try &.["href"]
related_author_url ||= ""
related_author_thumbnail = node.xpath_node(%q(.//img)).try &.["data-thumb"]
related_author_thumbnail ||= ""
AboutRelatedChannel.new(
ucid: related_id,
author: related_title,
author_url: related_author_url,
author_thumbnail: related_author_thumbnail,
)
end
total_views = 0_i64
sub_count = 0_i64
joined = Time.unix(0)
metadata = about.xpath_nodes(%q(//span[@class="about-stat"]))
metadata.each do |item|
case item.content
when .includes? "views"
total_views = item.content.gsub(/\D/, "").to_i64
when .includes? "subscribers"
sub_count = item.content.delete("subscribers").gsub(/\D/, "").to_i64
when .includes? "Joined"
joined = Time.parse(item.content.lchop("Joined "), "%b %-d, %Y", Time::Location.local)
end
end
# Auto-generated channels # Auto-generated channels
# https://support.google.com/youtube/answer/2579942 # https://support.google.com/youtube/answer/2579942
auto_generated = false auto_generated = false
@ -604,10 +1010,28 @@ def get_about_info(ucid, locale)
auto_generated = true auto_generated = true
end end
return {author, ucid, auto_generated, sub_count} tabs = about.xpath_nodes(%q(//ul[@id="channel-navigation-menu"]/li/a/span)).map { |node| node.content.downcase }
return AboutChannel.new(
ucid: ucid,
author: author,
auto_generated: auto_generated,
author_url: author_url,
author_thumbnail: author_thumbnail,
banner: banner,
description_html: description_html,
paid: paid,
total_views: total_views,
sub_count: sub_count,
joined: joined,
is_family_friendly: is_family_friendly,
allowed_regions: allowed_regions,
related_channels: related_channels,
tabs: tabs
)
end end
def get_60_videos(ucid, page, auto_generated, sort_by = "newest") def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest")
count = 0 count = 0
videos = [] of SearchVideo videos = [] of SearchVideo
@ -629,7 +1053,7 @@ def get_60_videos(ucid, page, auto_generated, sort_by = "newest")
if auto_generated if auto_generated
videos += extract_videos(nodeset) videos += extract_videos(nodeset)
else else
videos += extract_videos(nodeset, ucid) videos += extract_videos(nodeset, ucid, author)
end end
else else
break break

View File

@ -22,6 +22,7 @@ class RedditComment
replies: RedditThing | String, replies: RedditThing | String,
score: Int32, score: Int32,
depth: Int32, depth: Int32,
permalink: String,
created_utc: { created_utc: {
type: Time, type: Time,
converter: RedditComment::TimeConverter, converter: RedditComment::TimeConverter,
@ -56,14 +57,14 @@ class RedditListing
}) })
end end
def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode, region, sort_by = "top") def fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by = "top")
video = fetch_video(id, proxies, region: region) video = fetch_video(id, region)
session_token = video.info["session_token"]? session_token = video.info["session_token"]?
ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by)
continuation ||= ctoken continuation ||= ctoken
if !continuation || !session_token if !continuation || continuation.empty? || !session_token
if format == "json" if format == "json"
return {"comments" => [] of String}.to_json return {"comments" => [] of String}.to_json
else else
@ -72,11 +73,10 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
end end
post_req = { post_req = {
"session_token" => session_token, session_token: session_token,
} }
post_req = HTTP::Params.encode(post_req)
client = make_client(YT_URL, proxies, video.info["region"]?) client = make_client(YT_URL, video.info["region"]?)
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["content-type"] = "application/x-www-form-urlencoded" headers["content-type"] = "application/x-www-form-urlencoded"
@ -89,7 +89,7 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
headers["x-youtube-client-name"] = "1" headers["x-youtube-client-name"] = "1"
headers["x-youtube-client-version"] = "2.20180719" headers["x-youtube-client-version"] = "2.20180719"
response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, post_req) response = client.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req)
response = JSON.parse(response.body) response = JSON.parse(response.body)
if !response["response"]["continuationContents"]? if !response["response"]["continuationContents"]?
@ -112,10 +112,13 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
end end
end end
comments = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
if body["header"]? if body["header"]?
comment_count = body["header"]["commentsHeaderRenderer"]["countText"]["simpleText"].as_s.delete("Comments,").to_i count_text = body["header"]["commentsHeaderRenderer"]["countText"]
comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s.gsub(/\D/, "").to_i? || 0
json.field "commentCount", comment_count json.field "commentCount", comment_count
end end
@ -139,16 +142,9 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
node_comment = node["commentRenderer"] node_comment = node["commentRenderer"]
end end
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff') content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff').try { |block| HTML.escape(block) }.to_s ||
if content_html content_to_comment_html(node_comment["contentText"]["runs"].as_a).try &.to_s || ""
content_html = HTML.escape(content_html) author = node_comment["authorText"]?.try &.["simpleText"]? || ""
end
content_html ||= content_to_comment_html(node_comment["contentText"]["runs"].as_a)
content_html, content = html_to_content(content_html)
author = node_comment["authorText"]?.try &.["simpleText"]
author ||= ""
json.field "author", author json.field "author", author
json.field "authorThumbnails" do json.field "authorThumbnails" do
@ -180,10 +176,12 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
json.field "isEdited", false json.field "isEdited", false
end end
json.field "content", content json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html json.field "contentHtml", content_html
json.field "published", published.to_unix json.field "published", published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
json.field "likeCount", node_comment["likeCount"] json.field "likeCount", node_comment["likeCount"]
json.field "commentId", node_comment["commentId"] json.field "commentId", node_comment["commentId"]
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
@ -199,13 +197,8 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
end end
if node_replies && !response["commentRepliesContinuation"]? if node_replies && !response["commentRepliesContinuation"]?
reply_count = node_replies["moreText"]["simpleText"].as_s.delete("View all reply replies,") reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
if reply_count.empty? .try &.as_s.gsub(/\D/, "").to_i? || 1
reply_count = 1
else
reply_count = reply_count.try &.to_i?
reply_count ||= 1
end
continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
continuation ||= "" continuation ||= ""
@ -230,15 +223,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
end end
if format == "html" if format == "html"
comments = JSON.parse(comments) response = JSON.parse(response)
content_html = template_youtube_comments(comments, locale, thin_mode) content_html = template_youtube_comments(response, locale, thin_mode)
comments = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
json.field "contentHtml", content_html json.field "contentHtml", content_html
if comments["commentCount"]? if response["commentCount"]?
json.field "commentCount", comments["commentCount"] json.field "commentCount", response["commentCount"]
else else
json.field "commentCount", 0 json.field "commentCount", 0
end end
@ -246,14 +239,15 @@ def fetch_youtube_comments(id, continuation, proxies, format, locale, thin_mode,
end end
end end
return comments return response
end end
def fetch_reddit_comments(id, sort_by = "confidence") def fetch_reddit_comments(id, sort_by = "confidence")
client = make_client(REDDIT_URL) client = make_client(REDDIT_URL)
headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"} headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by /u/omarroth)"}
query = "(url:3D#{id}%20OR%20url:#{id})%20(site:youtube.com%20OR%20site:youtu.be)" # TODO: Use something like #479 for a static list of instances to use here
query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)"
search_results = client.get("/search.json?q=#{query}", headers) search_results = client.get("/search.json?q=#{query}", headers)
if search_results.status_code == 200 if search_results.status_code == 200
@ -282,56 +276,110 @@ def fetch_reddit_comments(id, sort_by = "confidence")
end end
def template_youtube_comments(comments, locale, thin_mode) def template_youtube_comments(comments, locale, thin_mode)
html = "" String.build do |html|
root = comments["comments"].as_a
root = comments["comments"].as_a root.each do |child|
root.each do |child| if child["replies"]?
if child["replies"]? replies_html = <<-END_HTML
replies_html = <<-END_HTML <div id="replies" class="pure-g">
<div id="replies" class="pure-g"> <div class="pure-u-1-24"></div>
<div class="pure-u-1-24"></div> <div class="pure-u-23-24">
<div class="pure-u-23-24"> <p>
<p> <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}" onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a>
onclick="get_youtube_replies(this)">#{translate(locale, "View `x` replies", child["replies"]["replyCount"].to_s)}</a> </p>
</p> </div>
</div> </div>
</div> END_HTML
END_HTML
end
if !thin_mode
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
else
author_thumbnail = ""
end
html += <<-END_HTML
<div class="pure-g">
<div class="pure-u-4-24 pure-u-md-2-24">
<img style="width:90%;padding-right:1em;padding-top:1em" src="#{author_thumbnail}">
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
<b>
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML
if child["creatorHeart"]?
if !thin_mode
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).full_path}"
else
creator_thumbnail = ""
end end
html += <<-END_HTML if !thin_mode
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
else
author_thumbnail = ""
end
html << <<-END_HTML
<div class="pure-g" style="width:100%">
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
<img style="padding-right:1em;padding-top:1em;width:90%" src="#{author_thumbnail}">
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
<b>
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a>
</b>
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
END_HTML
if child["attachment"]?
attachment = child["attachment"]
case attachment["type"]
when "image"
attachment = attachment["imageThumbnails"][1]
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).full_path}">
</div>
</div>
END_HTML
when "video"
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<div style="position:relative;width:100%;height:0;padding-bottom:56.25%;margin-bottom:5px">
END_HTML
if attachment["error"]?
html << <<-END_HTML
<p>#{attachment["error"]}</p>
END_HTML
else
html << <<-END_HTML
<iframe id='ivplayer' type='text/html' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' frameborder='0'></iframe>
END_HTML
end
html << <<-END_HTML
</div>
</div>
</div>
END_HTML
end
end
html << <<-END_HTML
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
END_HTML
if comments["videoId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end
html << <<-END_HTML
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML
if child["creatorHeart"]?
if !thin_mode
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).full_path}"
else
creator_thumbnail = ""
end
html << <<-END_HTML
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}"> <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<div class="creator-heart"> <div class="creator-heart">
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
@ -340,84 +388,77 @@ def template_youtube_comments(comments, locale, thin_mode)
</div> </div>
</div> </div>
</span> </span>
END_HTML
end
html << <<-END_HTML
</p>
#{replies_html}
</div>
</div>
END_HTML END_HTML
end end
html += <<-END_HTML if comments["continuation"]?
</p> html << <<-END_HTML
#{replies_html} <div class="pure-g">
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
onclick="get_youtube_replies(this, true)">#{translate(locale, "Load more")}</a>
</p>
</div>
</div> </div>
</div> END_HTML
END_HTML end
end end
if comments["continuation"]?
html += <<-END_HTML
<div class="pure-g">
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
onclick="get_youtube_replies(this, true)">#{translate(locale, "Load more")}</a>
</p>
</div>
</div>
END_HTML
end
return html
end end
def template_reddit_comments(root, locale) def template_reddit_comments(root, locale)
html = "" String.build do |html|
root.each do |child| root.each do |child|
if child.data.is_a?(RedditComment) if child.data.is_a?(RedditComment)
child = child.data.as(RedditComment) child = child.data.as(RedditComment)
author = child.author body_html = HTML.unescape(child.body_html)
score = child.score
body_html = HTML.unescape(child.body_html)
replies_html = "" replies_html = ""
if child.replies.is_a?(RedditThing) if child.replies.is_a?(RedditThing)
replies = child.replies.as(RedditThing) replies = child.replies.as(RedditThing)
replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale) replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale)
end end
content = <<-END_HTML if child.depth > 0
<p> html << <<-END_HTML
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
#{translate(locale, "`x` points", number_with_separator(score))}
#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}
</p>
<div>
#{body_html}
#{replies_html}
</div>
END_HTML
if child.depth > 0
html += <<-END_HTML
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1-24"> <div class="pure-u-1-24">
</div> </div>
<div class="pure-u-23-24"> <div class="pure-u-23-24">
#{content} END_HTML
</div> else
</div> html << <<-END_HTML
END_HTML
else
html += <<-END_HTML
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
#{content} END_HTML
</div> end
</div>
html << <<-END_HTML
<p>
<a href="javascript:void(0)" onclick="toggle_parent(this)">[ - ]</a>
<b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b>
#{translate(locale, "`x` points", number_with_separator(child.score))}
<span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span>
<a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a>
</p>
<div>
#{body_html}
#{replies_html}
</div>
</div>
</div>
END_HTML END_HTML
end end
end end
end end
return html
end end
def replace_links(html) def replace_links(html)
@ -517,114 +558,111 @@ def content_to_comment_html(content)
end end
text text
end.join.rchop('\ufeff') end.join("").delete('\ufeff')
return comment_html return comment_html
end end
def produce_comment_continuation(video_id, cursor = "", sort_by = "top") def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, 0x26]) data.write Bytes[0x12, 0x26]
continuation.write(Bytes[0x12, video_id.size]) data.write_byte 0x12
continuation.print(video_id) VarInt.to_io(data, video_id.bytesize)
data.print video_id
continuation.write(Bytes[0xc0, 0x01, 0x01]) data.write Bytes[0xc0, 0x01, 0x01]
continuation.write(Bytes[0xc8, 0x01, 0x01]) data.write Bytes[0xc8, 0x01, 0x01]
continuation.write(Bytes[0xe0, 0x01, 0x01]) data.write Bytes[0xe0, 0x01, 0x01]
continuation.write(Bytes[0xa2, 0x02, 0x0d]) data.write Bytes[0xa2, 0x02, 0x0d]
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]) data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
continuation.write(Bytes[0x40, 0x00]) data.write Bytes[0x40, 0x00]
continuation.write(Bytes[0x18, 0x06]) data.write Bytes[0x18, 0x06]
if cursor.empty? if cursor.empty?
continuation.write(Bytes[0x32]) data.write Bytes[0x32]
continuation.write(write_var_int(video_id.size + 8)) VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 8)
continuation.write(Bytes[0x22, video_id.size + 4]) data.write Bytes[0x22, video_id.bytesize + 4]
continuation.write(Bytes[0x22, video_id.size]) data.write Bytes[0x22, video_id.bytesize]
continuation.print(video_id) data.print video_id
case sort_by case sort_by
when "top" when "top"
continuation.write(Bytes[0x30, 0x00]) data.write Bytes[0x30, 0x00]
when "new", "newest" when "new", "newest"
continuation.write(Bytes[0x30, 0x01]) data.write Bytes[0x30, 0x01]
end end
continuation.write(Bytes[0x78, 0x02]) data.write(Bytes[0x78, 0x02])
else else
continuation.write(Bytes[0x32]) data.write Bytes[0x32]
continuation.write(write_var_int(cursor.size + video_id.size + 11)) VarInt.to_io(data, cursor.bytesize + video_id.bytesize + 11)
continuation.write(Bytes[0x0a]) data.write_byte 0x0a
continuation.write(write_var_int(cursor.size)) VarInt.to_io(data, cursor.bytesize)
continuation.print(cursor) data.print cursor
continuation.write(Bytes[0x22, video_id.size + 4]) data.write Bytes[0x22, video_id.bytesize + 4]
continuation.write(Bytes[0x22, video_id.size]) data.write Bytes[0x22, video_id.bytesize]
continuation.print(video_id) data.print video_id
case sort_by case sort_by
when "top" when "top"
continuation.write(Bytes[0x30, 0x00]) data.write Bytes[0x30, 0x00]
when "new", "newest" when "new", "newest"
continuation.write(Bytes[0x30, 0x01]) data.write Bytes[0x30, 0x01]
end end
continuation.write(Bytes[0x28, 0x14]) data.write Bytes[0x28, 0x14]
end end
continuation.rewind continuation = Base64.urlsafe_encode(data)
continuation = continuation.gets_to_end
continuation = Base64.urlsafe_encode(continuation.to_slice)
continuation = URI.escape(continuation) continuation = URI.escape(continuation)
return continuation return continuation
end end
def produce_comment_reply_continuation(video_id, ucid, comment_id) def produce_comment_reply_continuation(video_id, ucid, comment_id)
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, 0x26]) data.write Bytes[0x12, 0x26]
continuation.write(Bytes[0x12, video_id.size]) data.write_byte 0x12
continuation.print(video_id) VarInt.to_io(data, video_id.size)
data.print video_id
continuation.write(Bytes[0xc0, 0x01, 0x01]) data.write Bytes[0xc0, 0x01, 0x01]
continuation.write(Bytes[0xc8, 0x01, 0x01]) data.write Bytes[0xc8, 0x01, 0x01]
continuation.write(Bytes[0xe0, 0x01, 0x01]) data.write Bytes[0xe0, 0x01, 0x01]
continuation.write(Bytes[0xa2, 0x02, 0x0d]) data.write Bytes[0xa2, 0x02, 0x0d]
continuation.write(Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]) data.write Bytes[0x28, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]
continuation.write(Bytes[0x40, 0x00]) data.write Bytes[0x40, 0x00]
continuation.write(Bytes[0x18, 0x06]) data.write Bytes[0x18, 0x06]
continuation.write(Bytes[0x32, ucid.size + video_id.size + comment_id.size + 16]) data.write(Bytes[0x32, ucid.size + video_id.size + comment_id.size + 16])
continuation.write(Bytes[0x1a, ucid.size + video_id.size + comment_id.size + 14]) data.write(Bytes[0x1a, ucid.size + video_id.size + comment_id.size + 14])
continuation.write(Bytes[0x12, comment_id.size]) data.write_byte 0x12
continuation.print(comment_id) VarInt.to_io(data, comment_id.size)
data.print comment_id
continuation.write(Bytes[0x22, 0x02, 0x08, 0x00]) # ?? data.write(Bytes[0x22, 0x02, 0x08, 0x00]) # ??
continuation.write(Bytes[ucid.size + video_id.size + 7]) data.write(Bytes[ucid.size + video_id.size + 7])
continuation.write(Bytes[ucid.size]) data.write(Bytes[ucid.size])
continuation.print(ucid) data.print(ucid)
continuation.write(Bytes[0x32, video_id.size]) data.write(Bytes[0x32, video_id.size])
continuation.print(video_id) data.print(video_id)
continuation.write(Bytes[0x40, 0x01]) data.write(Bytes[0x40, 0x01])
continuation.write(Bytes[0x48, 0x0a]) data.write(Bytes[0x48, 0x0a])
continuation.rewind continuation = Base64.urlsafe_encode(data.to_slice)
continuation = continuation.gets_to_end
continuation = Base64.urlsafe_encode(continuation.to_slice)
continuation = URI.escape(continuation) continuation = URI.escape(continuation)
return continuation return continuation

View File

@ -176,3 +176,41 @@ class HTTP::Client
response response
end end
end end
# https://github.com/will/crystal-pg/pull/171
class PG::Statement < ::DB::Statement
protected def perform_query(args : Enumerable) : ResultSet
params = args.map { |arg| PQ::Param.encode(arg) }
conn = self.conn
conn.send_parse_message(@sql)
conn.send_bind_message params
conn.send_describe_portal_message
conn.send_execute_message
conn.send_sync_message
conn.expect_frame PQ::Frame::ParseComplete
conn.expect_frame PQ::Frame::BindComplete
frame = conn.read
case frame
when PQ::Frame::RowDescription
fields = frame.fields
when PQ::Frame::NoData
fields = nil
else
raise "expected RowDescription or NoData, got #{frame}"
end
ResultSet.new(self, fields)
rescue IO::Error
raise DB::ConnectionLost.new(connection)
end
protected def perform_exec(args : Enumerable) : ::DB::ExecResult
result = perform_query(args)
result.each { }
::DB::ExecResult.new(
rows_affected: result.rows_affected,
last_insert_id: 0_i64 # postgres doesn't support this
)
rescue IO::Error
raise DB::ConnectionLost.new(connection)
end
end

View File

@ -87,12 +87,53 @@ end
struct Config struct Config
module ConfigPreferencesConverter module ConfigPreferencesConverter
def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder)
value.to_yaml(yaml)
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Preferences def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Preferences
Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple) Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple)
end end
end
def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder) module FamilyConverter
value.to_yaml(yaml) def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
case value
when Socket::Family::UNSPEC
yaml.scalar nil
when Socket::Family::INET
yaml.scalar "ipv4"
when Socket::Family::INET6
yaml.scalar "ipv6"
end
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
if node.is_a?(YAML::Nodes::Scalar)
case node.value.downcase
when "ipv4"
Socket::Family::INET
when "ipv6"
Socket::Family::INET6
else
Socket::Family::UNSPEC
end
else
node.raise "Expected scalar, not #{node.class}"
end
end
end
def disabled?(option)
case disabled = CONFIG.disable_proxy
when Bool
return disabled
when Array
if disabled.includes? option
return true
else
return false
end
end end
end end
@ -105,7 +146,6 @@ struct Config
hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions hmac_key: String?, # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required domain: String?, # Domain to be used for links to resources on the site where an absolute URL is required
use_pubsub_feeds: {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) use_pubsub_feeds: {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
use_feed_events: {type: Bool | Int32, default: false}, # Update feeds on receiving notifications
default_home: {type: String, default: "Top"}, default_home: {type: String, default: "Top"},
feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]}, feed_menu: {type: Array(String), default: ["Popular", "Top", "Trending", "Subscriptions"]},
top_enabled: {type: Bool, default: true}, top_enabled: {type: Bool, default: true},
@ -119,11 +159,13 @@ struct Config
default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple), default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple),
converter: ConfigPreferencesConverter, converter: ConfigPreferencesConverter,
}, },
dmca_content: {type: Array(String), default: [] of String}, # For compliance with DMCA, disables download widget using list of video IDs dmca_content: {type: Array(String), default: [] of String}, # For compliance with DMCA, disables download widget using list of video IDs
check_tables: {type: Bool, default: false}, # Check table integrity, automatically try to add any missing columns, create tables, etc. check_tables: {type: Bool, default: false}, # Check table integrity, automatically try to add any missing columns, create tables, etc.
cache_annotations: {type: Bool, default: false}, # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards cache_annotations: {type: Bool, default: false}, # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
banner: {type: String?, default: nil}, # Optional banner to be displayed along top of page for announcements, etc. banner: {type: String?, default: nil}, # Optional banner to be displayed along top of page for announcements, etc.
hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
disable_proxy: {type: Bool? | Array(String)?, default: false}, # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
force_resolve: {type: Socket::Family, default: Socket::Family::UNSPEC, converter: FamilyConverter}, # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
}) })
end end
@ -147,7 +189,7 @@ def rank_videos(db, n)
published = rs.read(Time) published = rs.read(Time)
# Exponential decay, older videos tend to rank lower # Exponential decay, older videos tend to rank lower
temperature = wilson_score * Math.exp(-0.000005*((Time.now - published).total_minutes)) temperature = wilson_score * Math.exp(-0.000005*((Time.utc - published).total_minutes))
top << {temperature, id} top << {temperature, id}
end end
end end
@ -161,40 +203,42 @@ def rank_videos(db, n)
return top[0..n - 1] return top[0..n - 1]
end end
def login_req(login_form, f_req) def login_req(f_req)
data = { data = {
# Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard
# Generally this is much longer (>1250 characters), see also
# https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb .
# For now this can be empty.
"bgRequest" => %|["identifier",""]|,
"pstMsg" => "1", "pstMsg" => "1",
"checkConnection" => "youtube", "checkConnection" => "youtube",
"checkedDomains" => "youtube", "checkedDomains" => "youtube",
"hl" => "en", "hl" => "en",
"deviceinfo" => %q([null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]), "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|,
"f.req" => f_req, "f.req" => f_req,
"flowName" => "GlifWebSignIn", "flowName" => "GlifWebSignIn",
"flowEntry" => "ServiceLogin", "flowEntry" => "ServiceLogin",
# "cookiesDisabled" => "false",
# "gmscoreversion" => "undefined",
# "continue" => "https://accounts.google.com/ManageAccount",
# "azt" => "",
# "bgHash" => "",
} }
data = login_form.merge(data)
return HTTP::Params.encode(data) return HTTP::Params.encode(data)
end end
def html_to_content(description_html) def html_to_content(description_html : String)
if !description_html description = description_html.gsub(/(<br>)|(<br\/>)/, {
description = "" "<br>": "\n",
description_html = "" "<br/>": "\n",
else })
description_html = description_html.to_s
description = description_html.gsub("<br>", "\n")
description = description.gsub("<br/>", "\n")
if description.empty? if !description.empty?
description = "" description = XML.parse_html(description).content.strip("\n ")
else
description = XML.parse_html(description).content.strip("\n ")
end
end end
return description_html, description return description
end end
def extract_videos(nodeset, ucid = nil, author_name = nil) def extract_videos(nodeset, ucid = nil, author_name = nil)
@ -231,8 +275,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
author ||= "" author ||= ""
author_id ||= "" author_id ||= ""
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])) description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || ""
description_html, description = html_to_content(description_html)
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")])) tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
if !tile if !tile
@ -331,7 +374,6 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
author_thumbnail: author_thumbnail, author_thumbnail: author_thumbnail,
subscriber_count: subscriber_count, subscriber_count: subscriber_count,
video_count: video_count, video_count: video_count,
description: description,
description_html: description_html description_html: description_html
) )
else else
@ -347,7 +389,7 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64) published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
rescue ex rescue ex
end end
published ||= Time.now published ||= Time.utc
begin begin
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64? view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
@ -397,7 +439,6 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
ucid: author_id, ucid: author_id,
published: published, published: published,
views: view_count, views: view_count,
description: description,
description_html: description_html, description_html: description_html,
length_seconds: length_seconds, length_seconds: length_seconds,
live_now: live_now, live_now: live_now,
@ -523,7 +564,7 @@ def analyze_table(db, logger, table_name, struct_type = nil)
begin begin
db.exec("SELECT * FROM #{table_name} LIMIT 0") db.exec("SELECT * FROM #{table_name} LIMIT 0")
rescue ex rescue ex
logger.write("CREATE TABLE #{table_name}\n") logger.puts("CREATE TABLE #{table_name}")
db.using_connection do |conn| db.using_connection do |conn|
conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
@ -547,7 +588,7 @@ def analyze_table(db, logger, table_name, struct_type = nil)
if name != column_array[i]? if name != column_array[i]?
if !column_array[i]? if !column_array[i]?
new_column = column_types.select { |line| line.starts_with? name }[0] new_column = column_types.select { |line| line.starts_with? name }[0]
logger.write("ALTER TABLE #{table_name} ADD COLUMN #{new_column}\n") logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
next next
end end
@ -565,26 +606,29 @@ def analyze_table(db, logger, table_name, struct_type = nil)
# There's a column we didn't expect # There's a column we didn't expect
if !new_column if !new_column
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}\n") logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
column_array = get_column_array(db, table_name) column_array = get_column_array(db, table_name)
next next
end end
logger.write("ALTER TABLE #{table_name} ADD COLUMN #{new_column}\n") logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
logger.write("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}\n")
logger.puts("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE\n")
logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
logger.write("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}\n")
logger.puts("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
column_array = get_column_array(db, table_name) column_array = get_column_array(db, table_name)
end end
else else
logger.write("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE\n") logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
end end
end end
@ -635,52 +679,25 @@ def cache_annotation(db, id, annotations)
end end
end end
def proxy_file(response, env) def create_notification_stream(env, config, kemal_config, decrypt_function, topics, connection_channel)
if !response.body_io? connection = Channel(PQ::Notification).new(8)
return connection_channel.send({true, connection})
end
if response.headers.includes_word?("Content-Encoding", "gzip")
Gzip::Writer.open(env.response) do |deflate|
copy_in_chunks(response.body_io, deflate)
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Flate::Writer.open(env.response) do |deflate|
copy_in_chunks(response.body_io, deflate)
end
else
copy_in_chunks(response.body_io, env.response)
end
end
# https://stackoverflow.com/a/44802810 <3
def copy_in_chunks(input, output, chunk_size = 4096)
size = 1
while size > 0
size = IO.copy(input, output, chunk_size)
Fiber.yield
end
end
def create_notification_stream(env, proxies, config, kemal_config, decrypt_function, topics)
locale = LOCALES[env.get("preferences").as(Preferences).locale]? locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "text/event-stream"
since = env.params.query["since"]?.try &.to_i? since = env.params.query["since"]?.try &.to_i?
id = 0
begin if topics.includes? "debug"
id = 0 spawn do
begin
if topics.includes? "debug"
spawn do
loop do loop do
time_span = [0, 0, 0, 0] time_span = [0, 0, 0, 0]
time_span[rand(4)] = rand(30) + 5 time_span[rand(4)] = rand(30) + 5
published = Time.now - Time::Span.new(time_span[0], time_span[1], time_span[2], time_span[3]) published = Time.utc - Time::Span.new(time_span[0], time_span[1], time_span[2], time_span[3])
video_id = TEST_IDS[rand(TEST_IDS.size)] video_id = TEST_IDS[rand(TEST_IDS.size)]
video = get_video(video_id, PG_DB, proxies) video = get_video(video_id, PG_DB)
video.published = published video.published = published
response = JSON.parse(video.to_json(locale, config, kemal_config, decrypt_function)) response = JSON.parse(video.to_json(locale, config, kemal_config, decrypt_function))
@ -701,11 +718,15 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
id += 1 id += 1
sleep 1.minute sleep 1.minute
Fiber.yield
end end
rescue ex
end end
end end
end
spawn do spawn do
begin
if since if since
topics.try &.each do |topic| topics.try &.each do |topic|
case topic case topic
@ -735,14 +756,24 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
end end
end end
end end
end
end
spawn do
begin
loop do
event = connection.receive
PG.connect_listen(PG_URL, "notifications") do |event|
notification = JSON.parse(event.payload) notification = JSON.parse(event.payload)
topic = notification["topic"].as_s topic = notification["topic"].as_s
video_id = notification["videoId"].as_s video_id = notification["videoId"].as_s
published = notification["published"].as_i64 published = notification["published"].as_i64
video = get_video(video_id, PG_DB, proxies) if !topics.try &.includes? topic
next
end
video = get_video(video_id, PG_DB)
video.published = Time.unix(published) video.published = Time.unix(published)
response = JSON.parse(video.to_json(locale, config, Kemal.config, decrypt_function)) response = JSON.parse(video.to_json(locale, config, Kemal.config, decrypt_function))
@ -755,24 +786,114 @@ def create_notification_stream(env, proxies, config, kemal_config, decrypt_funct
end end
end end
if topics.try &.includes? topic env.response.puts "id: #{id}"
env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}"
env.response.puts "data: #{response.to_json}" env.response.puts
env.response.puts env.response.flush
env.response.flush
id += 1 id += 1
end
end end
rescue ex
ensure
connection_channel.send({false, connection})
end end
end
begin
# Send heartbeat # Send heartbeat
loop do loop do
env.response.puts ":keepalive #{Time.now.to_unix}" env.response.puts ":keepalive #{Time.utc.to_unix}"
env.response.puts env.response.puts
env.response.flush env.response.flush
sleep (20 + rand(11)).seconds sleep (20 + rand(11)).seconds
end end
rescue rescue ex
ensure
connection_channel.send({false, connection})
end
end
def extract_initial_data(body)
initial_data = body.match(/window\["ytInitialData"\] = (?<info>.*?);\n/).try &.["info"] || "{}"
if initial_data.starts_with?("JSON.parse(\"")
return JSON.parse(JSON.parse(%({"initial_data":"#{initial_data[12..-3]}"}))["initial_data"].as_s)
else
return JSON.parse(initial_data)
end
end
def proxy_file(response, env)
if response.headers.includes_word?("Content-Encoding", "gzip")
Gzip::Writer.open(env.response) do |deflate|
response.pipe(deflate)
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Flate::Writer.open(env.response) do |deflate|
response.pipe(deflate)
end
else
response.pipe(env.response)
end
end
class HTTP::Client::Response
def pipe(io)
HTTP.serialize_body(io, headers, @body, @body_io, @version)
end
end
# Supports serialize_body without first writing headers
module HTTP
def self.serialize_body(io, headers, body, body_io, version)
if body
io << body
elsif body_io
content_length = content_length(headers)
if content_length
copied = IO.copy(body_io, io)
if copied != content_length
raise ArgumentError.new("Content-Length header is #{content_length} but body had #{copied} bytes")
end
elsif Client::Response.supports_chunked?(version)
headers["Transfer-Encoding"] = "chunked"
serialize_chunked_body(io, body_io)
else
io << body
end
end
end
end
class HTTP::Client
property family : Socket::Family = Socket::Family::UNSPEC
private def socket
socket = @socket
return socket if socket
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
socket = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family
socket.read_timeout = @read_timeout if @read_timeout
socket.sync = false
{% if !flag?(:without_openssl) %}
if tls = @tls
socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: @host)
end
{% end %}
@socket = socket
end
end
class TCPSocket
def initialize(host, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC)
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
super(addrinfo.family, addrinfo.type, addrinfo.protocol)
connect(addrinfo, timeout: connect_timeout) do |error|
close
error
end
end
end end
end end

View File

@ -22,12 +22,12 @@ def refresh_channels(db, logger, config)
begin begin
channel = fetch_channel(id, db, config.full_refresh) channel = fetch_channel(id, db, config.full_refresh)
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.now, channel.author, id) db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
rescue ex rescue ex
if ex.message == "Deleted or invalid channel" if ex.message == "Deleted or invalid channel"
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.now, id) db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
end end
logger.write("#{id} : #{ex.message}\n") logger.puts("#{id} : #{ex.message}")
end end
active_channel.send(true) active_channel.send(true)
@ -36,6 +36,7 @@ def refresh_channels(db, logger, config)
end end
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
@ -43,66 +44,6 @@ def refresh_channels(db, logger, config)
end end
def refresh_feeds(db, logger, config) def refresh_feeds(db, logger, config)
# Spawn thread to handle feed events
if config.use_feed_events
case config.use_feed_events
when Bool
max_feed_event_threads = config.use_feed_events.as(Bool).to_unsafe
when Int32
max_feed_event_threads = config.use_feed_events.as(Int32)
end
max_feed_event_channel = Channel(Int32).new
spawn do
queue = Deque(String).new(30)
PG.connect_listen(PG_URL, "feeds") do |event|
if !queue.includes? event.payload
queue << event.payload
end
end
max_threads = max_feed_event_channel.receive
active_threads = 0
active_channel = Channel(Bool).new
loop do
until queue.empty?
event = queue.shift
if active_threads >= max_threads
if active_channel.receive
active_threads -= 1
end
end
active_threads += 1
spawn do
begin
feed = JSON.parse(event)
email = feed["email"].as_s
action = feed["action"].as_s
view_name = "subscriptions_#{sha256(email)}"
case action
when "refresh"
db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
end
rescue ex
end
active_channel.send(true)
end
end
sleep 5.seconds
end
end
max_feed_event_channel.send(max_feed_event_threads.as(Int32))
end
max_channel = Channel(Int32).new max_channel = Channel(Int32).new
spawn do spawn do
max_threads = max_channel.receive max_threads = max_channel.receive
@ -110,7 +51,7 @@ def refresh_feeds(db, logger, config)
active_channel = Channel(Bool).new active_channel = Channel(Bool).new
loop do loop do
db.query("SELECT email FROM users") do |rs| db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs|
rs.each do rs.each do
email = rs.read(String) email = rs.read(String)
view_name = "subscriptions_#{sha256(email)}" view_name = "subscriptions_#{sha256(email)}"
@ -128,33 +69,37 @@ def refresh_feeds(db, logger, config)
column_array = get_column_array(db, view_name) column_array = get_column_array(db, view_name)
ChannelVideo.to_type_tuple.each_with_index do |name, i| ChannelVideo.to_type_tuple.each_with_index do |name, i|
if name != column_array[i]? if name != column_array[i]?
logger.write("DROP MATERIALIZED VIEW #{view_name}\n") logger.puts("DROP MATERIALIZED VIEW #{view_name}")
db.exec("DROP MATERIALIZED VIEW #{view_name}") db.exec("DROP MATERIALIZED VIEW #{view_name}")
raise "view does not exist" raise "view does not exist"
end end
end end
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
logger.puts("Materialized view #{view_name} is out-of-date, recreating...")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
end
db.exec("REFRESH MATERIALIZED VIEW #{view_name}") db.exec("REFRESH MATERIALIZED VIEW #{view_name}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
rescue ex rescue ex
# Rename old views # Rename old views
begin begin
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}" legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0") db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
logger.write("RENAME MATERIALIZED VIEW #{legacy_view_name}\n") logger.puts("RENAME MATERIALIZED VIEW #{legacy_view_name}")
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}") db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
rescue ex rescue ex
begin begin
# While iterating through, we may have an email stored from a deleted account # While iterating through, we may have an email stored from a deleted account
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool) if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
logger.write("CREATE #{view_name}\n") logger.puts("CREATE #{view_name}")
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
SELECT * FROM channel_videos WHERE \ db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
end end
rescue ex rescue ex
logger.write("REFRESH #{email} : #{ex.message}\n") logger.puts("REFRESH #{email} : #{ex.message}")
end end
end end
end end
@ -164,7 +109,8 @@ def refresh_feeds(db, logger, config)
end end
end end
sleep 1.minute sleep 5.seconds
Fiber.yield
end end
end end
@ -204,7 +150,7 @@ def subscribe_to_feeds(db, logger, key, config)
response = subscribe_pubsub(ucid, key, config) response = subscribe_pubsub(ucid, key, config)
if response.status_code >= 400 if response.status_code >= 400
logger.write("#{ucid} : #{response.body}\n") logger.puts("#{ucid} : #{response.body}")
end end
rescue ex rescue ex
end end
@ -215,6 +161,7 @@ def subscribe_to_feeds(db, logger, key, config)
end end
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
@ -227,12 +174,16 @@ def pull_top_videos(config, db)
begin begin
top = rank_videos(db, 40) top = rank_videos(db, 40)
rescue ex rescue ex
sleep 1.minute
Fiber.yield
next next
end end
if top.size > 0 if top.size == 0
args = arg_array(top) sleep 1.minute
else Fiber.yield
next next
end end
@ -247,22 +198,23 @@ def pull_top_videos(config, db)
end end
yield videos yield videos
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
def pull_popular_videos(db) def pull_popular_videos(db)
loop do loop do
subscriptions = db.query_all("SELECT channel FROM \ videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE ucid IN \
(SELECT UNNEST(subscriptions) AS channel FROM users) AS d \ (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d \
GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40", as: String) GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) \
ORDER BY ucid, published DESC", as: ChannelVideo).sort_by { |video| video.published }.reverse
videos = db.query_all("SELECT DISTINCT ON (ucid) * FROM \
channel_videos WHERE ucid IN (#{arg_array(subscriptions)}) \
ORDER BY ucid, published DESC", subscriptions, as: ChannelVideo).sort_by { |video| video.published }.reverse
yield videos yield videos
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
@ -270,12 +222,13 @@ def update_decrypt_function
loop do loop do
begin begin
decrypt_function = fetch_decrypt_function decrypt_function = fetch_decrypt_function
yield decrypt_function
rescue ex rescue ex
next next
end end
yield decrypt_function
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end
@ -290,5 +243,6 @@ def find_working_proxies(regions)
end end
sleep 1.minute sleep 1.minute
Fiber.yield
end end
end end

View File

@ -1,13 +1,20 @@
require "logger" require "logger"
enum LogLevel
Debug
Info
Warn
Error
end
class Invidious::LogHandler < Kemal::BaseLogHandler class Invidious::LogHandler < Kemal::BaseLogHandler
def initialize(@io : IO = STDOUT) def initialize(@io : IO = STDOUT, @level = LogLevel::Warn)
end end
def call(context : HTTP::Server::Context) def call(context : HTTP::Server::Context)
time = Time.now time = Time.utc
call_next(context) call_next(context)
elapsed_text = elapsed_text(Time.now - time) elapsed_text = elapsed_text(Time.utc - time)
@io << time << ' ' << context.response.status_code << ' ' << context.request.method << ' ' << context.request.resource << ' ' << elapsed_text << '\n' @io << time << ' ' << context.response.status_code << ' ' << context.request.method << ' ' << context.request.resource << ' ' << elapsed_text << '\n'
@ -18,7 +25,15 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
context context
end end
def write(message : String) def puts(message : String)
@io << message << '\n'
if @io.is_a? File
@io.flush
end
end
def write(message : String, level = @level)
@io << message @io << message
if @io.is_a? File if @io.is_a? File
@ -26,6 +41,29 @@ class Invidious::LogHandler < Kemal::BaseLogHandler
end end
end end
def set_log_level(level : String)
case level.downcase
when "debug"
set_log_level(LogLevel::Debug)
when "info"
set_log_level(LogLevel::Info)
when "warn"
set_log_level(LogLevel::Warn)
when "error"
set_log_level(LogLevel::Error)
end
end
def set_log_level(level : LogLevel)
@level = level
end
{% for level in %w(debug info warn error) %}
def {{level.id}}(message : String)
puts(message, LogLevel::{{level.id.capitalize}})
end
{% end %}
private def elapsed_text(elapsed) private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1 return "#{millis.round(2)}ms" if millis >= 1

View 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

View File

@ -1,6 +1,6 @@
def generate_token(email, scopes, expire, key, db) def generate_token(email, scopes, expire, key, db)
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.now) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc)
token = { token = {
"session" => session, "session" => session,
@ -18,7 +18,7 @@ def generate_token(email, scopes, expire, key, db)
end end
def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
expire = Time.now + expire expire = Time.utc + expire
token = { token = {
"session" => session, "session" => session,
@ -85,8 +85,8 @@ def validate_request(token, session, request, key, db, locale = nil)
end end
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
if nonce[1] > Time.now if nonce[1] > Time.utc
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0]) db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
else else
raise translate(locale, "Erroneous token") raise translate(locale, "Erroneous token")
end end
@ -100,7 +100,7 @@ def validate_request(token, session, request, key, db, locale = nil)
end end
expire = token["expire"]?.try &.as_i expire = token["expire"]?.try &.as_i
if expire.try &.< Time.now.to_unix if expire.try &.< Time.utc.to_unix
raise translate(locale, "Token is expired, please try again") raise translate(locale, "Token is expired, please try again")
end end

View File

@ -18,24 +18,14 @@ def elapsed_text(elapsed)
"#{(millis * 1000).round(2)}µs" "#{(millis * 1000).round(2)}µs"
end end
def make_client(url : URI, proxies = {} of String => Array({ip: String, port: Int32}), region = nil) def make_client(url : URI, region = nil)
context = nil client = HTTPClient.new(url)
client.family = CONFIG.force_resolve
if url.scheme == "https" client.read_timeout = 15.seconds
context = OpenSSL::SSL::Context::Client.new client.connect_timeout = 15.seconds
context.add_options(
OpenSSL::SSL::Options::ALL |
OpenSSL::SSL::Options::NO_SSL_V2 |
OpenSSL::SSL::Options::NO_SSL_V3
)
end
client = HTTPClient.new(url, context)
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
if region if region
proxies[region]?.try &.sample(40).each do |proxy| PROXY_LIST[region]?.try &.sample(40).each do |proxy|
begin begin
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
client.set_proxy(proxy) client.set_proxy(proxy)
@ -90,7 +80,7 @@ def decode_time(string)
millis = /(?<millis>\d+)ms/.match(string).try &.["millis"].try &.to_f millis = /(?<millis>\d+)ms/.match(string).try &.["millis"].try &.to_f
millis ||= 0 millis ||= 0
time = hours * 3600 + minutes * 60 + seconds + millis / 1000 time = hours * 3600 + minutes * 60 + seconds + millis // 1000
end end
return time return time
@ -99,7 +89,7 @@ end
def decode_date(string : String) def decode_date(string : String)
# String matches 'YYYY' # String matches 'YYYY'
if string.match(/^\d{4}/) if string.match(/^\d{4}/)
return Time.new(string.to_i, 1, 1) return Time.utc(string.to_i, 1, 1)
end end
# Try to parse as format Jul 10, 2000 # Try to parse as format Jul 10, 2000
@ -110,9 +100,9 @@ def decode_date(string : String)
case string case string
when "today" when "today"
return Time.now return Time.utc
when "yesterday" when "yesterday"
return Time.now - 1.day return Time.utc - 1.day
end end
# String matches format "20 hours ago", "4 months ago"... # String matches format "20 hours ago", "4 months ago"...
@ -138,18 +128,18 @@ def decode_date(string : String)
raise "Could not parse #{string}" raise "Could not parse #{string}"
end end
return Time.now - delta return Time.utc - delta
end end
def recode_date(time : Time, locale) def recode_date(time : Time, locale)
span = Time.now - time span = Time.utc - time
if span.total_days > 365.0 if span.total_days > 365.0
span = translate(locale, "`x` years", (span.total_days.to_i / 365).to_s) span = translate(locale, "`x` years", (span.total_days.to_i // 365).to_s)
elsif span.total_days > 30.0 elsif span.total_days > 30.0
span = translate(locale, "`x` months", (span.total_days.to_i / 30).to_s) span = translate(locale, "`x` months", (span.total_days.to_i // 30).to_s)
elsif span.total_days > 7.0 elsif span.total_days > 7.0
span = translate(locale, "`x` weeks", (span.total_days.to_i / 7).to_s) span = translate(locale, "`x` weeks", (span.total_days.to_i // 7).to_s)
elsif span.total_hours > 24.0 elsif span.total_hours > 24.0
span = translate(locale, "`x` days", (span.total_days.to_i).to_s) span = translate(locale, "`x` days", (span.total_days.to_i).to_s)
elsif span.total_minutes > 60.0 elsif span.total_minutes > 60.0
@ -194,11 +184,11 @@ def number_to_short_text(number)
text = text.rchop(".0") text = text.rchop(".0")
if number / 1_000_000_000 != 0 if number // 1_000_000_000 != 0
text += "B" text += "B"
elsif number / 1_000_000 != 0 elsif number // 1_000_000 != 0
text += "M" text += "M"
elsif number / 1000 != 0 elsif number // 1000 != 0
text += "K" text += "K"
end end
@ -243,7 +233,7 @@ def make_host_url(config, kemal_config)
return "#{scheme}#{host}#{port}" return "#{scheme}#{host}#{port}"
end end
def get_referer(env, fallback = "/") def get_referer(env, fallback = "/", unroll = true)
referer = env.params.query["referer"]? referer = env.params.query["referer"]?
referer ||= env.request.headers["referer"]? referer ||= env.request.headers["referer"]?
referer ||= fallback referer ||= fallback
@ -251,16 +241,18 @@ def get_referer(env, fallback = "/")
referer = URI.parse(referer) referer = URI.parse(referer)
# "Unroll" nested referrers # "Unroll" nested referrers
loop do if unroll
if referer.query loop do
params = HTTP::Params.parse(referer.query.not_nil!) if referer.query
if params["referer"]? params = HTTP::Params.parse(referer.query.not_nil!)
referer = URI.parse(URI.unescape(params["referer"])) if params["referer"]?
referer = URI.parse(URI.unescape(params["referer"]))
else
break
end
else else
break break
end end
else
break
end end
end end
@ -274,50 +266,40 @@ def get_referer(env, fallback = "/")
return referer return referer
end end
def read_var_int(bytes) struct VarInt
num_read = 0 def self.from_io(io : IO, format = IO::ByteFormat::BigEndian) : Int32
result = 0 result = 0_i32
num_read = 0
read = bytes[num_read] loop do
byte = io.read_byte
if bytes.size == 1 raise "Invalid VarInt" if !byte
result = bytes[0].to_i32 value = byte & 0x7f
else
while ((read & 0b10000000) != 0)
read = bytes[num_read].to_u64
value = (read & 0b01111111)
result |= (value << (7 * num_read))
result |= value.to_i32 << (7 * num_read)
num_read += 1 num_read += 1
if num_read > 5
raise "VarInt is too big" break if byte & 0x80 == 0
end raise "Invalid VarInt" if num_read > 5
end end
result
end end
return result def self.to_io(io : IO, value : Int32)
end io.write_byte 0x00 if value == 0x00
def write_var_int(value : Int)
bytes = [] of UInt8
value = value.to_u32
if value == 0
bytes = [0_u8]
else
while value != 0 while value != 0
temp = (value & 0b01111111).to_u8 byte = (value & 0x7f).to_u8
value = value >> 7 value >>= 7
if value != 0 if value != 0
temp |= 0b10000000 byte |= 0x80
end end
bytes << temp io.write_byte byte
end end
end end
return Slice.new(bytes.to_unsafe, bytes.size)
end end
def sha256(text) def sha256(text)
@ -325,3 +307,52 @@ def sha256(text)
digest << text digest << text
return digest.hexdigest return digest.hexdigest
end end
def subscribe_pubsub(topic, key, config)
case topic
when .match(/^UC[A-Za-z0-9_-]{22}$/)
topic = "channel_id=#{topic}"
when .match(/^(PL|LL|EC|UU|FL|UL|OLAK5uy_)[0-9A-Za-z-_]{10,}$/)
# There's a couple missing from the above regex, namely TL and RD, which
# don't have feeds
topic = "playlist_id=#{topic}"
else
# TODO
end
client = make_client(PUBSUB_URL)
time = Time.utc.to_unix.to_s
nonce = Random::Secure.hex(4)
signature = "#{time}:#{nonce}"
host_url = make_host_url(config, Kemal.config)
body = {
"hub.callback" => "#{host_url}/feed/webhook/v1:#{time}:#{nonce}:#{OpenSSL::HMAC.hexdigest(:sha1, key, signature)}",
"hub.topic" => "https://www.youtube.com/xml/feeds/videos.xml?#{topic}",
"hub.verify" => "async",
"hub.mode" => "subscribe",
"hub.lease_seconds" => "432000",
"hub.secret" => key.to_s,
}
return client.post("/subscribe", form: body)
end
def parse_range(range)
if !range
return 0_i64, nil
end
ranges = range.lchop("bytes=").split(',')
ranges.each do |range|
start_range, end_range = range.split('-')
start_range = start_range.to_i64? || 0_i64
end_range = end_range.to_i64?
return start_range, end_range
end
return 0_i64, nil
end

View File

@ -6,7 +6,7 @@ struct MixVideo
ucid: String, ucid: String,
length_seconds: Int32, length_seconds: Int32,
index: Int32, index: Int32,
mixes: Array(String), rdid: String,
}) })
end end
@ -28,18 +28,13 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
end end
response = client.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en&has_verified=1&bpctr=9999999999", headers) response = client.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en&has_verified=1&bpctr=9999999999", headers)
yt_data = response.body.match(/window\["ytInitialData"\] = (?<data>.*);/) initial_data = extract_initial_data(response.body)
if yt_data
yt_data = JSON.parse(yt_data["data"].rchop(";")) if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
else
raise translate(locale, "Could not create mix.") raise translate(locale, "Could not create mix.")
end end
if !yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]? playlist = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
raise translate(locale, "Could not create mix.")
end
playlist = yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
mix_title = playlist["title"].as_s mix_title = playlist["title"].as_s
contents = playlist["contents"].as_a contents = playlist["contents"].as_a
@ -70,7 +65,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
ucid, ucid,
length_seconds, length_seconds,
index, index,
[rdid] rdid
) )
end end

View File

@ -1,4 +1,32 @@
struct PlaylistVideo struct PlaylistVideo
def to_json(locale, config, kemal_config, json : JSON::Builder)
json.object do
json.field "title", self.title
json.field "videoId", self.id
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "index", self.index
json.field "lengthSeconds", self.length_seconds
end
end
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, json)
end
end
end
db_mapping({ db_mapping({
title: String, title: String,
id: String, id: String,
@ -6,7 +34,7 @@ struct PlaylistVideo
ucid: String, ucid: String,
length_seconds: Int32, length_seconds: Int32,
published: Time, published: Time,
playlists: Array(String), plid: String,
index: Int32, index: Int32,
live_now: Bool, live_now: Bool,
}) })
@ -19,7 +47,6 @@ struct Playlist
author: String, author: String,
author_thumbnail: String, author_thumbnail: String,
ucid: String, ucid: String,
description: String,
description_html: String, description_html: String,
video_count: Int32, video_count: Int32,
views: Int64, views: Int64,
@ -114,8 +141,8 @@ def extract_playlist(plid, nodeset, index)
author: author, author: author,
ucid: ucid, ucid: ucid,
length_seconds: length_seconds, length_seconds: length_seconds,
published: Time.now, published: Time.utc,
playlists: [plid], plid: plid,
index: index + offset, index: index + offset,
live_now: live_now live_now: live_now
) )
@ -130,37 +157,44 @@ def produce_playlist_url(id, index)
end end
ucid = "VL" + id ucid = "VL" + id
meta = IO::Memory.new data = IO::Memory.new
meta.write(Bytes[0x08]) data.write_byte 0x08
meta.write(write_var_int(index)) VarInt.to_io(data, index)
meta.rewind data.rewind
meta = Base64.urlsafe_encode(meta.to_slice, false) data = Base64.urlsafe_encode(data, false)
meta = "PT:#{meta}" data = "PT:#{data}"
continuation = IO::Memory.new continuation = IO::Memory.new
continuation.write(Bytes[0x7a, meta.size]) continuation.write_byte 0x7a
continuation.print(meta) VarInt.to_io(continuation, data.bytesize)
continuation.print data
continuation.rewind data = Base64.urlsafe_encode(continuation)
meta = Base64.urlsafe_encode(continuation.to_slice) cursor = URI.escape(data)
meta = URI.escape(meta)
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a, meta.size])
continuation.print(meta)
wrapper = IO::Memory.new data.write_byte 0x12
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size]) VarInt.to_io(data, ucid.bytesize)
wrapper.print(continuation) data.print ucid
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice) data.write_byte 0x1a
wrapper = URI.escape(wrapper) VarInt.to_io(data, cursor.bytesize)
data.print cursor
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en" data.rewind
buffer = IO::Memory.new
buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
VarInt.to_io(buffer, data.bytesize)
IO.copy data, buffer
continuation = Base64.urlsafe_encode(buffer)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
return url return url
end end
@ -186,9 +220,8 @@ def fetch_playlist(plid, locale)
end end
title = title.content.strip(" \n") title = title.content.strip(" \n")
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])) description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])).try &.to_s ||
description_html ||= document.xpath_node(%q(//span[@class="pl-header-description-text"])) document.xpath_node(%q(//span[@class="pl-header-description-text"])).try &.to_s || ""
description_html, description = html_to_content(description_html)
# YouTube allows anonymous playlists, so most of this can be empty or optional # YouTube allows anonymous playlists, so most of this can be empty or optional
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])) anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
@ -208,7 +241,7 @@ def fetch_playlist(plid, locale)
if updated if updated
updated = decode_date(updated) updated = decode_date(updated)
else else
updated = Time.now updated = Time.utc
end end
playlist = Playlist.new( playlist = Playlist.new(
@ -217,7 +250,6 @@ def fetch_playlist(plid, locale)
author: author, author: author,
author_thumbnail: author_thumbnail, author_thumbnail: author_thumbnail,
ucid: ucid, ucid: ucid,
description: description,
description_html: description_html, description_html: description_html,
video_count: video_count, video_count: video_count,
views: views, views: views,

View File

@ -1,4 +1,92 @@
struct SearchVideo struct SearchVideo
def to_xml(host_url, auto_generated, xml : XML::Builder)
xml.element("entry") do
xml.element("id") { xml.text "yt:video:#{self.id}" }
xml.element("yt:videoId") { xml.text self.id }
xml.element("yt:channelId") { xml.text self.ucid }
xml.element("title") { xml.text self.title }
xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}")
xml.element("author") do
if auto_generated
xml.element("name") { xml.text self.author }
xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" }
else
xml.element("name") { xml.text author }
xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" }
end
end
xml.element("content", type: "xhtml") do
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do
xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg")
end
end
end
xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
xml.element("media:group") do
xml.element("media:title") { xml.text self.title }
xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg",
width: "320", height: "180")
xml.element("media:description") { xml.text html_to_content(self.description_html) }
end
xml.element("media:community") do
xml.element("media:statistics", views: self.views)
end
end
end
def to_xml(host_url, auto_generated, xml : XML::Builder | Nil = nil)
if xml
to_xml(host_url, auto_generated, xml)
else
XML.build do |json|
to_xml(host_url, auto_generated, xml)
end
end
end
def to_json(locale, config, kemal_config, json : JSON::Builder)
json.object do
json.field "type", "video"
json.field "title", self.title
json.field "videoId", self.id
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
json.field "viewCount", self.views
json.field "published", self.published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "lengthSeconds", self.length_seconds
json.field "liveNow", self.live_now
json.field "paid", self.paid
json.field "premium", self.premium
end
end
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, json)
end
end
end
db_mapping({ db_mapping({
title: String, title: String,
id: String, id: String,
@ -6,7 +94,6 @@ struct SearchVideo
ucid: String, ucid: String,
published: Time, published: Time,
views: Int64, views: Int64,
description: String,
description_html: String, description_html: String,
length_seconds: Int32, length_seconds: Int32,
live_now: Bool, live_now: Bool,
@ -25,6 +112,45 @@ struct SearchPlaylistVideo
end end
struct SearchPlaylist struct SearchPlaylist
def to_json(locale, config, kemal_config, json : JSON::Builder)
json.object do
json.field "type", "playlist"
json.field "title", self.title
json.field "playlistId", self.id
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "videoCount", self.video_count
json.field "videos" do
json.array do
self.videos.each do |video|
json.object do
json.field "title", video.title
json.field "videoId", video.id
json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do
generate_thumbnails(json, video.id, config, Kemal.config)
end
end
end
end
end
end
end
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, json)
end
end
end
db_mapping({ db_mapping({
title: String, title: String,
id: String, id: String,
@ -37,13 +163,50 @@ struct SearchPlaylist
end end
struct SearchChannel struct SearchChannel
def to_json(locale, config, kemal_config, json : JSON::Builder)
json.object do
json.field "type", "channel"
json.field "author", self.author
json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorThumbnails" do
json.array do
qualities = {32, 48, 76, 100, 176, 512}
qualities.each do |quality|
json.object do
json.field "url", self.author_thumbnail.gsub("=s176-", "=s#{quality}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
json.field "subCount", self.subscriber_count
json.field "videoCount", self.video_count
json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", self.description_html
end
end
def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, json)
end
end
end
db_mapping({ db_mapping({
author: String, author: String,
ucid: String, ucid: String,
author_thumbnail: String, author_thumbnail: String,
subscriber_count: Int32, subscriber_count: Int32,
video_count: Int32, video_count: Int32,
description: String,
description_html: String, description_html: String,
}) })
end end
@ -93,8 +256,8 @@ def channel_search(query, page, channel)
return count, items return count, items
end end
def search(query, page = 1, search_params = produce_search_params(content_type: "all"), proxies = nil, region = nil) def search(query, page = 1, search_params = produce_search_params(content_type: "all"), region = nil)
client = make_client(YT_URL, proxies, region) client = make_client(YT_URL, region)
if query.empty? if query.empty?
return {0, [] of SearchItem} return {0, [] of SearchItem}
end end
@ -211,45 +374,51 @@ end
def produce_channel_search_url(ucid, query, page) def produce_channel_search_url(ucid, query, page)
page = "#{page}" page = "#{page}"
meta = IO::Memory.new data = IO::Memory.new
meta.write(Bytes[0x12, 0x06]) data.write_byte 0x12
meta.print("search") data.write_byte 0x06
data.print "search"
meta.write(Bytes[0x30, 0x02]) data.write Bytes[0x30, 0x02]
meta.write(Bytes[0x38, 0x01]) data.write Bytes[0x38, 0x01]
meta.write(Bytes[0x60, 0x01]) data.write Bytes[0x60, 0x01]
meta.write(Bytes[0x6a, 0x00]) data.write Bytes[0x6a, 0x00]
meta.write(Bytes[0xb8, 0x01, 0x00]) data.write Bytes[0xb8, 0x01, 0x00]
meta.write(Bytes[0x7a, page.size]) data.write_byte 0x7a
meta.print(page) VarInt.to_io(data, page.bytesize)
data.print page
meta.rewind data.rewind
meta = Base64.urlsafe_encode(meta.to_slice) data = Base64.urlsafe_encode(data)
meta = URI.escape(meta) continuation = URI.escape(data)
continuation = IO::Memory.new data = IO::Memory.new
continuation.write(Bytes[0x12, ucid.size])
continuation.print(ucid)
continuation.write(Bytes[0x1a, meta.size]) data.write_byte 0x12
continuation.print(meta) VarInt.to_io(data, ucid.bytesize)
data.print ucid
continuation.write(Bytes[0x5a, query.size]) data.write_byte 0x1a
continuation.print(query) VarInt.to_io(data, continuation.bytesize)
data.print continuation
continuation.rewind data.write_byte 0x5a
continuation = continuation.gets_to_end VarInt.to_io(data, query.bytesize)
data.print query
wrapper = IO::Memory.new data.rewind
wrapper.write(Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02, continuation.size])
wrapper.print(continuation)
wrapper.rewind
wrapper = Base64.urlsafe_encode(wrapper.to_slice) buffer = IO::Memory.new
wrapper = URI.escape(wrapper) buffer.write Bytes[0xe2, 0xa9, 0x85, 0xb2, 0x02]
VarInt.to_io(buffer, data.bytesize)
url = "/browse_ajax?continuation=#{wrapper}&gl=US&hl=en" IO.copy data, buffer
continuation = Base64.urlsafe_encode(buffer)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en"
return url return url
end end

View File

@ -1,4 +1,4 @@
def fetch_trending(trending_type, proxies, region, locale) def fetch_trending(trending_type, region, locale)
client = make_client(YT_URL) client = make_client(YT_URL)
headers = HTTP::Headers.new headers = HTTP::Headers.new
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
@ -14,14 +14,9 @@ def fetch_trending(trending_type, proxies, region, locale)
response = client.get("/feed/trending?gl=#{region}&hl=en", headers).body response = client.get("/feed/trending?gl=#{region}&hl=en", headers).body
yt_data = response.match(/window\["ytInitialData"\] = (?<data>.*);/) initial_data = extract_initial_data(response)
if yt_data
yt_data = JSON.parse(yt_data["data"].rchop(";"))
else
raise translate(locale, "Could not pull trending pages.")
end
tabs = yt_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"]["content"]["sectionListRenderer"]["subMenu"]["channelListSubMenuRenderer"]["contents"].as_a tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"]["content"]["sectionListRenderer"]["subMenu"]["channelListSubMenuRenderer"]["contents"].as_a
url = tabs.select { |tab| tab["channelListSubMenuAvatarRenderer"]["title"]["simpleText"] == trending_type }[0]? url = tabs.select { |tab| tab["channelListSubMenuAvatarRenderer"]["title"]["simpleText"] == trending_type }[0]?
if url if url

View File

@ -1,5 +1,8 @@
require "crypto/bcrypt/password" require "crypto/bcrypt/password"
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
struct User struct User
module PreferencesConverter module PreferencesConverter
def self.from_rs(rs) def self.from_rs(rs)
@ -20,9 +23,10 @@ struct User
type: Preferences, type: Preferences,
converter: PreferencesConverter, converter: PreferencesConverter,
}, },
password: String?, password: String?,
token: String, token: String,
watched: Array(String), watched: Array(String),
feed_needs_update: Bool?,
}) })
end end
@ -40,10 +44,10 @@ struct Preferences
begin begin
result = [] of String result = [] of String
value.read_array do value.read_array do
result << HTML.escape(value.read_string) result << HTML.escape(value.read_string[0, 100])
end end
rescue ex rescue ex
result = [HTML.escape(value.read_string), ""] result = [HTML.escape(value.read_string[0, 100]), ""]
end end
result result
@ -69,11 +73,11 @@ struct Preferences
node.raise "Expected scalar, not #{item.class}" node.raise "Expected scalar, not #{item.class}"
end end
result << HTML.escape(item.value) result << HTML.escape(item.value[0, 100])
end end
rescue ex rescue ex
if node.is_a?(YAML::Nodes::Scalar) if node.is_a?(YAML::Nodes::Scalar)
result = [HTML.escape(node.value), ""] result = [HTML.escape(node.value[0, 100]), ""]
else else
result = ["", ""] result = ["", ""]
end end
@ -83,13 +87,13 @@ struct Preferences
end end
end end
module EscapeString module ProcessString
def self.to_json(value : String, json : JSON::Builder) def self.to_json(value : String, json : JSON::Builder)
json.string value json.string value
end end
def self.from_json(value : JSON::PullParser) : String def self.from_json(value : JSON::PullParser) : String
HTML.escape(value.read_string) HTML.escape(value.read_string[0, 100])
end end
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
@ -97,7 +101,25 @@ struct Preferences
end end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
HTML.escape(node.value) HTML.escape(node.value[0, 100])
end
end
module ClampInt
def self.to_json(value : Int32, json : JSON::Builder)
json.number value
end
def self.from_json(value : JSON::PullParser) : Int32
value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
end
def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
yaml.scalar value
end
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
node.value.clamp(0, MAX_ITEMS_PER_PAGE)
end end
end end
@ -113,13 +135,13 @@ struct Preferences
latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only}, latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
local: {type: Bool, default: CONFIG.default_user_preferences.local}, local: {type: Bool, default: CONFIG.default_user_preferences.local},
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: EscapeString}, locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString},
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results}, max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt},
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only},
quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: EscapeString}, quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString},
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed},
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},
sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: EscapeString}, sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString},
speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, speed: {type: Float32, default: CONFIG.default_user_preferences.speed},
thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode}, thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode},
unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only}, unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only},
@ -132,7 +154,7 @@ def get_user(sid, headers, db, refresh = true)
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
if refresh && Time.now - user.updated > 1.minute if refresh && Time.utc - user.updated > 1.minute
user, sid = fetch_user(sid, headers, db) user, sid = fetch_user(sid, headers, db)
user_array = user.to_a user_array = user.to_a
@ -143,14 +165,11 @@ def get_user(sid, headers, db, refresh = true)
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now) ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
begin begin
view_name = "subscriptions_#{sha256(user.email)}" view_name = "subscriptions_#{sha256(user.email)}"
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
rescue ex rescue ex
end end
end end
@ -165,14 +184,11 @@ def get_user(sid, headers, db, refresh = true)
ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array)
db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \
ON CONFLICT (id) DO NOTHING", sid, user.email, Time.now) ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc)
begin begin
view_name = "subscriptions_#{sha256(user.email)}" view_name = "subscriptions_#{sha256(user.email)}"
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS \ db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
SELECT * FROM channel_videos WHERE \
ucid = ANY ((SELECT subscriptions FROM users WHERE email = E'#{user.email.gsub("'", "\\'")}')::text[]) \
ORDER BY published DESC;")
rescue ex rescue ex
end end
end end
@ -205,7 +221,7 @@ def fetch_user(sid, headers, db)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new(Time.now, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String) user = User.new(Time.utc, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String, true)
return user, sid return user, sid
end end
@ -213,7 +229,7 @@ def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10) password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
user = User.new(Time.now, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String) user = User.new(Time.utc, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String, true)
return user, sid return user, sid
end end
@ -313,10 +329,108 @@ def subscribe_ajax(channel_id, action, env_headers)
headers["content-type"] = "application/x-www-form-urlencoded" headers["content-type"] = "application/x-www-form-urlencoded"
post_req = { post_req = {
"session_token" => session_token, session_token: session_token,
} }
post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
client.post(post_url, headers, form: post_req) client.post(post_url, headers, form: post_req)
end end
end end
def get_subscription_feed(db, user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit
notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
as: Array(String))
view_name = "subscriptions_#{sha256(user.email)}"
if user.preferences.notifications_only && !notifications.empty?
# Only show notifications
args = arg_array(notifications)
notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args})
ORDER BY published DESC", notifications, as: ChannelVideo)
videos = [] of ChannelVideo
notifications.sort_by! { |video| video.published }.reverse!
case user.preferences.sort
when "alphabetically"
notifications.sort_by! { |video| video.title }
when "alphabetically - reverse"
notifications.sort_by! { |video| video.title }.reverse!
when "channel name"
notifications.sort_by! { |video| video.author }
when "channel name - reverse"
notifications.sort_by! { |video| video.author }.reverse!
end
else
if user.preferences.latest_only
if user.preferences.unseen_only
# Show latest video from a channel that a user hasn't watched
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE \
NOT id = ANY (#{values}) \
ORDER BY ucid, published DESC", as: ChannelVideo)
else
# Show latest video from each channel
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} \
ORDER BY ucid, published DESC", as: ChannelVideo)
end
videos.sort_by! { |video| video.published }.reverse!
else
if user.preferences.unseen_only
# Only show unwatched
if user.watched.empty?
values = "'{}'"
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE \
NOT id = ANY (#{values}) \
ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
else
# Sort subscriptions as normal
videos = PG_DB.query_all("SELECT * FROM #{view_name} \
ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
end
end
case user.preferences.sort
when "published - reverse"
videos.sort_by! { |video| video.published }
when "alphabetically"
videos.sort_by! { |video| video.title }
when "alphabetically - reverse"
videos.sort_by! { |video| video.title }.reverse!
when "channel name"
videos.sort_by! { |video| video.author }
when "channel name - reverse"
videos.sort_by! { |video| video.author }.reverse!
end
notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
as: Array(String))
notifications = videos.select { |v| notifications.includes? v.id }
videos = videos - notifications
end
if !limit
videos = videos[0..max_results]
end
return videos, notifications
end

View File

@ -182,7 +182,7 @@ VIDEO_FORMATS = {
"135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"},
"137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"},
"138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https=>//github.com/rg3/youtube-dl/issues/4559) "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
"160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"},
"212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"},
@ -239,6 +239,12 @@ VIDEO_FORMATS = {
"249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50},
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
"251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160},
# av01 video only formats sometimes served with "unknown" codecs
"394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"},
"395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"},
"396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"},
"397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"},
} }
struct VideoPreferences struct VideoPreferences
@ -273,190 +279,209 @@ struct Video
end end
end end
def to_json(locale, config, kemal_config, decrypt_function) def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder)
JSON.build do |json| json.object do
json.object do json.field "type", "video"
json.field "title", self.title
json.field "videoId", self.id
json.field "videoThumbnails" do
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "storyboards" do
generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
end
description_html, description = html_to_content(self.description) json.field "title", self.title
json.field "videoId", self.id
json.field "videoThumbnails" do
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "storyboards" do
generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
end
json.field "description", description json.field "description", html_to_content(self.description_html)
json.field "descriptionHtml", description_html json.field "descriptionHtml", self.description_html
json.field "published", self.published.to_unix json.field "published", self.published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
json.field "keywords", self.keywords json.field "keywords", self.keywords
json.field "viewCount", self.views json.field "viewCount", self.views
json.field "likeCount", self.likes json.field "likeCount", self.likes
json.field "dislikeCount", self.dislikes json.field "dislikeCount", self.dislikes
json.field "paid", self.paid json.field "paid", self.paid
json.field "premium", self.premium json.field "premium", self.premium
json.field "isFamilyFriendly", self.is_family_friendly json.field "isFamilyFriendly", self.is_family_friendly
json.field "allowedRegions", self.allowed_regions json.field "allowedRegions", self.allowed_regions
json.field "genre", self.genre json.field "genre", self.genre
json.field "genreUrl", self.genre_url json.field "genreUrl", self.genre_url
json.field "author", self.author json.field "author", self.author
json.field "authorId", self.ucid json.field "authorId", self.ucid
json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorUrl", "/channel/#{self.ucid}"
json.field "authorThumbnails" do json.field "authorThumbnails" do
json.array do json.array do
qualities = {32, 48, 76, 100, 176, 512} qualities = {32, 48, 76, 100, 176, 512}
qualities.each do |quality| qualities.each do |quality|
json.object do json.object do
json.field "url", self.author_thumbnail.gsub("=s48-", "=s#{quality}-") json.field "url", self.author_thumbnail.gsub("=s48-", "=s#{quality}-")
json.field "width", quality json.field "width", quality
json.field "height", quality json.field "height", quality
end
end end
end end
end end
end
json.field "subCountText", self.sub_count_text json.field "subCountText", self.sub_count_text
json.field "lengthSeconds", self.info["length_seconds"].to_i json.field "lengthSeconds", self.length_seconds
json.field "allowRatings", self.allow_ratings json.field "allowRatings", self.allow_ratings
json.field "rating", self.info["avg_rating"].to_f32 json.field "rating", self.info["avg_rating"].to_f32
json.field "isListed", self.is_listed json.field "isListed", self.is_listed
json.field "liveNow", self.live_now json.field "liveNow", self.live_now
json.field "isUpcoming", self.is_upcoming json.field "isUpcoming", self.is_upcoming
if self.premiere_timestamp if self.premiere_timestamp
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
end end
if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]? if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]?
host_url = make_host_url(config, kemal_config) host_url = make_host_url(config, kemal_config)
hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url) hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
json.field "hlsUrl", hlsvp json.field "hlsUrl", hlsvp
end end
json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}" json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}"
json.field "adaptiveFormats" do json.field "adaptiveFormats" do
json.array do json.array do
self.adaptive_fmts(decrypt_function).each do |fmt| self.adaptive_fmts(decrypt_function).each do |fmt|
json.object do json.object do
json.field "index", fmt["index"] json.field "index", fmt["index"]
json.field "bitrate", fmt["bitrate"] json.field "bitrate", fmt["bitrate"]
json.field "init", fmt["init"] json.field "init", fmt["init"]
json.field "url", fmt["url"] json.field "url", fmt["url"]
json.field "itag", fmt["itag"] json.field "itag", fmt["itag"]
json.field "type", fmt["type"] json.field "type", fmt["type"]
json.field "clen", fmt["clen"] json.field "clen", fmt["clen"]
json.field "lmt", fmt["lmt"] json.field "lmt", fmt["lmt"]
json.field "projectionType", fmt["projection_type"] json.field "projectionType", fmt["projection_type"]
fmt_info = itag_to_metadata?(fmt["itag"]) fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30 fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
json.field "fps", fps json.field "fps", fps
json.field "container", fmt_info["ext"] json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]? if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p" json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p" quality_label = "#{fmt_info["height"]}p"
if fps > 30 if fps > 30
quality_label += "60" quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end end
end json.field "qualityLabel", quality_label
end
end
end
end
json.field "formatStreams" do if fmt_info["width"]?
json.array do json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
self.fmt_stream(decrypt_function).each do |fmt|
json.object do
json.field "url", fmt["url"]
json.field "itag", fmt["itag"]
json.field "type", fmt["type"]
json.field "quality", fmt["quality"]
fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
json.field "fps", fps
json.field "container", fmt_info["ext"]
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if fmt_info["height"]?
json.field "resolution", "#{fmt_info["height"]}p"
quality_label = "#{fmt_info["height"]}p"
if fps > 30
quality_label += "60"
end
json.field "qualityLabel", quality_label
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end
end end
end end
end end
end end
end end
end end
end
json.field "captions" do
json.array do json.field "formatStreams" do
self.captions.each do |caption| json.array do
json.object do self.fmt_stream(decrypt_function).each do |fmt|
json.field "label", caption.name.simpleText json.object do
json.field "languageCode", caption.languageCode json.field "url", fmt["url"]
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}" json.field "itag", fmt["itag"]
end json.field "type", fmt["type"]
end json.field "quality", fmt["quality"]
end
end fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info
json.field "recommendedVideos" do fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 30
json.array do json.field "fps", fps
self.info["rvs"]?.try &.split(",").each do |rv| json.field "container", fmt_info["ext"]
rv = HTTP::Params.parse(rv) json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
if rv["id"]? if fmt_info["height"]?
json.object do json.field "resolution", "#{fmt_info["height"]}p"
json.field "videoId", rv["id"]
json.field "title", rv["title"] quality_label = "#{fmt_info["height"]}p"
json.field "videoThumbnails" do if fps > 30
generate_thumbnails(json, rv["id"], config, kemal_config) quality_label += "60"
end end
json.field "author", rv["author"] json.field "qualityLabel", quality_label
json.field "lengthSeconds", rv["length_seconds"].to_i
json.field "viewCountText", rv["short_view_count_text"] if fmt_info["width"]?
end json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
end end
end end
end end
end end
end
end
end
json.field "captions" do
json.array do
self.captions.each do |caption|
json.object do
json.field "label", caption.name.simpleText
json.field "languageCode", caption.languageCode
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}"
end
end
end
end
json.field "recommendedVideos" do
json.array do
self.info["rvs"]?.try &.split(",").each do |rv|
rv = HTTP::Params.parse(rv)
if rv["id"]?
json.object do
json.field "videoId", rv["id"]
json.field "title", rv["title"]
json.field "videoThumbnails" do
generate_thumbnails(json, rv["id"], config, kemal_config)
end
json.field "author", rv["author"]
json.field "lengthSeconds", rv["length_seconds"].to_i
json.field "viewCountText", rv["short_view_count_text"]
end
end
end
end
end end
end end
end end
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, decrypt_function, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, decrypt_function, json)
end
end
end
# `description_html` is stored in DB as `description`, which can be
# quite confusing. Since it currently isn't very practical to rename
# it, we instead define a getter and setter here.
def description_html
self.description
end
def description_html=(other : String)
self.description = other
end
def allow_ratings def allow_ratings
allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool
@ -538,7 +563,14 @@ struct Video
fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0" fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0"
fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0" fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
fmt["itag"] = fmt_stream["itag"].as_i.to_s fmt["itag"] = fmt_stream["itag"].as_i.to_s
fmt["url"] = fmt_stream["url"].as_s if fmt_stream["url"]?
fmt["url"] = fmt_stream["url"].as_s
end
if fmt_stream["cipher"]?
HTTP::Params.parse(fmt_stream["cipher"].as_s).each do |key, value|
fmt[key] = value
end
end
fmt["quality"] = fmt_stream["quality"].as_s fmt["quality"] = fmt_stream["quality"].as_s
if fmt_stream["width"]? if fmt_stream["width"]?
@ -610,8 +642,14 @@ struct Video
fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0" fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0" fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
fmt["itag"] = adaptive_fmt["itag"].as_i.to_s fmt["itag"] = adaptive_fmt["itag"].as_i.to_s
fmt["url"] = adaptive_fmt["url"].as_s if adaptive_fmt["url"]?
fmt["url"] = adaptive_fmt["url"].as_s
end
if adaptive_fmt["cipher"]?
HTTP::Params.parse(adaptive_fmt["cipher"].as_s).each do |key, value|
fmt[key] = value
end
end
if index = adaptive_fmt["indexRange"]? if index = adaptive_fmt["indexRange"]?
fmt["index"] = "#{index["start"]}-#{index["end"]}" fmt["index"] = "#{index["start"]}-#{index["end"]}"
end end
@ -786,18 +824,23 @@ struct Video
end end
def short_description def short_description
description = self.description.gsub("<br>", " ") short_description = self.description_html.gsub(/(<br>)|(<br\/>|"|\n)/, {
description = description.gsub("<br/>", " ") "<br>": " ",
description = XML.parse_html(description).content[0..200].gsub('"', "&quot;").gsub("\n", " ").strip(" ") "<br/>": " ",
if description.empty? "\"": "&quot;",
description = " " "\n": " ",
})
short_description = XML.parse_html(short_description).content[0..200].strip(" ")
if short_description.empty?
short_description = " "
end end
return description return short_description
end end
def length_seconds def length_seconds
return self.info["length_seconds"].to_i self.player_response["videoDetails"]["lengthSeconds"].as_s.to_i
end end
db_mapping({ db_mapping({
@ -845,14 +888,16 @@ end
class VideoRedirect < Exception class VideoRedirect < Exception
end end
def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32}), refresh = true, region = nil, force_refresh = false) def get_video(id, db, refresh = true, region = nil, force_refresh = false)
if db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", id, as: Bool) && !region if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region
video = db.query_one("SELECT * FROM videos WHERE id = $1", id, as: Video) # If record was last updated over 10 minutes ago, or video has since premiered,
# refresh (expire param in response lasts for 6 hours)
# If record was last updated over 10 minutes ago, refresh (expire param in response lasts for 6 hours) if (refresh &&
if (refresh && Time.now - video.updated > 10.minutes) || force_refresh (Time.utc - video.updated > 10.minutes) ||
(video.premiere_timestamp && video.premiere_timestamp.as(Time) < Time.utc)) ||
force_refresh
begin begin
video = fetch_video(id, proxies, region: region) video = fetch_video(id, region)
video_array = video.to_a video_array = video.to_a
args = arg_array(video_array[1..-1], 2) args = arg_array(video_array[1..-1], 2)
@ -867,7 +912,7 @@ def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32})
end end
end end
else else
video = fetch_video(id, proxies, region: region) video = fetch_video(id, region)
video_array = video.to_a video_array = video.to_a
args = arg_array(video_array) args = arg_array(video_array)
@ -893,7 +938,7 @@ def extract_polymer_config(body, html)
end end
end end
initial_data = JSON.parse(body.match(/window\["ytInitialData"\] = (?<info>.*?);\n/).try &.["info"] || "{}") initial_data = extract_initial_data(body)
primary_results = initial_data["contents"]? primary_results = initial_data["contents"]?
.try &.["twoColumnWatchNextResults"]? .try &.["twoColumnWatchNextResults"]?
@ -971,7 +1016,7 @@ def extract_polymer_config(body, html)
if published if published
params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s
else else
params["published"] = Time.new(1990, 1, 1).to_unix.to_s params["published"] = Time.utc(1990, 1, 1).to_unix.to_s
end end
params["description_html"] = "<p></p>" params["description_html"] = "<p></p>"
@ -1071,8 +1116,8 @@ def extract_player_config(body, html)
return params return params
end end
def fetch_video(id, proxies, region) def fetch_video(id, region)
client = make_client(YT_URL, proxies, region) client = make_client(YT_URL, region)
response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/) if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/)
@ -1087,9 +1132,9 @@ def fetch_video(id, proxies, region)
if info["reason"]? && info["reason"].includes? "your country" if info["reason"]? && info["reason"].includes? "your country"
bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new
proxies.each do |proxy_region, list| PROXY_LIST.each do |proxy_region, list|
spawn do spawn do
client = make_client(YT_URL, proxies, proxy_region) client = make_client(YT_URL, proxy_region)
proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
proxy_html = XML.parse_html(proxy_response.body) proxy_html = XML.parse_html(proxy_response.body)
@ -1105,7 +1150,7 @@ def fetch_video(id, proxies, region)
end end
end end
proxies.size.times do PROXY_LIST.size.times do
response = bypass_channel.receive response = bypass_channel.receive
if response if response
html, info = response html, info = response
@ -1130,41 +1175,38 @@ def fetch_video(id, proxies, region)
end end
end end
if info["errorcode"]?.try &.== "2" if info["errorcode"]?.try &.== "2" || !info["player_response"]
raise "Video unavailable." raise "Video unavailable."
end end
if !info["title"]? if info["reason"]?
raise "Video unavailable." raise info["reason"]
end end
title = info["title"] player_json = JSON.parse(info["player_response"])
author = info["author"]
ucid = info["ucid"] title = player_json["videoDetails"]["title"].as_s
author = player_json["videoDetails"]["author"]?.try &.as_s || ""
ucid = player_json["videoDetails"]["ucid"]?.try &.as_s || ""
views = html.xpath_node(%q(//meta[@itemprop="interactionCount"])) views = html.xpath_node(%q(//meta[@itemprop="interactionCount"]))
views = views.try &.["content"].to_i64? .try &.["content"].to_i64? || 0_i64
views ||= 0_i64
likes = html.xpath_node(%q(//button[@title="I like this"]/span)) likes = html.xpath_node(%q(//button[@title="I like this"]/span))
likes = likes.try &.content.delete(",").try &.to_i? .try &.content.delete(",").try &.to_i? || 0
likes ||= 0
dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span)) dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span))
dislikes = dislikes.try &.content.delete(",").try &.to_i? .try &.content.delete(",").try &.to_i? || 0
dislikes ||= 0
avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1) avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1)
avg_rating = avg_rating.nan? ? 0.0 : avg_rating avg_rating = avg_rating.nan? ? 0.0 : avg_rating
info["avg_rating"] = "#{avg_rating}" info["avg_rating"] = "#{avg_rating}"
description = html.xpath_node(%q(//p[@id="eow-description"])) description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || ""
description = description ? description.to_xml(options: XML::SaveOptions::NO_DECL) : ""
wilson_score = ci_lower_bound(likes, likes + dislikes) wilson_score = ci_lower_bound(likes, likes + dislikes)
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"] published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"]
published ||= Time.now.to_s("%Y-%m-%d") published ||= Time.utc.to_s("%Y-%m-%d")
published = Time.parse(published, "%Y-%m-%d", Time::Location.local) published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",") allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
@ -1176,7 +1218,8 @@ def fetch_video(id, proxies, region)
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"] genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"]
genre ||= "" genre ||= ""
genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"] genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"]?
genre_url ||= ""
# YouTube provides invalid URLs for some genres, so we fix that here # YouTube provides invalid URLs for some genres, so we fix that here
case genre case genre
@ -1193,30 +1236,12 @@ def fetch_video(id, proxies, region)
when "Trailers" when "Trailers"
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g" genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
end end
genre_url ||= ""
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)) license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || ""
if license sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")])).try &.["title"]? || "0"
license = license.content author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || ""
else
license = ""
end
sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")])) video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html,
if sub_count_text
sub_count_text = sub_count_text["title"]
else
sub_count_text = "0"
end
author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img))
if author_thumbnail
author_thumbnail = author_thumbnail["data-thumb"]
else
author_thumbnail = ""
end
video = Video.new(id, info, Time.now, title, views, likes, dislikes, wilson_score, published, description,
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail) nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail)
return video return video
@ -1233,12 +1258,12 @@ def process_video_params(query, preferences)
continue = query["continue"]?.try &.to_i? continue = query["continue"]?.try &.to_i?
continue_autoplay = query["continue_autoplay"]?.try &.to_i? continue_autoplay = query["continue_autoplay"]?.try &.to_i?
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
local = query["local"]? && (query["local"] == "true").to_unsafe local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase } preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]? quality = query["quality"]?
region = query["region"]? region = query["region"]?
related_videos = query["related_videos"]? related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe
speed = query["speed"]?.try &.to_f? speed = query["speed"]?.try &.rchop("x").to_f?
video_loop = query["loop"]?.try &.to_i? video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i? volume = query["volume"]?.try &.to_i?
@ -1282,6 +1307,14 @@ def process_video_params(query, preferences)
related_videos = related_videos == 1 related_videos = related_videos == 1
video_loop = video_loop == 1 video_loop = video_loop == 1
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
end
if CONFIG.disabled?("local") && local
local = false
end
if query["t"]? if query["t"]?
video_start = decode_time(query["t"]) video_start = decode_time(query["t"])
end end