Compare commits

..

55 Commits
0.4.0 ... 0.6.0

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
42 changed files with 2835 additions and 224 deletions

View File

@@ -173,7 +173,7 @@ div {
/* ProgressBar marker */
.vjs-marker {
background-color: rgba(255, 255, 255, 1);
background-color: rgba(255, 255, 255, 1);
}
/* Big "Play" Button */
@@ -196,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;
}

7
assets/css/grids-responsive-min.css vendored Normal file

File diff suppressed because one or more lines are too long

11
assets/css/ionicons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

11
assets/css/pure-min.css vendored Normal file

File diff suppressed because one or more lines are too long

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
assets/css/video-js.min.css vendored Normal file

File diff suppressed because one or more lines are too long

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
assets/css/videojs.markers.min.css vendored Normal file
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}

BIN
assets/fonts/ionicons.eot Normal file

Binary file not shown.

2090
assets/fonts/ionicons.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 305 KiB

BIN
assets/fonts/ionicons.ttf Normal file

Binary file not shown.

BIN
assets/fonts/ionicons.woff Normal file

Binary file not shown.

BIN
assets/fonts/ionicons.woff2 Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

7
assets/js/video.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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});

14
assets/js/videojs-http-streaming.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
assets/js/videojs-markers.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
assets/js/videojs-share.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
assets/js/videojs.hotkeys.min.js vendored Normal file
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})});

View File

@@ -7,4 +7,5 @@ db:
host: localhost
port: 5432
dbname: invidious
full_refresh: false
full_refresh: false
https_only: false

View File

@@ -21,6 +21,7 @@ CREATE TABLE public.videos
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 (

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

View File

@@ -106,6 +106,9 @@ spawn do
end
before_all do |env|
env.response.headers["X-XSS-Protection"] = "1; mode=block;"
env.response.headers["X-Content-Type-Options"] = "nosniff"
if env.request.cookies.has_key? "SID"
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
@@ -261,8 +264,7 @@ get "/watch" do |env|
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
end
# TODO: Find highest resolution thumbnail automatically
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
thumbnail = "/vi/#{video.id}/maxres.jpg"
if params[:raw]
url = fmt_stream[0]["url"]
@@ -361,8 +363,7 @@ get "/embed/:id" do |env|
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
end
# TODO: Find highest resolution thumbnail automatically
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
thumbnail = "/vi/#{video.id}/maxres.jpg"
if params[:raw]
url = fmt_stream[0]["url"]
@@ -429,32 +430,64 @@ get "/search" do |env|
page = env.params.query["page"]?.try &.to_i?
page ||= 1
sort = "relevance"
user = env.get? "user"
if user
user = user.as(User)
ucids = user.subscriptions
end
ucids ||= [] of String
channel = nil
date = ""
duration = ""
features = [] of String
sort = "relevance"
subscriptions = nil
operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) }
operators.each do |operator|
key, value = operator.split(":")
case key
when "sort"
sort = value
when "channel", "user"
channel = value
when "date"
date = value
when "duration"
duration = value
when "features"
when "feature", "features"
features = value.split(",")
when "sort"
sort = value
when "subscriptions"
subscriptions = value == "true"
end
end
search_query = (query.split(" ") - operators).join(" ")
search_params = build_search_params(sort: sort, date: date, content_type: "video",
duration: duration, features: features)
count, videos = search(search_query, page, search_params).as(Tuple)
if channel
count, videos = channel_search(search_query, page, channel)
elsif subscriptions
videos = PG_DB.query_all("SELECT id,title,published,updated,ucid,author FROM (
SELECT *,
to_tsvector(channel_videos.title) ||
to_tsvector(channel_videos.author)
as document
FROM channel_videos WHERE ucid IN (#{arg_array(ucids, 3)})
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", [search_query, (page - 1) * 20] + ucids, as: ChannelVideo)
count = videos.size
else
begin
search_params = produce_search_params(sort: sort, date: date, content_type: "video",
duration: duration, features: features)
rescue ex
error_message = ex.message
next templated "error"
end
count, videos = search(search_query, page, search_params).as(Tuple)
end
templated "search"
end
@@ -1400,25 +1433,31 @@ get "/feed/channel/:ucid" do |env|
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with? " - Topic"
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
url = produce_channel_videos_url(ucid, auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
page = 1
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")]))
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
if auto_generated
videos = extract_videos(nodeset)
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")]))
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
videos = extract_videos(nodeset, ucid)
break
end
else
videos = [] of SearchVideo
end
channel = get_channel(ucid, client, PG_DB, pull_all_videos: false)
@@ -1462,7 +1501,7 @@ get "/feed/channel/:ucid" do |env|
xml.element("media:group") do
xml.element("media:title") { xml.text video.title }
xml.element("media:thumbnail", url: "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg",
xml.element("media:thumbnail", url: "/vi/#{video.id}/mqdefault.jpg",
width: "320", height: "180")
xml.element("media:description") { xml.text video.description }
end
@@ -1567,7 +1606,7 @@ get "/feed/private" do |env|
xml.element("media:group") do
xml.element("media:title") { xml.text video.title }
xml.element("media:thumbnail", url: "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg",
xml.element("media:thumbnail", url: "/vi/#{video.id}/mqdefault.jpg",
width: "320", height: "180")
end
end
@@ -1579,6 +1618,38 @@ get "/feed/private" do |env|
feed
end
get "/feed/playlist/:plid" do |env|
plid = env.params.url["plid"]
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"]?)
path = env.request.path
client = make_client(YT_URL)
response = client.get("/feeds/videos.xml?playlist_id=#{plid}")
document = XML.parse(response.body)
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
node.attributes.each do |attribute|
case attribute.name
when "url"
node["url"] = "#{host_url}#{URI.parse(node["url"]).full_path}"
when "href"
node["href"] = "#{host_url}#{URI.parse(node["href"]).full_path}"
end
end
end
document = document.to_xml(options: XML::SaveOptions::NO_DECL)
document.scan(/<uri>(?<url>[^<]+)<\/uri>/).each do |match|
content = "#{host_url}#{URI.parse(match["url"]).full_path}"
document = document.gsub(match[0], "<uri>#{content}</uri>")
end
env.response.content_type = "text/xml"
document
end
# Channels
# YouTube appears to let users set a "brand" URL that
@@ -1603,6 +1674,11 @@ get "/user/:user" do |env|
env.redirect "/channel/#{user}"
end
get "/user/:user/videos" do |env|
user = env.params.url["user"]
env.redirect "/channel/#{user}/videos"
end
get "/channel/:ucid" do |env|
user = env.get? "user"
if user
@@ -1647,25 +1723,37 @@ get "/channel/:ucid" do |env|
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with? " - Topic"
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated)
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")]))
if auto_generated
videos = extract_videos(nodeset)
if !auto_generated
if author.includes? " "
env.set "search", "channel:#{ucid} "
else
videos = extract_videos(nodeset, ucid)
env.set "search", "channel:#{author.downcase} "
end
end
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
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")]))
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
break
end
else
videos = [] of SearchVideo
end
templated "channel"
@@ -1785,7 +1873,7 @@ get "/api/v1/comments/:id" do |env|
if source == "youtube"
client = make_client(YT_URL)
headers = HTTP::Headers.new
html = client.get("/watch?v=#{id}&disable_polymer=1")
html = client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&disable_polymer=1")
headers["cookie"] = html.cookies.add_request_headers(headers)["cookie"]
headers["content-type"] = "application/x-www-form-urlencoded"
@@ -1800,6 +1888,7 @@ get "/api/v1/comments/:id" do |env|
body = html.body
session_token = body.match(/'XSRF_TOKEN': "(?<session_token>[A-Za-z0-9\_\-\=]+)"/).not_nil!["session_token"]
ctoken = body.match(/'COMMENTS_TOKEN': "(?<ctoken>[^"]+)"/)
if !ctoken
env.response.content_type = "application/json"
@@ -1873,9 +1962,13 @@ get "/api/v1/comments/:id" do |env|
node_comment = node["commentRenderer"]
end
contentHtml = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff')
contentHtml ||= node_comment["contentText"]["runs"].as_a.map do |run|
text = run["text"].as_s
content_html = node_comment["contentText"]["simpleText"]?.try &.as_s.rchop('\ufeff')
if content_html
content_html = HTML.escape(content_html)
end
content_html ||= node_comment["contentText"]["runs"].as_a.map do |run|
text = HTML.escape(run["text"].as_s)
if run["text"] == "\n"
text = "<br>"
@@ -1893,7 +1986,14 @@ get "/api/v1/comments/:id" do |env|
url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s
if url
url = URI.parse(url)
url = HTTP::Params.parse(url.query.not_nil!)["q"]
if {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host
if url.path == "/redirect"
url = HTTP::Params.parse(url.query.not_nil!)["q"]
else
url = url.full_path
end
end
else
url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s
end
@@ -1904,7 +2004,7 @@ get "/api/v1/comments/:id" do |env|
text
end.join.rchop('\ufeff')
contentHtml, content = html_to_content(contentHtml)
content_html, content = html_to_content(content_html)
author = node_comment["authorText"]?.try &.["simpleText"]
author ||= ""
@@ -1933,8 +2033,9 @@ get "/api/v1/comments/:id" do |env|
published = decode_date(node_comment["publishedTimeText"]["runs"][0]["text"].as_s.rchop(" (edited)"))
json.field "content", content
json.field "contentHtml", contentHtml
json.field "contentHtml", content_html
json.field "published", published.epoch
json.field "publishedText", "#{recode_date(published)} ago"
json.field "likeCount", node_comment["likeCount"]
json.field "commentId", node_comment["commentId"]
@@ -1998,19 +2099,28 @@ get "/api/v1/comments/:id" do |env|
content_html = fill_links(content_html, "https", "www.reddit.com")
content_html = replace_links(content_html)
rescue ex
comments = nil
reddit_thread = nil
content_html = ""
end
if !reddit_thread
if !reddit_thread || !comments
halt env, status_code: 404
end
env.response.content_type = "application/json"
next {"title" => reddit_thread.title,
"permalink" => reddit_thread.permalink,
"contentHtml" => content_html,
}.to_json
if format == "json"
reddit_thread = JSON.parse(reddit_thread.to_json).as_h
reddit_thread["comments"] = JSON.parse(comments.to_json)
next reddit_thread.to_json
else
next {
"title" => reddit_thread.title,
"permalink" => reddit_thread.permalink,
"contentHtml" => content_html,
}.to_json
end
end
end
@@ -2044,6 +2154,7 @@ get "/api/v1/videos/:id" do |env|
json.field "description", description
json.field "descriptionHtml", video.description
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "keywords" do
json.array do
video.info["keywords"].split(",").each { |keyword| json.string keyword }
@@ -2221,6 +2332,7 @@ get "/api/v1/trending" do |env|
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "description", video.description
json.field "descriptionHtml", video.description_html
end
@@ -2249,6 +2361,7 @@ get "/api/v1/top" do |env|
json.field "author", video.author
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
description = video.description.gsub("<br>", "\n")
description = description.gsub("<br/>", "\n")
@@ -2297,25 +2410,31 @@ get "/api/v1/channels/:ucid" do |env|
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with? " - Topic"
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
url = produce_channel_videos_url(ucid, 1, auto_generated)
response = client.get(url)
page = 1
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")]))
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
if auto_generated
videos = extract_videos(nodeset)
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")]))
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
videos = extract_videos(nodeset, ucid)
break
end
else
videos = [] of SearchVideo
end
channel_html = client.get("/channel/#{ucid}/about?disable_polymer=1").body
@@ -2426,6 +2545,7 @@ get "/api/v1/channels/:ucid" do |env|
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
@@ -2474,25 +2594,29 @@ get "/api/v1/channels/:ucid/videos" do |env|
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with? " - Topic"
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
url = produce_channel_videos_url(ucid, auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
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")]))
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")]))
if auto_generated
videos = extract_videos(nodeset)
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
videos = extract_videos(nodeset, ucid)
break
end
else
videos = [] of SearchVideo
end
result = JSON.build do |json|
@@ -2521,6 +2645,7 @@ get "/api/v1/channels/:ucid/videos" do |env|
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
@@ -2556,7 +2681,7 @@ get "/api/v1/search" do |env|
env.response.content_type = "application/json"
begin
search_params = build_search_params(sort_by, date, content_type, duration, features)
search_params = produce_search_params(sort_by, date, content_type, duration, features)
rescue ex
next JSON.build do |json|
json.object do
@@ -2586,6 +2711,7 @@ get "/api/v1/search" do |env|
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
@@ -2898,6 +3024,113 @@ get "/videoplayback" do |env|
end
end
get "/ggpht*" do |env|
end
get "/ggpht/*" do |env|
host = "https://yt3.ggpht.com"
client = make_client(URI.parse(host))
url = env.request.path.lchop("/ggpht")
headers = env.request.headers
headers.delete("Host")
headers.delete("Cookie")
headers.delete("User-Agent")
headers.delete("Referer")
client.get(url, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
env.response.headers[key] = value
end
if response.status_code == 304
break
end
chunk_size = 4096
size = 1
if response.headers.includes_word?("Content-Encoding", "gzip")
Gzip::Writer.open(env.response) do |deflate|
until size == 0
size = IO.copy(response.body_io, deflate)
env.response.flush
end
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Flate::Writer.open(env.response) do |deflate|
until size == 0
size = IO.copy(response.body_io, deflate)
env.response.flush
end
end
else
until size == 0
size = IO.copy(response.body_io, env.response, chunk_size)
env.response.flush
end
end
end
end
get "/vi/:id/:name" do |env|
id = env.params.url["id"]
name = env.params.url["name"]
host = "https://i.ytimg.com"
client = make_client(URI.parse(host))
if name == "maxres.jpg"
VIDEO_THUMBNAILS.each do |thumb|
if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200
name = thumb[:url] + ".jpg"
break
end
end
end
url = "/vi/#{id}/#{name}"
headers = env.request.headers
headers.delete("Host")
headers.delete("Cookie")
headers.delete("User-Agent")
headers.delete("Referer")
client.get(url, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
env.response.headers[key] = value
end
if response.status_code == 304
break
end
chunk_size = 4096
size = 1
if response.headers.includes_word?("Content-Encoding", "gzip")
Gzip::Writer.open(env.response) do |deflate|
until size == 0
size = IO.copy(response.body_io, deflate)
env.response.flush
end
end
elsif response.headers.includes_word?("Content-Encoding", "deflate")
Flate::Writer.open(env.response) do |deflate|
until size == 0
size = IO.copy(response.body_io, deflate)
env.response.flush
end
end
else
until size == 0
size = IO.copy(response.body_io, env.response, chunk_size)
env.response.flush
end
end
end
end
error 404 do |env|
error_message = "404 Page not found"
templated "error"
@@ -2933,6 +3166,7 @@ public_folder "assets"
Kemal.config.powered_by_header = false
add_handler FilteredCompressHandler.new
add_handler DenyFrame.new
add_context_storage_type(User)
Kemal.run

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,61 +76,52 @@ 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_channel_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)
@@ -147,10 +145,16 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil)
switch = "\x00"
end
meta = "\x12\x06videos #{switch}\x30\x02\x38\x01\x60\x01\x6a\x00\x7a"
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 += "\xb8\x01\x00"
meta = Base64.urlsafe_encode(meta)
meta = URI.escape(meta)

View File

@@ -100,10 +100,12 @@ def template_youtube_comments(comments)
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>
@@ -196,15 +198,13 @@ def replace_links(html)
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!)
anchor["href"] = params["q"]?
else
anchor["href"] = url.full_path
end
elsif url.host == "youtu.be"
anchor["href"] = "/watch?v=#{url.path.try &.lchop("/")}&#{url.query}"
elsif url.to_s == "#"
begin
length_seconds = decode_length_seconds(anchor.content)

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}
@@ -173,7 +184,12 @@ def html_to_content(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_html, description

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

View File

@@ -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

View File

@@ -88,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_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}"
@@ -119,13 +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_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description, description_html = html_to_content(description_html)
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

View File

@@ -12,7 +12,44 @@ 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}
@@ -30,8 +67,8 @@ def search(query, page = 1, search_params = build_search_params(content_type: "v
return {nodeset.size, videos}
end
def build_search_params(sort : String = "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
when "relevance"
@@ -114,7 +151,7 @@ def build_search_params(sort : String = "relevance", date : String = "", content
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
@@ -124,3 +161,40 @@ def build_search_params(sort : String = "relevance", date : String = "", content
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

View File

@@ -1,9 +1,10 @@
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(/"signature",(?<name>[a-zA-Z0-9]{2})\(/).not_nil!["name"]
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]

View File

@@ -140,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

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,9 +417,10 @@ class Video
allowed_regions: Array(String),
is_family_friendly: Bool,
genre: String,
genre_url: {
genre_url: String,
license: {
type: String,
default: "/",
default: "",
},
})
end
@@ -370,8 +443,8 @@ 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
@@ -380,7 +453,7 @@ def get_video(id, db, refresh = true)
db.exec("UPDATE videos SET (info,updated,title,views,likes,dislikes,wilson_score,\
published,description,language,author,ucid, allowed_regions, is_family_friendly,\
genre, genre_url)\
genre, genre_url, license)\
= (#{args}) WHERE id = $1", video_array)
rescue ex
db.exec("DELETE FROM videos * WHERE id = $1", id)
@@ -501,8 +574,15 @@ def fetch_video(id)
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, genre_url)
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license)
return video
end

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 == 30 %>
<% if videos.size == 60 %>
<a href="/channel/<%= ucid %>?page=<%= page + 1 %>">Next page</a>
<% end %>
</div>

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

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| %>

View File

@@ -18,7 +18,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 count == 20 %>
<% if count >= 20 %>
<a href="/search?q=<%= query %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>

View File

@@ -6,9 +6,9 @@
<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">
@@ -19,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>
@@ -28,7 +28,7 @@
<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>
@@ -87,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>

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,7 +26,7 @@
<title><%= HTML.escape(video.title) %> - Invidious</title>
<% end %>
<div class="h-box">
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
@@ -56,6 +56,9 @@
<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: <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>
@@ -113,14 +116,14 @@
</div>
</div>
<div class="pure-u-1 pure-u-md-1-5">
<% if preferences && preferences.related_videos %>
<% 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>
@@ -191,7 +194,7 @@ function get_youtube_replies(target) {
}
function get_reddit_comments() {
var url = "/api/v1/comments/<%= video.id %>?source=reddit";
var url = "/api/v1/comments/<%= video.id %>?source=reddit&format=html";
var xhr = new XMLHttpRequest();
xhr.responseType = "json";
xhr.timeout = 20000;
@@ -202,19 +205,18 @@ function get_reddit_comments() {
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({
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
@@ -249,15 +251,15 @@ function get_youtube_comments() {
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({
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)
});