Compare commits

..

99 Commits

Author SHA1 Message Date
Omar Roth 35bee987f6 Proxy profile pictures 2018-09-17 18:39:28 -05:00
Omar Roth bd5ec2f2f3 Add playlist RSS 2018-09-17 18:13:24 -05:00
Omar Roth 296771809a Refactor protocol buffers 2018-09-17 16:56:28 -05:00
Omar Roth 83ba4e2a4c Fix truncated thumbnails 2018-09-17 14:48:02 -05:00
Omar Roth 6cb834a18d Add support for 304 in thumbnails 2018-09-17 09:38:52 -05:00
Omar Roth 0a4e9e6252 Properly filter user's subscriptions in search 2018-09-16 22:14:51 -05:00
Omar Roth 9619d3f1bc Fix channel refresh 2018-09-16 21:44:24 -05:00
Omar Roth f39ed3d145 Add subscriptions search filter 2018-09-16 21:28:00 -05:00
Omar Roth f38aac851e Fix full channel refresh 2018-09-16 20:32:39 -05:00
Omar Roth b6adeb80e6 Fix player margin 2018-09-15 13:04:13 -05:00
Omar Roth c74cc1123f Maintain aspect ratio even when JS is disabled 2018-09-15 12:15:39 -05:00
Omar Roth 0e1b5d7cdd Add fix for dash sequences 2018-09-15 10:25:43 -05:00
Omar Roth d2bbf9d33c Fix dash parsing for video info 2018-09-15 08:56:47 -05:00
Omar Roth 3ccee120d3 Proxy thumbnails for related videos 2018-09-15 08:20:43 -05:00
Omar Roth 6753294ee1 Fix poster resize 2018-09-14 22:38:53 -05:00
Omar Roth f9881ebaab Update videojs-share.css 2018-09-14 21:49:05 -05:00
Omar Roth 429a4b2dec Proxy thumbnails 2018-09-14 21:24:28 -05:00
Omar Roth 4287c0d96a Fix related video bar for users that aren't logged in 2018-09-14 20:10:13 -05:00
Omar Roth 5cd137d808 Refactor signature extractor 2018-09-14 19:50:11 -05:00
Omar Roth 62ae836565 Remove 'less' button in playlist descriptions 2018-09-13 21:00:39 -05:00
Omar Roth b7acdfad24 Fix typo 2018-09-13 20:27:50 -05:00
Omar Roth d3eadccd51 Add 'publishedText' to API endpoints 2018-09-13 20:26:05 -05:00
Omar Roth 2232bc0495 Use escaped newlines instead of graves 2018-09-13 18:12:19 -05:00
Omar Roth f7ca81c384 Add support for channel search 2018-09-13 17:47:31 -05:00
Omar Roth d4ee786cab Add support for comments under controversial videos 2018-09-13 16:09:14 -05:00
Omar Roth a54668688b Add support for dashmpd within video info 2018-09-12 22:31:47 -05:00
Omar Roth 89bda1d3db Remove migration points 2018-09-09 21:58:22 -05:00
Omar Roth e0ee1c3d79 Shrink size of template gutters 2018-09-09 14:50:24 -05:00
Omar Roth 5b2c228bb6 Add 'license' 2018-09-09 14:47:26 -05:00
Omar Roth ffab3ee79f Shrink size between video requests 2018-09-09 14:41:29 -05:00
Omar Roth dc6cc028c5 Remove migration point 2018-09-09 14:34:16 -05:00
Omar Roth c1f17f2f82 Show quality selector even if only one source 2018-09-09 14:23:37 -05:00
Omar Roth 1c8bd671d8 Fix link redirect for YouTube comments 2018-09-09 09:18:31 -05:00
Omar Roth 133b72f9cf Add support for genre channels that don't end with " - Topic" 2018-09-09 08:53:04 -05:00
Omar Roth 8c45694ce5 Escape comment text 2018-09-09 07:40:12 -05:00
Omar Roth bd820b9b48 Update videojs-share.js 2018-09-07 15:55:11 -05:00
Omar Roth 47e94fedc6 Fix signature extraction 2018-09-07 15:52:46 -05:00
Omar Roth aff2083529 Fix missing 'end' 2018-09-06 18:18:36 -05:00
Omar Roth 1eae76fc15 Add fix for empty descriptions 2018-09-06 16:50:12 -05:00
Omar Roth cf63c825d4 Add fix for shortened youtu.be links in comments 2018-09-06 16:45:15 -05:00
Omar Roth 446d8569a4 Bump version to match tag 2018-09-06 10:54:12 -05:00
Omar Roth 454b1662b7 Add format=json for reddit comments 2018-09-06 10:19:28 -05:00
Omar Roth 3ec684ae71 Host assets locally 2018-09-06 09:59:17 -05:00
Omar Roth b17d3d1e51 Bump number of videos in channel resources to 60 2018-09-06 08:43:22 -05:00
Omar Roth d81a803618 Add /user/:user/videos 2018-09-05 23:12:11 -05:00
Omar Roth e6d2166bac Add X-XSS-Protection and X-Content-Type-Options 2018-09-05 21:51:40 -05:00
Omar Roth e590d39aa9 Revert "Add header check for CSRF"
This reverts commit a749ac73ac.
2018-09-05 21:45:14 -05:00
Omar Roth 4f91854bd3 Fix typo 2018-09-05 21:10:32 -05:00
Omar Roth 29a21860ae Strip leading slashes from referers 2018-09-05 21:07:19 -05:00
Omar Roth 96234e509f Add X-Frame-Options, X-XSS-Protection, and X-Content-Type-Options 2018-09-05 21:06:30 -05:00
Omar Roth a749ac73ac Add header check for CSRF 2018-09-05 20:32:01 -05:00
Omar Roth 62f023c50f Add 'https_only' default 2018-09-05 20:31:08 -05:00
Omar Roth 29dc114f7a Bump supported Crystal version 2018-09-05 20:30:44 -05:00
Omar Roth 023066b452 Revert "Remove 'codecs' from source types"
This reverts commit 93e12d94fc.
2018-09-05 10:49:40 -05:00
Omar Roth 93e12d94fc Remove 'codecs' from source types 2018-09-05 10:38:01 -05:00
Omar Roth 044a57ef34 Fix video count for channels 2018-09-04 23:01:46 -05:00
Omar Roth bc49c7d181 Add author info to API endpoints 2018-09-04 21:35:25 -05:00
Omar Roth 5632e58636 Add support for genre channels 2018-09-04 21:04:40 -05:00
Omar Roth e1bf7fa6cc Add descriptionHtml to playlists 2018-09-04 19:27:10 -05:00
Omar Roth 40028e1462 Update SQL and remove migration points 2018-09-04 09:57:40 -05:00
Omar Roth 53732cdcab Add genre URLs 2018-09-04 09:50:19 -05:00
Omar Roth 2ac89d5e00 Update project synopsis 2018-09-04 09:22:10 -05:00
Omar Roth 98d71ca8e7 Add support for /c/ URLs 2018-09-04 09:13:58 -05:00
Omar Roth 0f2f273335 Don't leak referers 2018-09-04 09:01:43 -05:00
Omar Roth 000cfd4834 Don't show comments when commentCount is 0 2018-09-04 08:52:39 -05:00
Omar Roth 25c3ee034e Minor refactor 2018-09-04 08:52:30 -05:00
Omar Roth 89d3587861 Fix typo 2018-09-03 22:20:20 -05:00
Omar Roth 0d8f036bf1 Replace YouTube links 2018-09-03 22:15:47 -05:00
Omar Roth 81c520e0dd Add info to README 2018-09-03 21:42:49 -05:00
Omar Roth c0bda13965 Fix view_count_text 2018-08-31 22:53:41 -05:00
Omar Roth 3b1df75061 Merge pull request #143 from dimqua/patch-1
Change the color of progressBar marker
2018-08-31 18:20:30 -05:00
dimqua eda5beaed5 Change the color of progressBar marker 2018-08-31 16:49:02 +03:00
Omar Roth 4022670cb1 Fix typo in video params 2018-08-30 21:04:41 -05:00
Omar Roth 7b135a6d0c Add commentCount for videos with no comments 2018-08-30 21:03:22 -05:00
Omar Roth bdaa8a06fd Fix typo 2018-08-30 20:25:43 -05:00
Omar Roth b3f9059452 Add comment formatting 2018-08-30 20:06:08 -05:00
Omar Roth 917d220623 Fix search filters 2018-08-30 17:42:30 -05:00
Omar Roth ed8ddbc07d Add seperator when notifications > 0 2018-08-30 16:52:29 -05:00
Omar Roth cb01b50fbb Add option to hide related videos 2018-08-30 16:49:38 -05:00
Omar Roth 6b3c9d23d0 Fix referer on 404 2018-08-30 08:14:59 -05:00
Omar Roth 3839013a37 Use '/video' page for channel endpoint 2018-08-28 20:29:08 -05:00
Omar Roth 9d5dddab29 Fix signature extraction 2018-08-28 09:51:59 -05:00
Omar Roth 45fa148380 Don't add playlist id for channel videos 2018-08-27 18:53:34 -05:00
Omar Roth 2ba0063dc0 Add search filters 2018-08-27 15:23:25 -05:00
Omar Roth b57176d7ef Fix notification count in subscription feed 2018-08-27 13:46:50 -05:00
Omar Roth 0dbef6ab9f Fix typo in preferred_captions 2018-08-26 15:00:19 -05:00
Omar Roth 8fc4dcfdea Use username for /data_control 2018-08-25 21:49:18 -05:00
Omar Roth 6c98513153 Add referer to /data_control 2018-08-25 21:48:20 -05:00
Omar Roth c3d8ca68b3 Add code to calculate video rating 2018-08-25 21:34:11 -05:00
Omar Roth a37692cce4 Fix 'to_json' for comment array 2018-08-25 21:33:53 -05:00
Omar Roth a1ad561b98 Fix /clear_watch_history 2018-08-25 21:33:33 -05:00
Omar Roth 7fd0f93d02 Add support for preferences as query params 2018-08-25 20:05:51 -05:00
Omar Roth 23aaf7f1b7 Add comments fallback 2018-08-25 18:33:15 -05:00
Omar Roth 41a04e7c67 Clean up /videoplayback 2018-08-25 17:24:07 -05:00
Omar Roth 77b12b6249 Only show next page when there are more results 2018-08-25 17:18:43 -05:00
Omar Roth 78fcf579a7 Add Liberapay 2018-08-25 15:43:39 -05:00
Omar Roth 9ae3bf216e Update signature extraction 2018-08-24 07:17:16 -05:00
Omar Roth 0e7c56687b Add error message for comment timeouts 2018-08-23 16:55:26 -05:00
Omar Roth 01a80995d3 Add fix for channel endpoint where channel has no subscribers 2018-08-22 11:06:31 -05:00
47 changed files with 3595 additions and 640 deletions
+21 -1
View File
@@ -1,7 +1,27 @@
# Invidious
## Invidious is what YouTube should be
## Invidious is an alternative front-end to YouTube
- Audio-only (and no need to keep window open on mobile)
- [Open-source](https://github.com/omarroth/invidious) (AGPLv3 licensed)
- No ads
- No need to create a Google account to save subscriptions
- Lightweight (homepage is ~4 KB compressed)
- Tools for managing subscriptions:
- Only show unseen videos
- Only show latest (or latest unseen) video from each channel
- Delivers notifications from all subscribed channels
- Automatically redirect homepage to feed
- Import subscriptions from YouTube
- Dark mode
- Embed support
- Set default player options (speed, quality, autoplay, loop)
- Does not require JS to play videos
- Support for Reddit comments in place of YT comments
- Import/Export subscriptions, watch history, preference
- Does not use any of the official YouTube APIs
Liberapay: https://liberapay.com/omarroth
Patreon: https://patreon.com/omarroth
BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY
BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
+25
View File
@@ -171,6 +171,11 @@ div {
background-color: rgba(0, 182, 240, 1);
}
/* ProgressBar marker */
.vjs-marker {
background-color: rgba(255, 255, 255, 1);
}
/* Big "Play" Button */
.video-js .vjs-big-play-button {
background-color: rgba(35, 35, 35, 0.5);
@@ -191,3 +196,23 @@ div {
padding-left: 0px;
padding-right: 0px;
}
.video-js .vjs-poster {
background-size: cover;
object-fit: cover;
}
#player {
position: absolute;
left: 0;
top: 0;
height: 100%;
}
#player-container {
position: relative;
padding-bottom: 56.25%;
margin-left: 1em;
margin-right: 1em;
height: 0;
}
File diff suppressed because one or more lines are too long
+11
View File
File diff suppressed because one or more lines are too long
+11
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.vjs-quality-selector .vjs-menu-button{margin:0;padding:0;height:100%;width:100%}.vjs-quality-selector .vjs-icon-placeholder{font-family:'VideoJS';font-weight:normal;font-style:normal}.vjs-quality-selector .vjs-icon-placeholder:before{content:'\f110'}.vjs-quality-changing .vjs-big-play-button{display:none}.vjs-quality-changing .vjs-control-bar{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;visibility:visible;opacity:1}
+1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
/**
* videojs-share
* @version 2.0.1
* @copyright 2018 Mikhail Khazov <mkhazov.work@gmail.com>
* @license MIT
*/
.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-modal-dialog-content{display:flex;align-items:center;padding:0;background-image:linear-gradient(to bottom, rgba(0,0,0,0.77), rgba(0,0,0,0.75))}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{position:absolute;right:0;top:5px;width:30px;height:30px;color:#fff;cursor:pointer;opacity:0.9;transition:opacity 0.25s ease-out}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button:before{content:'×';font-size:20px;line-height:15px}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button:hover{opacity:1}.video-js .vjs-share{display:flex;flex-direction:column;justify-content:space-around;align-items:center;width:100%;height:100%;max-height:400px}.video-js .vjs-share__top,.video-js .vjs-share__middle,.video-js .vjs-share__bottom{display:flex}.video-js .vjs-share__top,.video-js .vjs-share__middle{flex-direction:column;justify-content:space-between}.video-js .vjs-share__middle{padding:0 25px}.video-js .vjs-share__title{align-self:center;font-size:22px;color:#fff}.video-js .vjs-share__subtitle{width:100%;margin:0 auto 12px;font-size:16px;color:#fff;opacity:0.7}.video-js .vjs-share__short-link-wrapper{position:relative;display:block;width:100%;height:40px;margin:0 auto;margin-bottom:15px;border:0;color:rgba(255,255,255,0.65);background-color:#363636;outline:none;overflow:hidden;flex-shrink:0}.video-js .vjs-share__short-link{display:block;width:100%;height:100%;padding:0 40px 0 15px;border:0;color:rgba(255,255,255,0.65);background-color:#363636;outline:none}.video-js .vjs-share__btn{position:absolute;right:0;bottom:0;height:40px;width:40px;display:flex;align-items:center;padding:0 11px;border:0;color:#fff;background-color:#2e2e2e;background-size:18px 19px;background-position:center;background-repeat:no-repeat;cursor:pointer;outline:none;transition:width 0.3s ease-out, padding 0.3s ease-out}.video-js .vjs-share__btn svg{flex-shrink:0}.video-js .vjs-share__btn span{position:relative;padding-left:10px;opacity:0;transition:opacity 0.3s ease-out}.video-js .vjs-share__btn:hover{justify-content:center;width:100%;padding:0 40px;background-image:none}.video-js .vjs-share__btn:hover span{opacity:1}.video-js .vjs-share__socials{display:flex;flex-wrap:wrap;justify-content:center;align-content:flex-start;transition:width 0.3s ease-out, height 0.3s ease-out}.video-js .vjs-share__social{display:flex;justify-content:center;align-items:center;flex-shrink:0;width:32px;height:32px;margin-right:6px;margin-bottom:6px;cursor:pointer;font-size:8px;transition:transform 0.3s ease-out, filter 0.2s ease-out;border:none;outline:none}.video-js .vjs-share__social:hover{filter:brightness(115%)}.video-js .vjs-share__social svg{width:100%;max-height:24px}.video-js .vjs-share__social_vk{background-color:#5d7294}.video-js .vjs-share__social_ok{background-color:#ed7c20}.video-js .vjs-share__social_mail{background-color:#134785}.video-js .vjs-share__social_tw{background-color:#76aaeb}.video-js .vjs-share__social_reddit{background-color:#ff4500}.video-js .vjs-share__social_fbFeed{background-color:#475995}.video-js .vjs-share__social_messenger{background-color:#0084ff}.video-js .vjs-share__social_gp{background-color:#d53f35}.video-js .vjs-share__social_linkedin{background-color:#0077b5}.video-js .vjs-share__social_viber{background-color:#766db5}.video-js .vjs-share__social_telegram{background-color:#4bb0e2}.video-js .vjs-share__social_whatsapp{background-color:#78c870}.video-js .vjs-share__bottom{justify-content:center}@media (max-height: 220px){.video-js .vjs-share .hidden-xs{display:none}}@media (max-height: 350px){.video-js .vjs-share .hidden-sm{display:none}}@media (min-height: 400px){.video-js .vjs-share__title{margin-bottom:15px}.video-js .vjs-share__short-link-wrapper{margin-bottom:30px}}@media (min-width: 320px){.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{right:5px;top:10px}}@media (min-width: 660px){.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{right:20px;top:20px}.video-js .vjs-share__social{width:40px;height:40px}}
+1
View File
@@ -0,0 +1 @@
.vjs-marker{position:absolute;left:0;bottom:0;opacity:1;height:100%;transition:opacity .2s ease;-webkit-transition:opacity .2s ease;-moz-transition:opacity .2s ease;z-index:100}.vjs-marker:hover{cursor:pointer;-webkit-transform:scale(1.3,1.3);-moz-transform:scale(1.3,1.3);-o-transform:scale(1.3,1.3);-ms-transform:scale(1.3,1.3);transform:scale(1.3,1.3)}.vjs-tip{visibility:hidden;display:block;opacity:.8;padding:5px;font-size:10px;position:absolute;bottom:14px;z-index:100000}.vjs-tip .vjs-tip-arrow{background:url(data:image/gif;base64,R0lGODlhCQAJAIABAAAAAAAAACH5BAEAAAEALAAAAAAJAAkAAAIRjAOnwIrcDJxvwkplPtchVQAAOw==) no-repeat top left;bottom:0;left:50%;margin-left:-4px;background-position:bottom left;position:absolute;width:9px;height:5px}.vjs-tip .vjs-tip-inner{border-radius:3px;-moz-border-radius:3px;-webkit-border-radius:3px;padding:5px 8px 4px 8px;background-color:#000;color:#fff;max-width:200px;text-align:center}.vjs-break-overlay{visibility:hidden;position:absolute;z-index:100000;top:0}.vjs-break-overlay .vjs-break-overlay-text{padding:9px;text-align:center}
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
/*! @name videojs-contrib-quality-levels @version 2.0.7 @license Apache-2.0 */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("video.js"),require("global/document")):"function"==typeof define&&define.amd?define(["video.js","global/document"],t):e.videojsContribQualityLevels=t(e.videojs,e.document)}(this,function(e,t){"use strict";e=e&&e.hasOwnProperty("default")?e.default:e,t=t&&t.hasOwnProperty("default")?t.default:t;var n=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},r=function(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t},i=function(i){function o(){n(this,o);var l=r(this,i.call(this)),s=l;if(e.browser.IS_IE8)for(var u in s=t.createElement("custom"),o.prototype)"constructor"!==u&&(s[u]=o.prototype[u]);return s.levels_=[],s.selectedIndex_=-1,Object.defineProperty(s,"selectedIndex",{get:function(){return s.selectedIndex_}}),Object.defineProperty(s,"length",{get:function(){return s.levels_.length}}),r(l,s)}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}(o,i),o.prototype.addQualityLevel=function(r){var i=this.getQualityLevelById(r.id);if(i)return i;var o=this.levels_.length;return i=new function r(i){n(this,r);var o=this;if(e.browser.IS_IE8)for(var l in o=t.createElement("custom"),r.prototype)"constructor"!==l&&(o[l]=r.prototype[l]);return o.id=i.id,o.label=o.id,o.width=i.width,o.height=i.height,o.bitrate=i.bandwidth,o.enabled_=i.enabled,Object.defineProperty(o,"enabled",{get:function(){return o.enabled_()},set:function(e){o.enabled_(e)}}),o}(r),""+o in this||Object.defineProperty(this,o,{get:function(){return this.levels_[o]}}),this.levels_.push(i),this.trigger({qualityLevel:i,type:"addqualitylevel"}),i},o.prototype.removeQualityLevel=function(e){for(var t=null,n=0,r=this.length;n<r;n++)if(this[n]===e){t=this.levels_.splice(n,1)[0],this.selectedIndex_===n?this.selectedIndex_=-1:this.selectedIndex_>n&&this.selectedIndex_--;break}return t&&this.trigger({qualityLevel:e,type:"removequalitylevel"}),t},o.prototype.getQualityLevelById=function(e){for(var t=0,n=this.length;t<n;t++){var r=this[t];if(r.id===e)return r}return null},o.prototype.dispose=function(){this.selectedIndex_=-1,this.levels_.length=0},o}(e.EventTarget);for(var o in i.prototype.allowedEvents_={change:"change",addqualitylevel:"addqualitylevel",removequalitylevel:"removequalitylevel"},i.prototype.allowedEvents_)i.prototype["on"+o]=null;var l=function(t){return n=this,e.mergeOptions({},t),r=n.qualityLevels,o=new i,n.on("dispose",function e(){o.dispose(),n.qualityLevels=r,n.off("dispose",e)}),n.qualityLevels=function(){return o},n.qualityLevels.VERSION="__VERSION__",o;var n,r,o};return(e.registerPlugin||e.plugin)("qualityLevels",l),l.VERSION="__VERSION__",l});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
/* videojs-hotkeys v0.2.22 - https://github.com/ctd1500/videojs-hotkeys */
!function(e,t){"undefined"!=typeof window&&window.videojs?t(window.videojs):"function"==typeof define&&define.amd?define("videojs-hotkeys",["video.js"],function(e){return t(e.default||e)}):"undefined"!=typeof module&&module.exports&&(module.exports=t(require("video.js")))}(0,function(s){"use strict";"undefined"!=typeof window&&(window.videojs_hotkeys={version:"0.2.22"});(s.registerPlugin||s.plugin)("hotkeys",function(m){var y=this,v=y.el(),f=document,e={volumeStep:.1,seekStep:5,enableMute:!0,enableVolumeScroll:!0,enableHoverScroll:!0,enableFullscreen:!0,enableNumbers:!0,enableJogStyle:!1,alwaysCaptureHotkeys:!1,enableModifiersForNumbers:!0,enableInactiveFocus:!0,skipInitialFocus:!1,playPauseKey:function(e){return 32===e.which||179===e.which},rewindKey:function(e){return 37===e.which||177===e.which},forwardKey:function(e){return 39===e.which||176===e.which},volumeUpKey:function(e){return 38===e.which},volumeDownKey:function(e){return 40===e.which},muteKey:function(e){return 77===e.which},fullscreenKey:function(e){return 70===e.which},customKeys:{}},t=s.mergeOptions||s.util.mergeOptions,d=(m=t(e,m||{})).volumeStep,n=m.seekStep,p=m.enableMute,r=m.enableVolumeScroll,o=m.enableHoverScroll,b=m.enableFullscreen,h=m.enableNumbers,w=m.enableJogStyle,k=m.alwaysCaptureHotkeys,S=m.enableModifiersForNumbers,u=m.enableInactiveFocus,l=m.skipInitialFocus;v.hasAttribute("tabIndex")||v.setAttribute("tabIndex","-1"),v.style.outline="none",!k&&y.autoplay()||l||y.one("play",function(){v.focus()}),u&&y.on("userinactive",function(){var n=function(){clearTimeout(e)},e=setTimeout(function(){y.off("useractive",n);var e=f.activeElement,t=v.querySelector(".vjs-control-bar");e&&e.parentElement==t&&v.focus()},10);y.one("useractive",n)}),y.on("play",function(){var e=v.querySelector(".iframeblocker");e&&""===e.style.display&&(e.style.display="block",e.style.bottom="39px")});var i=!1,c=v.querySelector(".vjs-volume-menu-button")||v.querySelector(".vjs-volume-panel");c.onmouseover=function(){i=!0},c.onmouseout=function(){i=!1};var a=function(e){if(o)var t=0;else t=f.activeElement;if(y.controls()&&(k||t==v||t==v.querySelector(".vjs-tech")||t==v.querySelector(".iframeblocker")||t==v.querySelector(".vjs-control-bar")||i)&&r){e=window.event||e;var n=Math.max(-1,Math.min(1,e.wheelDelta||-e.detail));e.preventDefault(),1==n?y.volume(y.volume()+d):-1==n&&y.volume(y.volume()-d)}},K=function(e,t){return m.playPauseKey(e,t)?1:m.rewindKey(e,t)?2:m.forwardKey(e,t)?3:m.volumeUpKey(e,t)?4:m.volumeDownKey(e,t)?5:m.muteKey(e,t)?6:m.fullscreenKey(e,t)?7:void 0};function q(e){return"function"==typeof n?n(e):n}return y.on("keydown",function(e){var t,n,r=e.which,o=e.preventDefault,u=y.duration();if(y.controls()){var l=f.activeElement;if(k||l==v||l==v.querySelector(".vjs-tech")||l==v.querySelector(".vjs-control-bar")||l==v.querySelector(".iframeblocker"))switch(K(e,y)){case 1:o(),k&&e.stopPropagation(),y.paused()?y.play():y.pause();break;case 2:t=!y.paused(),o(),t&&y.pause(),(n=y.currentTime()-q(e))<=0&&(n=0),y.currentTime(n),t&&y.play();break;case 3:t=!y.paused(),o(),t&&y.pause(),u<=(n=y.currentTime()+q(e))&&(n=t?u-.001:u),y.currentTime(n),t&&y.play();break;case 5:o(),w?(n=y.currentTime()-1,y.currentTime()<=1&&(n=0),y.currentTime(n)):y.volume(y.volume()-d);break;case 4:o(),w?(u<=(n=y.currentTime()+1)&&(n=u),y.currentTime(n)):y.volume(y.volume()+d);break;case 6:p&&y.muted(!y.muted());break;case 7:b&&(y.isFullscreen()?y.exitFullscreen():y.requestFullscreen());break;default:if((47<r&&r<59||95<r&&r<106)&&(S||!(e.metaKey||e.ctrlKey||e.altKey))&&h){var i=48;95<r&&(i=96);var c=r-i;o(),y.currentTime(y.duration()*c*.1)}for(var a in m.customKeys){var s=m.customKeys[a];s&&s.key&&s.handler&&s.key(e)&&(o(),s.handler(y,m,e))}}}}),y.on("dblclick",function(e){if(y.controls()){var t=e.relatedTarget||e.toElement||f.activeElement;t!=v&&t!=v.querySelector(".vjs-tech")&&t!=v.querySelector(".iframeblocker")||b&&(y.isFullscreen()?y.exitFullscreen():y.requestFullscreen())}}),y.on("mousewheel",a),y.on("DOMMouseScroll",a),this})});
+2 -1
View File
@@ -7,4 +7,5 @@ db:
host: localhost
port: 5432
dbname: invidious
full_refresh: false
full_refresh: false
https_only: false
+2
View File
@@ -20,6 +20,8 @@ CREATE TABLE public.videos
allowed_regions text[] COLLATE pg_catalog."default",
is_family_friendly boolean,
genre text COLLATE pg_catalog."default",
genre_url text COLLATE pg_catalog."default",
license text COLLATE pg_catalog."default",
CONSTRAINT videos_pkey PRIMARY KEY (id)
)
WITH (
+6 -6
View File
@@ -1,5 +1,5 @@
name: invidious
version: 0.2.0
version: 0.4.0
authors:
- Omar Roth <omarroth@hotmail.com>
@@ -9,13 +9,13 @@ targets:
main: src/invidious.cr
dependencies:
kemal:
github: kemalcr/kemal
pg:
github: will/crystal-pg
detect_language:
github: detectlanguage/detectlanguage-crystal
kemal:
github: kemalcr/kemal
pg:
github: will/crystal-pg
crystal: 0.26.0
crystal: 0.26.1
license: AGPLv3
+601 -157
View File
File diff suppressed because it is too large Load Diff
+84 -38
View File
@@ -48,6 +48,13 @@ def fetch_channel(ucid, client, db, pull_all_videos = true)
end
author = author.content
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
if !pull_all_videos
rss.xpath_nodes("//feed/entry").each do |entry|
video_id = entry.xpath_node("videoid").not_nil!.content
@@ -69,64 +76,103 @@ def fetch_channel(ucid, client, db, pull_all_videos = true)
updated = $4, ucid = $5, author = $6", video_array)
end
else
videos = [] of ChannelVideo
page = 1
ids = [] of String
loop do
url = produce_videos_url(ucid, page)
url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
content_html = json["content_html"].as_s
if content_html.empty?
# If we don't get anything, move on
if json["content_html"]? && !json["content_html"].as_s.empty?
document = XML.parse_html(json["content_html"].as_s)
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
else
break
end
document = XML.parse_html(content_html)
document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])).each do |item|
anchor = item.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
if !anchor
raise "could not find anchor"
end
title = anchor.content.strip
video_id = anchor["href"].lchop("/watch?v=")
published = item.xpath_node(%q(.//div[@class="yt-lockup-meta"]/ul/li[1]))
if !published
# This happens on Youtube red videos, here we just skip them
next
end
published = published.content
published = decode_date(published)
videos << ChannelVideo.new(video_id, title, published, Time.now, ucid, author)
if auto_generated
videos = extract_videos(nodeset)
else
videos = extract_videos(nodeset, ucid)
videos.each { |video| video.ucid = ucid }
videos.each { |video| video.author = author }
end
if document.xpath_nodes(%q(//li[contains(@class, "channels-content-item")])).size < 30
count = nodeset.size
videos = videos.map { |video| ChannelVideo.new(video.id, video.title, video.published, Time.now, video.ucid, video.author) }
videos.each do |video|
ids << video.id
db.exec("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, video.ucid)
video_array = video.to_a
args = arg_array(video_array)
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \
published = $3, updated = $4, ucid = $5, author = $6", video_array)
end
if count < 30
break
end
page += 1
end
video_ids = [] of String
videos.each do |video|
db.exec("UPDATE users SET notifications = notifications || $1 \
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid)
video_ids << video.id
video_array = video.to_a
args = arg_array(video_array)
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", video_array)
end
# When a video is deleted from a channel, we find and remove it here
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{video_ids.map { |a| %("#{a}") }.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
channel = InvidiousChannel.new(ucid, author, Time.now)
return channel
end
def produce_channel_videos_url(ucid, page = 1, auto_generated = nil)
if auto_generated
seed = Time.epoch(1525757349)
until seed >= Time.now
seed += 1.month
end
timestamp = seed - (page - 1).months
page = "#{timestamp.epoch}"
switch = "\x36"
else
page = "#{page}"
switch = "\x00"
end
meta = "\x12\x06videos"
meta += "\x30\x02"
meta += "\x38\x01"
meta += "\x60\x01"
meta += "\x6a\x00"
meta += "\xb8\x01\x00"
meta += "\x20#{switch}"
meta += "\x7a"
meta += page.size.to_u8.unsafe_chr
meta += page
meta = Base64.urlsafe_encode(meta)
meta = URI.escape(meta)
continuation = "\x12"
continuation += ucid.size.to_u8.unsafe_chr
continuation += ucid
continuation += "\x1a"
continuation += meta.size.to_u8.unsafe_chr
continuation += meta
continuation = continuation.size.to_u8.unsafe_chr + continuation
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
continuation = Base64.urlsafe_encode(continuation)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}"
return url
end
+16 -43
View File
@@ -93,17 +93,19 @@ def template_youtube_comments(comments)
<div class="pure-u-23-24">
<p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
onclick="load_comments(this)">View #{child["replies"]["replyCount"]} replies</a>
onclick="get_youtube_replies(this)">View #{child["replies"]["replyCount"]} replies</a>
</p>
</div>
</div>
END_HTML
end
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
html += <<-END_HTML
<div class="pure-g">
<div class="pure-u-2-24">
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{child["authorThumbnails"][-1]["url"]}">
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{author_thumbnail}">
</div>
<div class="pure-u-22-24">
<p>
@@ -113,7 +115,7 @@ def template_youtube_comments(comments)
- #{recode_date(Time.epoch(child["published"].as_i64))} ago
</p>
<div>
#{child["content"]}
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
#{replies_html}
</div>
</div>
@@ -127,7 +129,7 @@ def template_youtube_comments(comments)
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
onclick="load_comments(this)">Load more</a>
onclick="get_youtube_replies(this)">Load more</a>
</p>
</div>
</div>
@@ -190,37 +192,19 @@ def template_reddit_comments(root)
return html
end
def add_alt_links(html)
alt_links = [] of {String, String}
def replace_links(html)
html = XML.parse_html(html)
# This is painful but likely the only way to accomplish this in Crystal,
# as Crystigiri and others are not able to insert XML Nodes into a document.
# The goal here is to use as little regex as possible
html.scan(/<a[^>]*>([^<]+)<\/a>/) do |match|
anchor = XML.parse_html(match[0])
anchor = anchor.xpath_node("//a").not_nil!
html.xpath_nodes(%q(//a)).each do |anchor|
url = URI.parse(anchor["href"])
if ["www.youtube.com", "m.youtube.com"].includes?(url.host)
if {"www.youtube.com", "m.youtube.com", "youtu.be"}.includes?(url.host)
if url.path == "/redirect"
params = HTTP::Params.parse(url.query.not_nil!)
alt_url = params["q"]?
alt_url ||= "/"
anchor["href"] = params["q"]?
else
alt_url = url.full_path
anchor["href"] = url.full_path
end
alt_link = <<-END_HTML
<a href="#{alt_url}">
<i class="icon ion-ios-link"></i>
</a>
END_HTML
elsif url.host == "youtu.be"
alt_link = <<-END_HTML
<a href="/watch?v=#{url.path.try &.lchop("/")}&#{url.query}">
<i class="icon ion-ios-link"></i>
</a>
END_HTML
elsif url.to_s == "#"
begin
length_seconds = decode_length_seconds(anchor.content)
@@ -228,23 +212,12 @@ def add_alt_links(html)
length_seconds = decode_time(anchor.content)
end
alt_anchor = <<-END_HTML
<a href="javascript:void(0)" onclick="player.currentTime(#{length_seconds})">#{anchor.content}</a>
END_HTML
html = html.sub(anchor.to_s, alt_anchor)
next
else
alt_link = ""
anchor["href"] = "javascript:void(0)"
anchor["onclick"] = "player.currentTime(#{length_seconds})"
end
alt_links << {anchor.to_s, alt_link}
end
alt_links.each do |original, alternate|
html = html.sub(original, original + alternate)
end
html = html.to_xml(options: XML::SaveOptions::NO_DECL)
return html
end
@@ -267,5 +240,5 @@ def fill_links(html, scheme, host)
html = html.to_xml(options: XML::SaveOptions::NO_DECL)
end
html
return html
end
+21 -80
View File
@@ -18,7 +18,7 @@ class Config
end
class FilteredCompressHandler < Kemal::Handler
exclude ["/videoplayback", "/videoplayback/*", "/api/*"]
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/api/*", "/ggpht/*"]
def call(env)
return call_next env if exclude_match? env
@@ -41,6 +41,17 @@ class FilteredCompressHandler < Kemal::Handler
end
end
class DenyFrame < Kemal::Handler
exclude ["/embed/*"]
def call(env)
return call_next env if exclude_match? env
env.response.headers["X-Frame-Options"] = "sameorigin"
call_next env
end
end
def rank_videos(db, n, filter, url)
top = [] of {Float64, String}
@@ -116,81 +127,6 @@ def login_req(login_form, f_req)
return HTTP::Params.encode(data)
end
def produce_videos_url(ucid, page = 1)
page = "#{page}"
meta = "\x12\x06videos \x00\x30\x02\x38\x01\x60\x01\x6a\x00\x7a"
meta += page.size.to_u8.unsafe_chr
meta += page
meta += "\xb8\x01\x00"
meta = Base64.urlsafe_encode(meta)
meta = URI.escape(meta)
continuation = "\x12"
continuation += ucid.size.to_u8.unsafe_chr
continuation += ucid
continuation += "\x1a"
continuation += meta.size.to_u8.unsafe_chr
continuation += meta
continuation = continuation.size.to_u8.unsafe_chr + continuation
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
continuation = Base64.urlsafe_encode(continuation)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}"
return url
end
def read_var_int(bytes)
numRead = 0
result = 0
read = bytes[numRead]
if bytes.size == 1
result = bytes[0].to_i32
else
while ((read & 0b10000000) != 0)
read = bytes[numRead].to_u64
value = (read & 0b01111111)
result |= (value << (7 * numRead))
numRead += 1
if numRead > 5
raise "VarInt is too big"
end
end
end
return result
end
def write_var_int(value : Int)
bytes = [] of UInt8
value = value.to_u32
if value == 0
bytes = [0_u8]
else
while value != 0
temp = (value & 0b01111111).to_u8
value = value >> 7
if value != 0
temp |= 0b10000000
end
bytes << temp
end
end
return bytes
end
def generate_captcha(key)
minute = Random::Secure.rand(12)
minute_angle = minute * 30
@@ -240,7 +176,7 @@ def generate_captcha(key)
return {challenge: challenge, token: token}
end
def html_to_description(description_html)
def html_to_content(description_html)
if !description_html
description = ""
description_html = ""
@@ -248,10 +184,15 @@ def html_to_description(description_html)
description_html = description_html.to_s
description = description_html.gsub("<br>", "\n")
description = description.gsub("<br/>", "\n")
description = XML.parse_html(description).content.strip("\n ")
if description.empty?
description = ""
else
description = XML.parse_html(description).content.strip("\n ")
end
end
return description, description_html
return description_html, description
end
def extract_videos(nodeset, ucid = nil)
@@ -319,7 +260,7 @@ def extract_videos(nodeset, ucid = nil)
view_count ||= 0_i64
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
description, description_html = html_to_description(description_html)
description_html, description = html_to_content(description_html)
length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
if length_seconds
+48 -1
View File
@@ -169,7 +169,7 @@ def get_referer(env, fallback = "/")
referer = URI.parse(referer)
# "Unroll" nested referers
# "Unroll" nested referrers
loop do
if referer.query
params = HTTP::Params.parse(referer.query.not_nil!)
@@ -184,6 +184,7 @@ def get_referer(env, fallback = "/")
end
referer = referer.full_path
referer = "/" + referer.lstrip("\/\\")
if referer == env.request.path
referer = fallback
@@ -191,3 +192,49 @@ def get_referer(env, fallback = "/")
return referer
end
def read_var_int(bytes)
numRead = 0
result = 0
read = bytes[numRead]
if bytes.size == 1
result = bytes[0].to_i32
else
while ((read & 0b10000000) != 0)
read = bytes[numRead].to_u64
value = (read & 0b01111111)
result |= (value << (7 * numRead))
numRead += 1
if numRead > 5
raise "VarInt is too big"
end
end
end
return result
end
def write_var_int(value : Int)
bytes = [] of UInt8
value = value.to_u32
if value == 0
bytes = [0_u8]
else
while value != 0
temp = (value & 0b01111111).to_u8
value = value >> 7
if value != 0
temp |= 0b10000000
end
bytes << temp
end
end
return bytes
end
+3 -4
View File
@@ -2,13 +2,13 @@ def crawl_videos(db)
ids = Deque(String).new
random = Random.new
search(random.base64(3)).each do |video|
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
end
loop do
if ids.empty?
search(random.base64(3)).each do |video|
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
end
end
@@ -141,8 +141,7 @@ end
def update_decrypt_function
loop do
begin
client = make_client(YT_URL)
decrypt_function = fetch_decrypt_function(client)
decrypt_function = fetch_decrypt_function
rescue ex
next
end
+38 -39
View File
@@ -1,13 +1,14 @@
class Playlist
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
description: String,
video_count: Int32,
views: Int64,
updated: Time,
title: String,
id: String,
author: String,
ucid: String,
description: String,
description_html: String,
video_count: Int32,
views: Int64,
updated: Time,
})
end
@@ -87,28 +88,29 @@ def produce_playlist_url(id, index)
end
ucid = "VL" + id
continuation = [0x08_u8] + write_var_int(index)
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice, false)
meta = "\x08#{write_var_int(index).join}"
meta = Base64.urlsafe_encode(meta, false)
meta = "PT:#{meta}"
# Inner Base64
continuation = "PT:" + slice
continuation = [0x7a_u8, continuation.bytes.size.to_u8] + continuation.bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
wrapped = "\x7a"
wrapped += meta.bytes.size.unsafe_chr
wrapped += meta
# Outer Base64
continuation = [0x1a.to_u8, slice.bytes.size.to_u8] + slice.bytes
continuation = ucid.bytes + continuation
continuation = [0x12_u8, ucid.size.to_u8] + continuation
continuation = [0xe2_u8, 0xa9_u8, 0x85_u8, 0xb2_u8, 2_u8, continuation.size.to_u8] + continuation
wrapped = Base64.urlsafe_encode(wrapped)
meta = URI.escape(wrapped)
# Wrap bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
continuation = slice
continuation = "\x12"
continuation += ucid.size.unsafe_chr
continuation += ucid
continuation += "\x1a"
continuation += meta.bytes.size.unsafe_chr
continuation += meta
continuation = continuation.size.to_u8.unsafe_chr + continuation
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
continuation = Base64.urlsafe_encode(continuation)
continuation = URI.escape(continuation)
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
@@ -118,22 +120,18 @@ end
def fetch_playlist(plid)
client = make_client(YT_URL)
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
document = XML.parse_html(response.body)
body = response.body.gsub(<<-END_BUTTON
<button class="yt-uix-button yt-uix-button-size-default yt-uix-button-link yt-uix-expander-head playlist-description-expander yt-uix-inlineedit-ignore-edit" type="button" onclick=";return false;"><span class="yt-uix-button-content"> less <img alt="" src="/yts/img/pixel-vfl3z5WfW.gif">
</span></button>
END_BUTTON
, "")
document = XML.parse_html(body)
title = document.xpath_node(%q(//h1[@class="pl-header-title"])).not_nil!.content
title = title.strip(" \n")
description = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description ||= document.xpath_node(%q(//span[@class="pl-header-description-text"]))
if description
description = description.to_xml.strip(" \n")
description = description.split("<button ")[0]
description = fill_links(description, "https", "www.youtube.com")
description = add_alt_links(description)
else
description = ""
end
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description_html, description = html_to_content(description_html)
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])).not_nil!
author = anchor.xpath_node(%q(.//li[1]/a)).not_nil!.content
@@ -151,6 +149,7 @@ def fetch_playlist(plid)
author,
ucid,
description,
description_html,
video_count,
views,
updated
+89 -10
View File
@@ -12,33 +12,75 @@ class SearchVideo
})
end
def search(query, page = 1, search_params = build_search_params(content_type: "video"))
def channel_search(query, page, channel)
client = make_client(YT_URL)
response = client.get("/user/#{channel}")
document = XML.parse_html(response.body)
canonical = document.xpath_node(%q(//link[@rel="canonical"]))
if !canonical
response = client.get("/channel/#{channel}")
document = XML.parse_html(response.body)
canonical = document.xpath_node(%q(//link[@rel="canonical"]))
end
if !canonical
return 0, [] of SearchVideo
end
ucid = canonical["href"].split("/")[-1]
url = produce_channel_search_url(ucid, query, page)
response = client.get(url)
json = JSON.parse(response.body)
if json["content_html"]? && !json["content_html"].as_s.empty?
document = XML.parse_html(json["content_html"].as_s)
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
count = nodeset.size
videos = extract_videos(nodeset)
else
count = 0
videos = [] of SearchVideo
end
return count, videos
end
def search(query, page = 1, search_params = produce_search_params(content_type: "video"))
client = make_client(YT_URL)
if query.empty?
return {0, [] of SearchVideo}
end
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&disable_polymer=1").body
if html.empty?
return [] of SearchVideo
return {0, [] of SearchVideo}
end
html = XML.parse_html(html)
nodeset = html.xpath_nodes(%q(//ol[@class="item-section"]/li))
videos = extract_videos(nodeset)
return videos
return {nodeset.size, videos}
end
def build_search_params(sort_by = "relevance", date : String = "", content_type : String = "", duration : String = "", features : Array(String) = [] of String)
def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
duration : String = "", features : Array(String) = [] of String)
head = "\x08"
head += case sort_by
head += case sort
when "relevance"
"\x00"
when "rating"
"\x01"
when "upload_date"
when "upload_date", "date"
"\x02"
when "view_count"
when "view_count", "views"
"\x03"
else
raise "No sort #{sort_by}"
raise "No sort #{sort}"
end
body = ""
@@ -87,7 +129,7 @@ def build_search_params(sort_by = "relevance", date : String = "", content_type
"\x20\x01"
when "subtitles"
"\x28\x01"
when "creative_commons"
when "creative_commons", "cc"
"\x30\x01"
when "3d"
"\x38\x01"
@@ -109,7 +151,7 @@ def build_search_params(sort_by = "relevance", date : String = "", content_type
end
if body.size > 0
token = head + "\x12" + body.size.to_u8.unsafe_chr + body
token = head + "\x12" + body.size.unsafe_chr + body
else
token = head
end
@@ -119,3 +161,40 @@ def build_search_params(sort_by = "relevance", date : String = "", content_type
return token
end
def produce_channel_search_url(ucid, query, page)
page = "#{page}"
meta = "\x12\x06search"
meta += "\x30\x02"
meta += "\x38\x01"
meta += "\x60\x01"
meta += "\x6a\x00"
meta += "\xb8\x01\x00"
meta += "\x7a"
meta += page.size.unsafe_chr
meta += page
meta = Base64.urlsafe_encode(meta)
meta = URI.escape(meta)
continuation = "\x12"
continuation += ucid.size.unsafe_chr
continuation += ucid
continuation += "\x1a"
continuation += meta.size.unsafe_chr
continuation += meta
continuation += "\x5a"
continuation += query.size.unsafe_chr
continuation += query
continuation = continuation.size.unsafe_chr + continuation
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
continuation = Base64.urlsafe_encode(continuation)
continuation = URI.escape(continuation)
url = "/browse_ajax?continuation=#{continuation}"
return url
end
+13 -14
View File
@@ -1,25 +1,25 @@
def fetch_decrypt_function(client, id = "CvFH_6DNRCY")
def fetch_decrypt_function(id = "CvFH_6DNRCY")
client = make_client(YT_URL)
document = client.get("/watch?v=#{id}").body
url = document.match(/src="(?<url>\/yts\/jsbin\/player-.{9}\/en_US\/base.js)"/).not_nil!["url"]
player = client.get(url).body
function_name = player.match(/\(b\|\|\(b="signature"\),d.set\(b,(?<name>[a-zA-Z0-9]{2})\(c\)\)\)/).not_nil!["name"]
function_body = player.match(/#{function_name}=function\(a\){(?<body>[^}]+)}/).not_nil!["body"]
function_name = player.match(/^(?<name>[^=]+)=function\(a\){a=a\.split\(""\)/m).not_nil!["name"]
function_body = player.match(/^#{function_name}=function\(a\){(?<body>[^}]+)}/m).not_nil!["body"]
function_body = function_body.split(";")[1..-2]
var_name = function_body[0][0, 2]
var_body = player.delete("\n").match(/var #{var_name}={(?<body>(.*?))};/).not_nil!["body"]
operations = {} of String => String
matches = player.delete("\n").match(/var #{var_name}={(?<op1>[a-zA-Z0-9]{2}:[^}]+}),(?<op2>[a-zA-Z0-9]{2}:[^}]+}),(?<op3>[a-zA-Z0-9]{2}:[^}]+})};/).not_nil!
3.times do |i|
operation = matches["op#{i + 1}"]
op_name = operation[0, 2]
var_body.split("},").each do |operation|
op_name = operation.match(/^[^:]+/).not_nil![0]
op_body = operation.match(/\{[^}]+/).not_nil![0]
op_body = operation.match(/\{[^}]+\}/).not_nil![0]
case op_body
when "{a.reverse()}"
when "{a.reverse()"
operations[op_name] = "a"
when "{a.splice(0,b)}"
when "{a.splice(0,b)"
operations[op_name] = "b"
else
operations[op_name] = "c"
@@ -28,11 +28,10 @@ def fetch_decrypt_function(client, id = "CvFH_6DNRCY")
decrypt_function = [] of {name: String, value: Int32}
function_body.each do |function|
function = function.lchop(var_name + ".")
op_name = function[0, 2]
function = function.lchop(var_name).delete("[].")
function = function.lchop(op_name + "(a,")
value = function.rchop(")").to_i
op_name = function.match(/[^\(]+/).not_nil![0]
value = function.match(/\(a,(?<value>[\d]+)\)/).not_nil!["value"].to_i
decrypt_function << {name: operations[op_name], value: value}
end
+45 -16
View File
@@ -27,22 +27,46 @@ class User
end
DEFAULT_USER_PREFERENCES = Preferences.from_json({
"video_loop" => false,
"autoplay" => false,
"speed" => 1.0,
"quality" => "hd720",
"volume" => 100,
"comments" => "youtube",
"captions" => ["", "", ""],
"dark_mode" => false,
"thin_mode " => false,
"max_results" => 40,
"sort" => "published",
"latest_only" => false,
"unseen_only" => false,
"video_loop" => false,
"autoplay" => false,
"speed" => 1.0,
"quality" => "hd720",
"volume" => 100,
"comments" => ["youtube", ""],
"captions" => ["", "", ""],
"related_videos" => true,
"dark_mode" => false,
"thin_mode " => false,
"max_results" => 40,
"sort" => "published",
"latest_only" => false,
"unseen_only" => false,
}.to_json)
class Preferences
module StringToArray
def self.to_json(value : Array(String), json : JSON::Builder)
json.array do
value.each do |element|
json.string element
end
end
end
def self.from_json(value : JSON::PullParser) : Array(String)
begin
result = [] of String
value.read_array do
result << value.read_string
end
rescue ex
result = [value.read_string, ""]
end
result
end
end
JSON.mapping({
video_loop: Bool,
autoplay: Bool,
@@ -50,8 +74,9 @@ class Preferences
quality: String,
volume: Int32,
comments: {
type: String,
default: "youtube",
type: Array(String),
default: ["youtube", ""],
converter: StringToArray,
},
captions: {
type: Array(String),
@@ -61,6 +86,10 @@ class Preferences
type: Bool,
default: false,
},
related_videos: {
type: Bool,
default: true,
},
dark_mode: Bool,
thin_mode: {
type: Bool,
@@ -111,7 +140,7 @@ def fetch_user(sid, client, headers, db)
channels = [] of String
feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).each do |channel|
if !["Popular on YouTube", "Music", "Sports", "Gaming"].includes? channel["title"]
if !{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"]
channel_id = channel["href"].lstrip("/channel/")
begin
+129 -16
View File
@@ -112,11 +112,11 @@ REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "A
BYPASS_REGIONS = {"CA", "DE", "FR", "JP", "RU", "UK"}
VIDEO_THUMBNAILS = {
{name: "default", url: "default", height: 90, width: 120},
{name: "medium", url: "mqdefault", height: 180, width: 320},
{name: "high", url: "hqdefault", height: 360, width: 480},
{name: "maxresdefault", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", url: "sddefault", height: 480, width: 640},
{name: "maxresdefault", url: "maxresdefault", height: 1280, width: 720},
{name: "high", url: "hqdefault", height: 360, width: 480},
{name: "medium", url: "mqdefault", height: 180, width: 320},
{name: "default", url: "default", height: 90, width: 120},
{name: "start", url: "1", height: 90, width: 120},
{name: "middle", url: "2", height: 90, width: 120},
{name: "end", url: "3", height: 90, width: 120},
@@ -258,10 +258,82 @@ class Video
def adaptive_fmts(decrypt_function)
adaptive_fmts = [] of HTTP::Params
if self.info.has_key?("adaptive_fmts")
self.info["adaptive_fmts"].split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
elsif self.info.has_key?("dashmpd")
client = make_client(YT_URL)
response = client.get(self.info["dashmpd"])
document = XML.parse_html(response.body)
document.xpath_nodes(%q(//adaptationset)).each do |adaptation_set|
mime_type = adaptation_set["mimetype"]
document.xpath_nodes(%q(.//representation)).each do |representation|
codecs = representation["codecs"]
itag = representation["id"]
bandwidth = representation["bandwidth"]
url = representation.xpath_node(%q(.//baseurl)).not_nil!.content
clen = url.match(/clen\/(?<clen>\d+)/).try &.["clen"]
clen ||= "0"
lmt = url.match(/lmt\/(?<lmt>\d+)/).try &.["lmt"]
lmt ||= "#{((Time.now + 1.hour).epoch_f.to_f64 * 1000000).to_i64}"
segment_list = representation.xpath_node(%q(.//segmentlist)).not_nil!
init = segment_list.xpath_node(%q(.//initialization))
# TODO: Replace with sane defaults when byteranges are absent
if init && !init["sourceurl"].starts_with? "sq"
init = init["sourceurl"].lchop("range/")
index = segment_list.xpath_node(%q(.//segmenturl)).not_nil!["media"]
index = index.lchop("range/")
index = "#{init.split("-")[1].to_i + 1}-#{index.split("-")[0].to_i}"
else
init = "0-0"
index = "1-1"
end
params = {
"type" => ["#{mime_type}; codecs=\"#{codecs}\""],
"url" => [url],
"projection_type" => ["1"],
"index" => [index],
"init" => [init],
"xtags" => [] of String,
"lmt" => [lmt],
"clen" => [clen],
"bitrate" => [bandwidth],
"itag" => [itag],
}
if mime_type == "video/mp4"
width = representation["width"]?
height = representation["height"]?
fps = representation["framerate"]?
metadata = itag_to_metadata?(itag)
if metadata
width ||= metadata["width"]?
height ||= metadata["height"]?
fps ||= metadata["fps"]?
end
if width && height
params["size"] = ["#{width}x#{height}"]
end
if width
params["quality_label"] = ["#{height}p"]
end
end
adaptive_fmts << HTTP::Params.new(params)
end
end
end
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
@@ -345,6 +417,11 @@ class Video
allowed_regions: Array(String),
is_family_friendly: Bool,
genre: String,
genre_url: String,
license: {
type: String,
default: "",
},
})
end
@@ -366,15 +443,17 @@ def get_video(id, db, refresh = true)
if db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", id, as: Bool)
video = db.query_one("SELECT * FROM videos WHERE id = $1", id, as: Video)
# If record was last updated over an hour ago, refresh (expire param in response lasts for 6 hours)
if refresh && Time.now - video.updated > 1.hour
# If record was last updated over 10 minutes ago, refresh (expire param in response lasts for 6 hours)
if refresh && Time.now - video.updated > 10.minutes
begin
video = fetch_video(id)
video_array = video.to_a
args = arg_array(video_array[1..-1], 2)
db.exec("UPDATE videos SET (info,updated,title,views,likes,dislikes,wilson_score,\
published,description,language,author,ucid, allowed_regions, is_family_friendly, genre)\
published,description,language,author,ucid, allowed_regions, is_family_friendly,\
genre, genre_url, license)\
= (#{args}) WHERE id = $1", video_array)
rescue ex
db.exec("DELETE FROM videos * WHERE id = $1", id)
@@ -384,6 +463,7 @@ def get_video(id, db, refresh = true)
else
video = fetch_video(id)
video_array = video.to_a
args = arg_array(video_array)
db.exec("INSERT INTO videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", video_array)
@@ -490,10 +570,19 @@ def fetch_video(id)
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).not_nil!["content"]
genre_url = html.xpath_node(%(//a[text()="#{genre}"])).not_nil!["href"]
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li))
if license
license = license.content
else
license ||= ""
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)
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license)
return video
end
@@ -504,24 +593,37 @@ end
def process_video_params(query, preferences)
autoplay = query["autoplay"]?.try &.to_i?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
speed = query["speed"]?.try &.to_f?
video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i?
if preferences
autoplay ||= preferences.autoplay.to_unsafe
preferred_captions ||= preferences.captions
quality ||= preferences.quality
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
volume ||= preferences.volume
end
autoplay ||= 0
autoplay = autoplay == 1
autoplay ||= 0
preferred_captions ||= [] of String
quality ||= "hd720"
speed ||= 1
video_loop ||= 0
volume ||= 100
autoplay = autoplay == 1
video_loop = video_loop == 1
if query["t"]?
video_start = decode_time(query["t"])
end
video_start ||= 0
if query["time_continu"]?
video_start = decode_time(query["t"])
if query["time_continue"]?
video_start = decode_time(query["time_continue"])
end
video_start ||= 0
if query["start"]?
@@ -542,14 +644,25 @@ def process_video_params(query, preferences)
raw ||= 0
raw = raw == 1
quality = query["quality"]?
quality ||= "hd720"
controls = query["controls"]?.try &.to_i?
controls ||= 1
controls = controls == 1
return autoplay, video_loop, video_start, video_end, listen, raw, quality, controls
params = {
autoplay: autoplay,
controls: controls,
listen: listen,
preferred_captions: preferred_captions,
quality: quality,
raw: raw,
speed: speed,
video_end: video_end,
video_loop: video_loop,
video_start: video_start,
volume: volume,
}
return params
end
def generate_thumbnails(json, id)
+1 -1
View File
@@ -51,7 +51,7 @@
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<% if videos.size == 100 %>
<% if videos.size == 60 %>
<a href="/channel/<%= ucid %>?page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
+16 -18
View File
@@ -1,19 +1,19 @@
<video style="width:100%" playsinline poster="<%= thumbnail %>" title="<%= HTML.escape(video.title) %>"
id="player" class="video-js"
<% if autoplay %>autoplay<% end %>
<% if video_loop %>loop<% end %>
<% if controls %>controls<% end %>>
<% if params[:autoplay] %>autoplay<% end %>
<% if params[:video_loop] %>loop<% end %>
<% if params[:controls] %>controls<% end %>>
<% if hlsvp %>
<source src="<%= hlsvp %>" type="application/x-mpegURL">
<% else %>
<% if listen %>
<% if params[:listen] %>
<% audio_streams.each_with_index do |fmt, i| %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>">
<% end %>
<% else %>
<% fmt_stream.each_with_index do |fmt, i| %>
<% if preferences %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= preferences.quality == fmt["label"].split(" - ")[0] %>">
<% if params[:quality] %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params[:quality] == fmt["label"].split(" - ")[0] %>">
<% else %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>">
<% end %>
@@ -63,8 +63,8 @@ var shareOptions = {
title: "<%= video.title.dump_unquoted %>",
description: "<%= description %>",
image: "<%= thumbnail %>",
embedCode: `<iframe id='ivplayer' type='text/html' width='640' height='360'
src='<%= host_url %>/embed/<%= video.id %>?<%= host_params %>' frameborder='0'></iframe>`
embedCode: "<iframe id='ivplayer' type='text/html' width='640' height='360' \
src='<%= host_url %>/embed/<%= video.id %>?<%= host_params %>' frameborder='0'></iframe>"
};
var player = videojs("player", options, function() {
@@ -110,7 +110,7 @@ var player = videojs("player", options, function() {
player.share(shareOptions);
<% if video_start > 0 || video_end > 0 %>
<% if params[:video_start] > 0 || params[:video_end] > 0 %>
player.markers({
onMarkerReached: function(marker) {
if (marker.text === "End") {
@@ -122,19 +122,19 @@ player.markers({
}
},
markers: [
{ time: <%= video_start %>, text: "Start" },
<% if video_end < 0 %>
{ time: <%= params[:video_start] %>, text: "Start" },
<% if params[:video_end] < 0 %>
{ time: <%= video.info["length_seconds"].to_f - 0.5 %>, text: "End" }
<% else %>
{ time: <%= video_end %>, text: "End" }
{ time: <%= params[:video_end] %>, text: "End" }
<% end %>
]
});
player.currentTime(<%= video_start %>);
player.currentTime(<%= params[:video_start] %>);
<% end %>
<% if !listen %>
<% if !params[:listen] %>
var currentSources = player.currentSources();
for (var i = 0; i < currentSources.length; i++) {
if (player.canPlayType(currentSources[i]["type"].split(";")[0]) === "") {
@@ -146,8 +146,6 @@ for (var i = 0; i < currentSources.length; i++) {
player.src(currentSources);
<% end %>
<% if preferences %>
player.volume(<%= preferences.volume.to_f / 100 %>);
player.playbackRate(<%= preferences.speed %>);
<% end %>
player.volume(<%= params[:volume].to_f / 100 %>);
player.playbackRate(<%= params[:speed] %>);
</script>
@@ -1,12 +1,11 @@
<link rel="stylesheet" href="https://unpkg.com/video.js@6.12.0/dist/video-js.min.css">
<link rel="stylesheet" href="https://unpkg.com/silvermine-videojs-quality-selector@1.1.2/dist/css/quality-selector.css">
<link rel="stylesheet" href="https://unpkg.com/videojs-markers@1.0.1/dist/videojs.markers.min.css">
<link rel="stylesheet" href="https://unpkg.com/videojs-share@1.1.0/dist/videojs-share.css">
<script src="https://unpkg.com/video.js@6.12.0/dist/video.min.js"></script>
<script src="https://unpkg.com/videojs-hotkeys@0.2.22/build/videojs.hotkeys.min.js"></script>
<script src="https://unpkg.com/silvermine-videojs-quality-selector@1.1.2/dist/js/silvermine-videojs-quality-selector.min.js"></script>
<script src="https://unpkg.com/videojs-markers@1.0.1/dist/videojs-markers.min.js"></script>
<script src="https://unpkg.com/videojs-share@1.1.0/dist/videojs-share.min.js"></script>
<% if hlsvp %>
<script src="https://unpkg.com/@videojs/http-streaming@1.2.2/dist/videojs-http-streaming.min.js"></script>
<% end %>
<link rel="stylesheet" href="/css/video-js.min.css">
<link rel="stylesheet" href="/css/quality-selector.css">
<link rel="stylesheet" href="/css/videojs.markers.min.css">
<link rel="stylesheet" href="/css/videojs-share.css">
<script src="/js/video.min.js"></script>
<script src="/js/videojs.hotkeys.min.js"></script>
<script src="/js/silvermine-videojs-quality-selector.min.js"></script>
<script src="/js/videojs-markers.min.js"></script>
<script src="/js/videojs-share.min.js"></script>
<script src="/js/videojs-http-streaming.min.js"></script>
<script src="/js/videojs-contrib-quality-levels.min.js"></script>
+2 -2
View File
@@ -1,6 +1,6 @@
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% if video.responds_to?(:playlists) %>
<% if video.responds_to?(:playlists) && !video.playlists.empty? %>
<% params = "&list=#{video.playlists[0]}" %>
<% else %>
<% params = nil %>
@@ -8,7 +8,7 @@
<a style="width:100%;" href="/watch?v=<%= video.id %><%= params %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>
<img style="width:100%;" src="/vi/<%= video.id %>/mqdefault.jpg"/>
<% end %>
<p><%= video.title %></p>
</a>
+1 -1
View File
@@ -3,7 +3,7 @@
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control" method="post">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control?referer=<%= referer %>" method="post">
<fieldset>
<legend>Import</legend>
+6 -1
View File
@@ -6,6 +6,11 @@
<div class="pure-u-2-3">
<h3><%= playlist.title %></h3>
</div>
<div class="pure-u-1-3" style="text-align:right;">
<h3>
<a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a>
</h3>
</div>
</div>
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-4">
@@ -16,7 +21,7 @@
</div>
<div class="h-box">
<p><%= playlist.description %></p>
<p><%= playlist.description_html %></p>
</div>
<% videos.each_slice(4) do |slice| %>
+24 -7
View File
@@ -48,10 +48,19 @@ function update_value(element) {
</div>
<div class="pure-control-group">
<label for="comments">Pull comments from: </label>
<select name="comments" id="comments">
<% {"youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments == option %> selected <% end %>><%= option %></option>
<label for="comments_0">Default comments: </label>
<select name="comments_0" id="comments_0">
<% {"", "youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments[0] == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="comments_1">Fallback comments: </label>
<select name="comments_1" id="comments_1">
<% {"", "youtube", "reddit"}.each do |option| %>
<option <% if user.preferences.comments[1] == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
@@ -66,7 +75,7 @@ function update_value(element) {
</div>
<div class="pure-control-group">
<label for="captions_fallback">Fallback languages: </label>
<label for="captions_fallback">Fallback captions: </label>
<select class="pure-u-1-5" name="captions_1" id="captions_1">
<% CAPTION_LANGUAGES.each do |option| %>
<option <% if user.preferences.captions[1] == option %> selected <% end %>><%= option %></option>
@@ -80,7 +89,13 @@ function update_value(element) {
</select>
</div>
<div class="pure-control-group">
<label for="related_videos">Show related videos? </label>
<input name="related_videos" id="related_videos" type="checkbox" <% if user.preferences.related_videos %>checked<% end %>>
</div>
<legend>Visual preferences</legend>
<div class="pure-control-group">
<label for="dark_mode">Dark mode: </label>
<input name="dark_mode" id="dark_mode" type="checkbox" <% if user.preferences.dark_mode %>checked<% end %>>
@@ -92,6 +107,7 @@ function update_value(element) {
</div>
<legend>Subscription preferences</legend>
<div class="pure-control-group">
<label for="redirect_feed">Redirect homepage to feed: </label>
<input name="redirect_feed" id="redirect_feed" type="checkbox" <% if user.preferences.redirect_feed %>checked<% end %>>
@@ -127,12 +143,13 @@ function update_value(element) {
</div>
<legend>Data preferences</legend>
<div class="pure-control-group">
<a href="/clear_watch_history">Clear watch history</a>
<a href="/clear_watch_history?referer=<%= referer %>">Clear watch history</a>
</div>
<div class="pure-control-group">
<a href="/data_control">Import/Export data</a>
<a href="/data_control?referer=<%= referer %>">Import/Export data</a>
</div>
<div class="pure-control-group">
+3 -1
View File
@@ -1,5 +1,5 @@
<% content_for "header" do %>
<title><%= query.not_nil!.size > 30 ? query.not_nil![0,30].rstrip(".") + "..." : query.not_nil! %> - Invidious</title>
<title><%= search_query.not_nil!.size > 30 ? query.not_nil![0,30].rstrip(".") + "..." : query.not_nil! %> - Invidious</title>
<% end %>
<% videos.each_slice(4) do |slice| %>
@@ -18,6 +18,8 @@
</div>
<div class="pure-u-1 pure-u-md-3-5"></div>
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
<% if count >= 20 %>
<a href="/search?q=<%= query %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
</div>
<div class="pure-u-1-3" style="text-align:right;">
<h3>
<a href="/data_control">Import/Export</a>
<a href="/data_control?referer=<%= referer %>">Import/Export</a>
</h3>
</div>
</div>
+7
View File
@@ -16,6 +16,13 @@
</div>
<center><%= notifications.size %> unseen notifications</center>
<% if !notifications.empty? %>
<div class="h-box">
<hr>
</div>
<% end %>
<% notifications.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
+17 -12
View File
@@ -4,10 +4,11 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<%= yield_content "header" %>
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/pure-min.css">
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/grids-responsive-min.css">
<link rel="stylesheet" href="https://unpkg.com/ionicons@4.2.6/dist/css/ionicons.min.css">
<link rel="stylesheet" href="/css/pure-min.css">
<link rel="stylesheet" href="/css/grids-responsive-min.css">
<link rel="stylesheet" href="/css/ionicons.min.css">
<link rel="stylesheet" href="/css/default.css">
<% if env.get?("user") && env.get("user").as(User).preferences.dark_mode %>
<link rel="stylesheet" href="/css/darktheme.css">
@@ -18,8 +19,8 @@
<body>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-4-24"></div>
<div class="pure-u-1 pure-u-md-16-24">
<div class="pure-u-1 pure-u-md-2-24"></div>
<div class="pure-u-1 pure-u-md-20-24">
<div class="pure-g navbar h-box">
<div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
@@ -27,14 +28,14 @@
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<form class="pure-form" action="/search" method="get">
<fieldset>
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]? %>">
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]? || env.get? "search" %>">
</fieldset>
</form>
</div>
<div class="pure-u-1 pure-u-md-8-24 user-field">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a href="/toggle_theme?referer=<%= env.get("current_page") %>" class="pure-menu-heading">
<a href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<% preferences = env.get("user").as(User).preferences %>
<% if preferences.dark_mode %>
<i class="icon ion-ios-sunny"></i>
@@ -54,15 +55,15 @@
</a>
</div>
<div class="pure-u-1-4">
<a href="/preferences?referer=<%= env.get("current_page") %>" class="pure-menu-heading">
<a href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<div class="pure-u-1-4">
<a href="/signout?referer=<%= env.get("current_page") %>" class="pure-menu-heading">Sign out</a>
<a href="/signout?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">Sign out</a>
</div>
<% else %>
<a href="/login?referer=<%= env.get("current_page") %>" class="pure-menu-heading">Login</a>
<a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">Login</a>
<% end %>
</div>
</div>
@@ -72,6 +73,11 @@
Roth</a>.
Source available <a
href="https://github.com/omarroth/invidious">here</a>.
<p>Liberapay:
<a href="https://liberapay.com/omarroth">
https://liberapay.com/omarroth
</a>
</p>
<p>Patreon:
<a href="https://patreon.com/omarroth">
https://patreon.com/omarroth
@@ -81,8 +87,7 @@
<p>BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk</p>
</div>
</div>
<div class="pure-u-1 pure-u-md-4-24"></div>
</div>
<div class="pure-u-1 pure-u-md-2-24"></div>
</body>
</html>
+194 -158
View File
@@ -5,7 +5,7 @@
<meta property="og:site_name" content="Invidious">
<meta property="og:url" content="<%= host_url %>/watch?v=<%= video.id %>">
<meta property="og:title" content="<%= HTML.escape(video.title) %>">
<meta property="og:image" content="https://i.ytimg.com/vi/<%= video.id %>/hqdefault.jpg">
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg">
<meta property="og:description" content="<%= description %>">
<meta property="og:type" content="video.other">
<meta property="og:video:url" content="<%= host_url %>/embed/<%= video.id %>">
@@ -26,167 +26,14 @@
<title><%= HTML.escape(video.title) %> - Invidious</title>
<% end %>
<div class="h-box">
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<script>
function toggle(target) {
body = target.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function toggle_comments(target) {
body = target.parentNode.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function load_comments(target) {
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
var url =
"/api/v1/comments/<%= video.id %>?format=html&continuation=" + continuation;
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
body.innerHTML = xhr.response.contentHtml;
} else {
body.innerHTML = fallback;
}
}
};
xhr.ontimeout = function() {
body.innerHTML = fallback;
};
}
function get_reddit_comments() {
var url = "/api/v1/comments/<%= video.id %>?source=reddit";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
{title}
</h3>
<b>
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a>
</b>
</div>
<div>{contentHtml}</div>
<hr>`.supplant({
title: xhr.response.title,
permalink: xhr.response.permalink,
contentHtml: xhr.response.contentHtml
});
} else {
get_youtube_comments();
}
};
xhr.ontimeout = function() {
get_reddit_comments();
};
}
function get_youtube_comments() {
var url = "/api/v1/comments/<%= video.id %>?format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = `
<div>
<h3>
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
View {commentCount} comments
</h3>
</div>
<div>{contentHtml}</div>
<hr>`.supplant({
contentHtml: xhr.response.contentHtml,
commentCount: commaSeparateNumber(xhr.response.commentCount)
});
} else {
comments = document.getElementById("comments");
comments.innerHTML = "";
}
};
xhr.ontimeout = function() {
comments = document.getElementById("comments");
comments.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
get_youtube_comments();
};
}
function commaSeparateNumber(val){
while (/(\d+)(\d{3})/.test(val.toString())){
val = val.toString().replace(/(\d+)(\d{3})/, '$1'+','+'$2');
}
return val;
}
String.prototype.supplant = function(o) {
return this.replace(/{([^{}]*)}/g, function(a, b) {
var r = o[b];
return typeof r === "string" || typeof r === "number" ? r : a;
});
};
<% if preferences && preferences.comments == "reddit" %>
get_reddit_comments();
<% else %>
get_youtube_comments();
<% end %>
</script>
<div class="h-box">
<h1>
<%= HTML.escape(video.title) %>
<% if listen %>
<% if params[:listen] %>
<a href="/watch?<%= env.params.query %>">
<i class="icon ion-ios-videocam"></i>
</a>
@@ -208,7 +55,10 @@ get_youtube_comments();
<p><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p>
<p id="Genre">Genre: <%= video.genre %></p>
<p id="Genre">Genre: <a href="<%= video.genre_url %>"><%= video.genre %></a></p>
<% if !video.license.empty? %>
<p id="License">License: <%= video.license %></p>
<% end %>
<p id="FamilyFriendly">Family Friendly? <%= video.is_family_friendly %></p>
<p id="Wilson">Wilson Score: <%= video.wilson_score.round(4) %></p>
<p id="Rating">Rating: <%= rating.round(4) %> / 5</p>
@@ -266,13 +116,14 @@ get_youtube_comments();
</div>
</div>
<div class="pure-u-1 pure-u-md-1-5">
<% if !preferences || preferences && preferences.related_videos %>
<div class="h-box">
<% rvs.each do |rv| %>
<% if rv.has_key?("id") %>
<a href="/watch?v=<%= rv["id"] %>">
<% if preferences && preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="<%= rv["iurlmq"] %>">
<img style="width:100%;" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
<% end %>
<p style="width:100%"><%= rv["title"] %></p>
<p>
@@ -282,5 +133,190 @@ get_youtube_comments();
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
<script>
function toggle(target) {
body = target.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function toggle_comments(target) {
body = target.parentNode.parentNode.parentNode.children[1];
if (body.style.display === null || body.style.display === "") {
target.innerHTML = "[ + ]";
body.style.display = "none";
} else {
target.innerHTML = "[ - ]";
body.style.display = "";
}
}
function get_youtube_replies(target) {
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
var url =
"/api/v1/comments/<%= video.id %>?format=html&continuation=" + continuation;
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
body.innerHTML = xhr.response.contentHtml;
} else {
body.innerHTML = fallback;
}
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
body.innerHTML = fallback;
};
}
function get_reddit_comments() {
var url = "/api/v1/comments/<%= video.id %>?source=reddit&format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
comments.innerHTML = ' \
<div> \
<h3> \
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a> \
{title} \
</h3> \
<b> \
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a> \
</b> \
</div> \
<div>{contentHtml}</div> \
<hr>'.supplant({
title: xhr.response.title,
permalink: xhr.response.permalink,
contentHtml: xhr.response.contentHtml
});
} else {
<% if preferences && preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
get_reddit_comments();
};
}
function get_youtube_comments() {
var url = "/api/v1/comments/<%= video.id %>?format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
xhr.open("GET", url, true);
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4)
if (xhr.status == 200) {
comments = document.getElementById("comments");
if (xhr.response.commentCount > 0) {
comments.innerHTML = ' \
<div> \
<h3> \
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a> \
View {commentCount} comments \
</h3> \
</div> \
<div>{contentHtml}</div> \
<hr>'.supplant({
contentHtml: xhr.response.contentHtml,
commentCount: commaSeparateNumber(xhr.response.commentCount)
});
} else {
comments.innerHTML = "";
}
} else {
<% if preferences && preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
}
};
xhr.ontimeout = function() {
console.log("Pulling comments timed out.");
comments = document.getElementById("comments");
comments.innerHTML =
'<h3><center class="loading"><i class="icon ion-ios-refresh"></i></center></h3>';
get_youtube_comments();
};
}
function commaSeparateNumber(val){
while (/(\d+)(\d{3})/.test(val.toString())){
val = val.toString().replace(/(\d+)(\d{3})/, '$1'+','+'$2');
}
return val;
}
String.prototype.supplant = function(o) {
return this.replace(/{([^{}]*)}/g, function(a, b) {
var r = o[b];
return typeof r === "string" || typeof r === "number" ? r : a;
});
};
<% if preferences %>
<% if preferences.comments[0] == "youtube" %>
get_youtube_comments();
<% elsif preferences.comments[0] == "reddit" %>
get_reddit_comments();
<% else %>
<% if preferences.comments[1] == "youtube" %>
get_youtube_comments();
<% elsif preferences.comments[1] == "reddit" %>
get_reddit_comments();
<% else %>
comments = document.getElementById("comments");
comments.innerHTML = "";
<% end %>
<% end %>
<% else %>
get_youtube_comments();
<% end %>
</script>