From b25013c4a25a00c45ff9753220721ad75f78c26e Mon Sep 17 00:00:00 2001 From: Leon Klingele Date: Tue, 24 Sep 2019 00:19:18 +0200 Subject: [PATCH 01/39] docker,travis: fail build on any warning --- .travis.yml | 2 +- docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f5918bb1..6e39f75c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ jobs: - shards update - shards install install: - - crystal build --error-on-warnings src/invidious.cr + - crystal build --warnings all --error-on-warnings src/invidious.cr script: - crystal tool format --check - crystal spec diff --git a/docker/Dockerfile b/docker/Dockerfile index 224c0bf2..5cf5997d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,7 +9,7 @@ COPY ./src/ ./src/ # TODO: .git folder is required for building – this is destructive. # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. COPY ./.git/ ./.git/ -RUN crystal build --static --release \ +RUN crystal build --static --release --warnings all --error-on-warnings \ # TODO: Remove next line, see https://github.com/crystal-lang/crystal/issues/7946 -Dmusl \ ./src/invidious.cr From 7378a84c9666cd6da211645122e3effa9696093b Mon Sep 17 00:00:00 2001 From: Leon Klingele Date: Tue, 24 Sep 2019 00:20:10 +0200 Subject: [PATCH 02/39] travis: unshallowly clone Git repo This fixes a compilation error if too many commits were made after the most recent tag: fatal: No names found, cannot describe anything. In src/invidious.cr:60:19 60 | CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }} Error: expanding macro See https://travis-ci.org/leonklingele/invidious/jobs/588672881#L275-L290. --- .travis.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.travis.yml b/.travis.yml index f5918bb1..7e61b9ae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,9 @@ dist: bionic jobs: include: - stage: build + # TODO: Shallowly clone again once the .git folder is no longer required for building + git: + depth: false language: crystal crystal: latest before_install: @@ -15,6 +18,9 @@ jobs: - crystal spec - stage: build_docker + # TODO: Shallowly clone again once the .git folder is no longer required for building + git: + depth: false language: minimal services: - docker From e390405d0c13060bc55dc2b44ba0ed9ec567cb0b Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Tue, 24 Sep 2019 20:47:49 -0400 Subject: [PATCH 03/39] Update privacy policy --- src/invidious/views/privacy.ecr | 1 - 1 file changed, 1 deletion(-) diff --git a/src/invidious/views/privacy.ecr b/src/invidious/views/privacy.ecr index 5d66a731..643f880b 100644 --- a/src/invidious/views/privacy.ecr +++ b/src/invidious/views/privacy.ecr @@ -20,7 +20,6 @@
  • a randomly generated token for providing an RSS feed of a user's subscriptions
  • a list of video IDs identifying watched videos
  • -

    The above list reflects this code.

    Users can clear their watch history using the clear watch history page.

    If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.

    From 553d52a45e018bbfc4948e8a61411d9449cbbab4 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Thu, 26 Sep 2019 17:11:10 -0400 Subject: [PATCH 04/39] Update silvermine quality selector --- assets/js/silvermine-videojs-quality-selector.min.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 22387d04..3ad2c2ab 100644 --- a/assets/js/silvermine-videojs-quality-selector.min.js +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -1,3 +1,4 @@ -/*! @silvermine/videojs-quality-selector 2019-09-21 v1.2.2-4-gc134430-dirty */ +/*! @silvermine/videojs-quality-selector 2019-09-26 v1.2.2-4-gc134430-dirty */ -!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n":">",'"':""","'":"'","`":"`"},B=h.invert(D);h.escape=W(D),h.unescape=W(B),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function z(n){return"\\"+K[n]}var Y=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},V=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||Y).source,(n.interpolate||Y).source,(n.evaluate||Y).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(V,z),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function J(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),J(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],J(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return J(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected"}},{}],6:[function(n,e,t){"use strict";var o=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),c=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(u){u.on(r.QUALITY_REQUESTED,function(n,e){var t=u.currentSources(),r=u.currentTime(),i=(u.playbackRate(),u.paused());o.each(t,function(n){n.selected=!1}),o.findWhere(t,{src:e.src}).selected=!0,u._qualitySelectorSafeSeek&&u._qualitySelectorSafeSeek.onQualitySelectionChange(),u.src(t),u.ready(function(){u._qualitySelectorSafeSeek&&!u._qualitySelectorSafeSeek.hasFinished()||(u._qualitySelectorSafeSeek=new c(u,r),u.playbackRate=playbackRate),i||u.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); \ No newline at end of file +!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n":">",'"':""","'":"'","`":"`"},B=h.invert(D);h.escape=W(D),h.unescape=W(B),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function z(n){return"\\"+K[n]}var Y=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},V=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||Y).source,(n.interpolate||Y).source,(n.evaluate||Y).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(V,z),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function J(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),J(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],J(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return J(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected"}},{}],6:[function(n,e,t){"use strict";var c=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),a=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(o){o.on(r.QUALITY_REQUESTED,function(n,e){var t=o.currentSources(),r=o.currentTime(),i=o.playbackRate(),u=o.paused();c.each(t,function(n){n.selected=!1}),c.findWhere(t,{src:e.src}).selected=!0,o._qualitySelectorSafeSeek&&o._qualitySelectorSafeSeek.onQualitySelectionChange(),o.src(t),o.ready(function(){o._qualitySelectorSafeSeek&&!o._qualitySelectorSafeSeek.hasFinished()||(o._qualitySelectorSafeSeek=new a(o,r),o.playbackRate(i)),u||o.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); +//# sourceMappingURL=silvermine-videojs-quality-selector.min.js.map \ No newline at end of file From 4aa1180fce1eea8eea262be276660f5a8ca4b324 Mon Sep 17 00:00:00 2001 From: girst Date: Sat, 7 Sep 2019 17:45:37 +0200 Subject: [PATCH 05/39] Forward parameters given in ¶ms= from Atom feeds Any parameters given in ¶ms=... are appended to /watch URLs. This allows e.g. passing &raw=1&listen=1 to a playlist of music and use an rss reader like newsboat as a media player, like so: https://invidio.us/feed/playlist/XXX?params=%26raw%3D1%listen%3D1 All three feeds--channels, playlists, subscriptions--are supported. --- src/invidious.cr | 18 ++++++++++++------ src/invidious/channels.cr | 8 +++++--- src/invidious/search.cr | 14 ++++++++------ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 8f7e1a63..42a2b23a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -2630,6 +2630,8 @@ get "/feed/channel/:ucid" do |env| ucid = env.params.url["ucid"] + params = HTTP::Params.parse(env.params.query["params"]? || "") + begin channel = get_about_info(ucid, locale) rescue ex @@ -2690,7 +2692,7 @@ get "/feed/channel/:ucid" do |env| end videos.each do |video| - video.to_xml(host_url, channel.auto_generated, xml) + video.to_xml(host_url, channel.auto_generated, params, xml) end end end @@ -2721,6 +2723,8 @@ get "/feed/private" do |env| page = env.params.query["page"]?.try &.to_i? page ||= 1 + params = HTTP::Params.parse(env.params.query["params"]? || "") + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) host_url = make_host_url(config, Kemal.config) @@ -2734,7 +2738,7 @@ get "/feed/private" do |env| xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } (notifications + videos).each do |video| - video.to_xml(locale, host_url, xml) + video.to_xml(locale, host_url, params, xml) end end end @@ -2747,6 +2751,8 @@ get "/feed/playlist/:plid" do |env| plid = env.params.url["plid"] + params = HTTP::Params.parse(env.params.query["params"]? || "") + host_url = make_host_url(config, Kemal.config) path = env.request.path @@ -2757,10 +2763,10 @@ get "/feed/playlist/:plid" do |env| 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}" + when "url", "href" + full_path = URI.parse(node[attribute.name]).full_path + query_string_opt = full_path.starts_with?("/watch?v=") ? "&#{params}" : "" + node[attribute.name] = "#{host_url}#{full_path}#{query_string_opt}" end end end diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 00eac902..85502505 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -41,13 +41,15 @@ struct ChannelVideo end end - def to_xml(locale, host_url, xml : XML::Builder) + def to_xml(locale, host_url, query_params, xml : XML::Builder) + query_params["v"] = self.id + xml.element("entry") do xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("yt:videoId") { xml.text self.id } xml.element("yt:channelId") { xml.text self.ucid } xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") + xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}") xml.element("author") do xml.element("name") { xml.text self.author } @@ -56,7 +58,7 @@ struct ChannelVideo xml.element("content", type: "xhtml") do xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do + xml.element("a", href: "#{host_url}/watch?#{query_params}") do xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") end end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index a55bb216..56035160 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,11 +1,13 @@ struct SearchVideo - def to_xml(host_url, auto_generated, xml : XML::Builder) + def to_xml(host_url, auto_generated, query_params, xml : XML::Builder) + query_params["v"] = self.id + xml.element("entry") do xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("yt:videoId") { xml.text self.id } xml.element("yt:channelId") { xml.text self.ucid } xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") + xml.element("link", rel: "alternate", href: "#{host_url}/watch?#{query_params}") xml.element("author") do if auto_generated @@ -19,7 +21,7 @@ struct SearchVideo xml.element("content", type: "xhtml") do xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do + xml.element("a", href: "#{host_url}/watch?#{query_params}") do xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") end end @@ -40,12 +42,12 @@ struct SearchVideo end end - def to_xml(host_url, auto_generated, xml : XML::Builder | Nil = nil) + def to_xml(host_url, auto_generated, query_params, xml : XML::Builder | Nil = nil) if xml - to_xml(host_url, auto_generated, xml) + to_xml(host_url, auto_generated, query_params, xml) else XML.build do |json| - to_xml(host_url, auto_generated, xml) + to_xml(host_url, auto_generated, query_params, xml) end end end From da07f99d3d81242064722d05eab35b028568fe1f Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 30 Sep 2019 15:35:38 -0400 Subject: [PATCH 06/39] Bump supported Crystal version --- shard.yml | 2 +- src/invidious/helpers/handlers.cr | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/shard.yml b/shard.yml index 3980201d..69c1610f 100644 --- a/shard.yml +++ b/shard.yml @@ -19,6 +19,6 @@ dependencies: github: kemalcr/kemal version: ~> 0.26.0 -crystal: 0.31.0 +crystal: 0.31.1 license: AGPLv3 diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 949eb335..f2240691 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -237,16 +237,3 @@ class HTTP::Client response end end - -struct Crystal::ThreadLocalValue(T) - @values = Hash(Thread, T).new - - def get(&block : -> T) - th = Thread.current - if !@values[th]? - @values[th] = yield - else - @values[th] - end - end -end From f5d8a952f2ea5b95dfd32769152025dc7e420dfe Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Wed, 2 Oct 2019 16:28:25 +0800 Subject: [PATCH 07/39] Add zh-TW translations. --- locales/zh-TW.json | 370 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 locales/zh-TW.json diff --git a/locales/zh-TW.json b/locales/zh-TW.json new file mode 100644 index 00000000..28f04061 --- /dev/null +++ b/locales/zh-TW.json @@ -0,0 +1,370 @@ +{ + "`x` subscribers": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 個訂閱者", + "": "`x` 個訂閱者" + }, + "`x` videos": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 部影片", + "": "`x` 部影片" + }, + "LIVE": "直播", + "Shared `x` ago": "`x` 前分享", + "Unsubscribe": "取消訂閱", + "Subscribe": "訂閱", + "View channel on YouTube": "在 YouTube 上檢視頻道", + "View playlist on YouTube": "在 YouTube 上檢視播放清單", + "newest": "最新", + "oldest": "最舊", + "popular": "流行", + "last": "上一個", + "Next page": "下一頁", + "Previous page": "上一頁", + "Clear watch history?": "清除觀看歷史?", + "New password": "新密碼", + "New passwords must match": "新密碼必須符合", + "Cannot change password for Google accounts": "無法變更 Google 帳號的密碼", + "Authorize token?": "授權 token?", + "Authorize token for `x`?": "`x` 的授權 token?", + "Yes": "是", + "No": "否", + "Import and Export Data": "匯入與匯出資料", + "Import": "匯入", + "Import Invidious data": "匯入 Invidious 資料", + "Import YouTube subscriptions": "匯入 YouTube 訂閱", + "Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)", + "Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)", + "Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)", + "Export": "匯出", + "Export subscriptions as OPML": "將訂閱匯出為 OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML(供 NewPipe 與 FreeTube 使用)", + "Export data as JSON": "將 JSON 匯出為 JSON", + "Delete account?": "刪除帳號?", + "History": "歷史", + "An alternative front-end to YouTube": "一個 YouTube 的替代前端", + "JavaScript license information": "JavaScript 授權條款資訊", + "source": "來源", + "Log in": "登入", + "Log in/register": "登入/註冊", + "Log in with Google": "使用 Google 登入", + "User ID": "使用者 ID", + "Password": "密碼", + "Time (h:mm:ss):": "時間 (h:mm:ss):", + "Text CAPTCHA": "文字 CAPTCHA", + "Image CAPTCHA": "圖片 CAPTCHA", + "Sign In": "登入", + "Register": "註冊", + "E-mail": "電子郵件", + "Google verification code": "Google 驗證碼", + "Preferences": "偏好設定", + "Player preferences": "播放器偏好設定", + "Always loop: ": "總是循環播放:", + "Autoplay: ": "自動播放:", + "Play next by default: ": "預設播放下一部:", + "Autoplay next video: ": "自動播放下一部影片:", + "Listen by default: ": "預設聆聽:", + "Proxy videos: ": "代理影片:", + "Default speed: ": "預設速度:", + "Preferred video quality: ": "偏好的影片畫質:", + "Player volume: ": "播放器音量:", + "Default comments: ": "預設留言:", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "預設字幕:", + "Fallback captions: ": "汰退字幕:", + "Show related videos: ": "顯示相關的影片:", + "Show annotations by default: ": "預設顯示註釋:", + "Visual preferences": "視覺偏好設定", + "Player style: ": "", + "Dark mode: ": "深色模式:", + "Theme: ": "", + "dark": "", + "light": "", + "Thin mode: ": "精簡模式:", + "Subscription preferences": "訂閱偏好設定", + "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋?", + "Redirect homepage to feed: ": "重新導向首頁至 feed:", + "Number of videos shown in feed: ": "顯示在 feed 中的影片數量:", + "Sort videos by: ": "以此種方式排序影片:", + "published": "已發佈", + "published - reverse": "已發佈 - 反向", + "alphabetically": "字母", + "alphabetically - reverse": "字母 - 反向", + "channel name": "頻道名稱", + "channel name - reverse": "頻道名稱 - 反向", + "Only show latest video from channel: ": "僅顯示從頻道而來的最新影片:", + "Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片:", + "Only show unwatched: ": "僅顯示未觀看的:", + "Only show notifications (if there are any): ": "僅顯示通知(如果有的話):", + "Enable web notifications": "啟用網路通知", + "`x` uploaded a video": "`x` 上傳了一部影片", + "`x` is live": "`x` 正在直播", + "Data preferences": "資料偏好設定", + "Clear watch history": "清除觀看歷史", + "Import/export data": "匯入/匯出資料", + "Change password": "變更密碼", + "Manage subscriptions": "管理訂閱", + "Manage tokens": "管理 tokens", + "Watch history": "觀看歷史", + "Delete account": "刪除帳號", + "Administrator preferences": "管理員偏好設定", + "Default homepage: ": "預設首頁:", + "Feed menu: ": "Feed 選單:", + "Top enabled: ": "頂部啟用:", + "CAPTCHA enabled: ": "CAPTCHA 啟用:", + "Login enabled: ": "啟用登入?", + "Registration enabled: ": "啟用註冊?", + "Report statistics: ": "回報統計?", + "Save preferences": "儲存偏好設定", + "Subscription manager": "訂閱管理員", + "Token manager": "Token 管理員", + "Token": "Token", + "`x` subscriptions": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 個訂閱", + "": "`x` 個訂閱" + }, + "`x` tokens": { + "([^0-9]|^)1([^,0-9]|$)": "`x` token", + "": "`x` tokens" + }, + "Import/export": "匯入/匯出", + "unsubscribe": "取消訂閱", + "revoke": "撤銷", + "Subscriptions": "訂閱", + "`x` unseen notifications": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 個未讀的通知", + "": "`x` 個未讀的通知" + }, + "search": "搜尋", + "Log out": "登出", + "Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。", + "Source available here.": "原始碼在此提供。", + "View JavaScript license information.": "檢視 JavaScript 授權條款資訊。", + "View privacy policy.": "檢視隱私權政策。", + "Trending": "趨勢", + "Unlisted": "未列出", + "Watch on YouTube": "在 YouTube 上觀看", + "Hide annotations": "隱藏註釋", + "Show annotations": "顯示註釋", + "Genre: ": "風格:", + "License: ": "授權條款:", + "Family friendly? ": "家庭友好?", + "Wilson score: ": "威爾遜分數:", + "Engagement: ": "參與度:", + "Whitelisted regions: ": "白名單區域:", + "Blacklisted regions: ": "黑名單區域:", + "Shared `x`": "`x` 發佈", + "`x` views": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 次檢視", + "": "`x` 次檢視" + }, + "Premieres in `x`": "首映於 `x`", + "Premieres `x`": "首映於 `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "嗨!看來您將 JavaScript 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。", + "View YouTube comments": "檢視 YouTube 留言", + "View more comments on Reddit": "在 Reddit 上檢視更多留言", + "View `x` comments": "檢視 `x` 則留言", + "View Reddit comments": "檢視 Reddit 留言", + "Hide replies": "隱藏回覆", + "Show replies": "顯示回覆", + "Incorrect password": "不正確的密碼", + "Quota exceeded, try again in a few hours": "超過限額,請在幾個小時後再試一次", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "無法登入,請確定雙因素驗證(驗證器或簡訊)已開啟。", + "Invalid TFA code": "無效的 TFA 代碼", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "登入失敗。這可能是因為您的帳號未開啟雙因素驗證的關係。", + "Wrong answer": "錯誤的答案", + "Erroneous CAPTCHA": "錯誤的 CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA 為必填欄位", + "User ID is a required field": "使用者 ID 為必填欄位", + "Password is a required field": "密碼為必填欄位", + "Wrong username or password": "錯誤的使用者名稱或密碼", + "Please sign in using 'Log in with Google'": "請使用「以 Google 登入」來登入", + "Password cannot be empty": "密碼不能為空", + "Password cannot be longer than 55 characters": "密碼不能長於55個字元", + "Please log in": "請登入", + "Invidious Private Feed for `x`": "`x` 的 Invidious 私密 feed", + "channel:`x`": "頻道:`x`", + "Deleted or invalid channel": "已刪除或無效的頻道", + "This channel does not exist.": "此頻道不存在。", + "Could not get channel info.": "無法取得頻道資訊。", + "Could not fetch comments": "無法擷取留言", + "View `x` replies": { + "([^0-9]|^)1([^,0-9]|$)": "檢視 `x` 則回覆", + "": "檢視 `x` 則回覆" + }, + "`x` ago": "`x` 以前", + "Load more": "載入更多", + "`x` points": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 點", + "": "`x` 點" + }, + "Could not create mix.": "無法建立混合。", + "Empty playlist": "空的播放清單", + "Not a playlist.": "不是播放清單。", + "Playlist does not exist.": "播放清單不存在。", + "Could not pull trending pages.": "無法拉取趨勢頁面。", + "Hidden field \"challenge\" is a required field": "隱藏的欄位 \"challenge\" 是必填欄位", + "Hidden field \"token\" is a required field": "隱藏的欄位 \"token\" 是必填欄位", + "Erroneous challenge": "錯誤的 challenge", + "Erroneous token": "錯誤的 token", + "No such user": "無此使用者", + "Token is expired, please try again": "Token 已過期,請再試一次", + "English": "英文", + "English (auto-generated)": "英文(自動生成)", + "Afrikaans": "南非語", + "Albanian": "阿爾巴尼亞語", + "Amharic": "阿姆哈拉語", + "Arabic": "阿拉伯語", + "Armenian": "亞美尼亞語", + "Azerbaijani": "亞塞拜然語", + "Bangla": "孟加拉文", + "Basque": "巴斯克語", + "Belarusian": "白俄羅斯語", + "Bosnian": "波士尼亞語", + "Bulgarian": "保加利亞語", + "Burmese": "緬甸語", + "Catalan": "加泰隆尼亞語", + "Cebuano": "宿霧語", + "Chinese (Simplified)": "簡體中文", + "Chinese (Traditional)": "繁體中文", + "Corsican": "科西嘉語", + "Croatian": "克羅埃西亞語", + "Czech": "捷克語", + "Danish": "丹麥語", + "Dutch": "荷蘭語", + "Esperanto": "世界語", + "Estonian": "愛沙尼亞語", + "Filipino": "菲律賓語", + "Finnish": "芬蘭語", + "French": "法語", + "Galician": "加利西亞語", + "Georgian": "喬治亞語", + "German": "德語", + "Greek": "希臘語", + "Gujarati": "古吉拉特語", + "Haitian Creole": "海地克里奧爾語", + "Hausa": "豪薩語", + "Hawaiian": "夏威夷語", + "Hebrew": "希伯來語", + "Hindi": "印地語", + "Hmong": "苗文", + "Hungarian": "匈牙利語", + "Icelandic": "冰島語", + "Igbo": "伊博語", + "Indonesian": "印尼語", + "Irish": "愛爾蘭語", + "Italian": "義大利語", + "Japanese": "日語", + "Javanese": "爪哇語", + "Kannada": "康納達語", + "Kazakh": "哈薩克語", + "Khmer": "高棉文", + "Korean": "韓語", + "Kurdish": "庫德語", + "Kyrgyz": "吉爾吉斯語", + "Lao": "寮語", + "Latin": "拉丁語", + "Latvian": "拉脫維亞語", + "Lithuanian": "立陶宛語", + "Luxembourgish": "盧森堡語", + "Macedonian": "馬其頓語", + "Malagasy": "馬拉加斯語", + "Malay": "馬來語", + "Malayalam": "馬拉雅拉姆語", + "Maltese": "馬爾他語", + "Maori": "毛利語", + "Marathi": "馬拉提語", + "Mongolian": "蒙古語", + "Nepali": "尼泊爾語", + "Norwegian Bokmål": "書面挪威語", + "Nyanja": "尼揚賈語", + "Pashto": "普什圖語", + "Persian": "波斯語", + "Polish": "波蘭人", + "Portuguese": "葡萄牙語", + "Punjabi": "旁遮普語", + "Romanian": "羅馬尼亞語", + "Russian": "俄語", + "Samoan": "薩摩亞語", + "Scottish Gaelic": "蘇格蘭蓋爾語", + "Serbian": "塞爾維亞語", + "Shona": "修納語", + "Sindhi": "信德語", + "Sinhala": "僧伽羅語", + "Slovak": "斯洛伐克語", + "Slovenian": "斯洛維尼亞語", + "Somali": "索馬利亞語", + "Southern Sotho": "南塞索托語", + "Spanish": "西班牙語", + "Spanish (Latin America)": "西班牙語(拉丁美洲)", + "Sundanese": "巽他語", + "Swahili": "斯瓦希里語", + "Swedish": "瑞典語", + "Tajik": "塔吉克語", + "Tamil": "坦米爾語", + "Telugu": "泰盧固語", + "Thai": "泰語", + "Turkish": "土耳其語", + "Ukrainian": "烏克蘭語", + "Urdu": "烏爾都語", + "Uzbek": "烏茲別克語", + "Vietnamese": "越南語", + "Welsh": "威爾斯語", + "Western Frisian": "西菲士蘭語", + "Xhosa": "科薩語", + "Yiddish": "意第緒語", + "Yoruba": "約魯巴語", + "Zulu": "祖魯語", + "`x` years": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 年", + "": "`x` 年" + }, + "`x` months": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 月", + "": "`x` 月" + }, + "`x` weeks": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 週", + "": "`x` 週" + }, + "`x` days": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 天", + "": "`x` 天" + }, + "`x` hours": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 小時", + "": "`x` 小時" + }, + "`x` minutes": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 天", + "": "`x` 天" + }, + "`x` seconds": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 秒", + "": "`x` 秒" + }, + "Fallback comments: ": "汰退留言:", + "Popular": "熱門頻道", + "Top": "熱門影片", + "About": "關於", + "Rating: ": "評分:", + "Language: ": "語言:", + "View as playlist": "以播放清單檢視", + "Default": "預設值", + "Music": "音樂", + "Gaming": "遊戲", + "News": "新聞", + "Movies": "電影", + "Download": "下載", + "Download as: ": "下載為:", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(已編輯)", + "YouTube comment permalink": "YouTube 留言永久連結", + "permalink": "", + "`x` marked it with a ❤": "`x` 為此標記 ❤", + "Audio mode": "音訊模式", + "Video mode": "視訊模式", + "Videos": "影片", + "Playlists": "播放清單", + "Community": "社群", + "Current version: ": "目前版本:" +} From affeeb39decd206589eb7ab28aec93188a21ad5e Mon Sep 17 00:00:00 2001 From: agony Date: Wed, 2 Oct 2019 14:05:58 +0200 Subject: [PATCH 08/39] Fixed bug that made the whole 'Invidious' div clickable. Solves #691 --- assets/css/default.css | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/css/default.css b/assets/css/default.css index f9d52281..24714149 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -162,6 +162,7 @@ img.thumbnail { .navbar .index-link { font-weight: bold; + display: inline; } .navbar > .searchbar .pure-form input[type="search"] { From 9dcc87c70533aa0fd61542ebf778fcbf5f192617 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 4 Oct 2019 10:23:02 -0400 Subject: [PATCH 09/39] Refactor storyboard generation --- src/invidious/videos.cr | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index e175ae39..ea6788ee 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -715,14 +715,15 @@ struct Video storyboards = player_response["storyboards"]? .try &.as_h .try &.["playerStoryboardSpecRenderer"]? + .try &.["spec"]? + .try &.as_s.split("|") if !storyboards - storyboards = player_response["storyboards"]? - .try &.as_h - .try &.["playerLiveStoryboardSpecRenderer"]? - - if storyboard = storyboards.try &.["spec"]? - .try &.as_s + if storyboard = player_response["storyboards"]? + .try &.as_h + .try &.["playerLiveStoryboardSpecRenderer"]? + .try &.["spec"]? + .try &.as_s return [{ url: storyboard.split("#")[0], width: 106, @@ -736,9 +737,6 @@ struct Video end end - storyboards = storyboards.try &.["spec"]? - .try &.as_s.split("|") - items = [] of NamedTuple( url: String, width: Int32, @@ -767,6 +765,7 @@ struct Video interval = interval.to_i storyboard_width = storyboard_width.to_i storyboard_height = storyboard_height.to_i + storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i items << { url: url.to_s.sub("$L", i).sub("$N", "M$M"), @@ -776,7 +775,7 @@ struct Video interval: interval, storyboard_width: storyboard_width, storyboard_height: storyboard_height, - storyboard_count: (count.to_f / (storyboard_width.to_f * storyboard_height.to_f)).ceil.to_i, + storyboard_count: storyboard_count, } end From 68be24ffc69bdd38735faaa87a32d885f47f0ec9 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 4 Oct 2019 12:23:28 -0400 Subject: [PATCH 10/39] Refactor process_video_params --- src/invidious/videos.cr | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index ea6788ee..354ab19e 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1276,19 +1276,19 @@ end def process_video_params(query, preferences) annotations = query["iv_load_policy"]?.try &.to_i? - autoplay = query["autoplay"]?.try &.to_i? + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } comments = query["comments"]?.try &.split(",").map { |a| a.downcase } - continue = query["continue"]?.try &.to_i? - continue_autoplay = query["continue_autoplay"]?.try &.to_i? - listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe - local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe + continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } + continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe } + local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe } player_style = query["player_style"]? preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase } quality = query["quality"]? region = query["region"]? - related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe + related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe } speed = query["speed"]?.try &.rchop("x").to_f? - video_loop = query["loop"]?.try &.to_i? + video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } volume = query["volume"]?.try &.to_i? if preferences @@ -1341,17 +1341,10 @@ def process_video_params(query, preferences) local = false end - if query["t"]? - video_start = decode_time(query["t"]) + if start = query["t"]? || query["time_continue"]? || query["start"]? + video_start = decode_time(start) end video_start ||= 0 - if query["time_continue"]? - video_start = decode_time(query["time_continue"]) - end - video_start ||= 0 - if query["start"]? - video_start = decode_time(query["start"]) - end if query["end"]? video_end = decode_time(query["end"]) From c0796ac3d63823f00a5895007fbc7643af6c31a5 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 4 Oct 2019 12:49:58 -0400 Subject: [PATCH 11/39] Add description to RSS body --- src/invidious/search.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 08171ffe..b2fa78f0 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -24,6 +24,8 @@ struct SearchVideo xml.element("a", href: "#{host_url}/watch?#{query_params}") do xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") end + + xml.element("p", style: "white-space:pre") { xml.text html_to_content(self.description_html) } end end From e61c8046f460aac0b2c20344a07d11350a7a2689 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 4 Oct 2019 12:23:51 -0400 Subject: [PATCH 12/39] Fix z-index, scrollbar in player --- assets/css/default.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/css/default.css b/assets/css/default.css index 24714149..afa5329a 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -290,6 +290,10 @@ input[type="search"]::-webkit-search-cancel-button { } } +ul.vjs-menu-content::-webkit-scrollbar { + display: none; +} + .vjs-user-inactive { cursor: none; } @@ -394,6 +398,7 @@ span > select { /* ProgressBar marker */ .vjs-marker { background-color: rgba(255, 255, 255, 1); + z-index: 0; } /* Big "Play" Button */ From 2d59fdd178c211cc365976f736ce42fa124d5968 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Fri, 4 Oct 2019 17:04:43 -0400 Subject: [PATCH 13/39] Fix default value for empty description --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 354ab19e..a6e1aadb 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1228,7 +1228,7 @@ def fetch_video(id, region) avg_rating = avg_rating.nan? ? 0.0 : avg_rating info["avg_rating"] = "#{avg_rating}" - description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || "" + description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || "

    " wilson_score = ci_lower_bound(likes, likes + dislikes) published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"] From e03b4b7505f7757a9b5c2a4ea5d8232b7a68c66e Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Sat, 5 Oct 2019 11:51:31 -0400 Subject: [PATCH 14/39] Hide scrollbar for player menus --- assets/css/default.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/css/default.css b/assets/css/default.css index afa5329a..ea139b40 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -342,6 +342,11 @@ ul.vjs-menu-content::-webkit-scrollbar { .vjs-control-bar { display: flex; flex-direction: row; + scrollbar-width: none; +} + +.vjs-control-bar::-webkit-scrollbar { + display: none; } .video-js .vjs-icon-cog { From f83274300919aaec0a13f1b7fc111ca5624ddba1 Mon Sep 17 00:00:00 2001 From: ButterflyOfFire Date: Wed, 25 Sep 2019 15:54:31 +0000 Subject: [PATCH 15/39] Update Arabic translation --- locales/ar.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/locales/ar.json b/locales/ar.json index 09452eb2..c29a88ab 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -18,7 +18,7 @@ "New passwords must match": "الأرقام السرية يجب ان تكون متطابقة", "Cannot change password for Google accounts": "لا يستطيع تغيير الرقم السرى لحساب جوجل", "Authorize token?": "رمز الإذن ؟", - "Authorize token for `x`?": "رمز الإذن لـ `x` ?", + "Authorize token for `x`?": "تصريح الرمز لـ `x` ؟", "Yes": "نعم", "No": "لا", "Import and Export Data": "استخراج و إضافة البيانات", @@ -54,9 +54,9 @@ "Always loop: ": "كرر الفيديو دائما: ", "Autoplay: ": "تشغيل تلقائى: ", "Play next by default: ": "شغل الفيديو التالي تلقائيا: ", - "Autoplay next video: ": " شغل الفيديو التالى تلقائيا (فى قوائم التشغيل)", + "Autoplay next video: ": "شغل الفيديو التالي تلقائيا (في قوائم التشغيل) ", "Listen by default: ": "تشغيل النسخة السمعية تلقائى: ", - "Proxy videos: ": "عرض الفيديوهات عن طريق الوكيل(proxy) ؟", + "Proxy videos: ": "عرض الفيديوهات عن طريق البروكسي؟ ", "Default speed: ": "السرعة الإفتراضية: ", "Preferred video quality: ": "الجودة المفضلة للفيديوهات: ", "Player volume: ": "صوت المشغل: ", @@ -65,17 +65,17 @@ "reddit": "Reddit", "Default captions: ": "الترجمات الإفتراضية: ", "Fallback captions: ": "الترجمات المصاحبة: ", - "Show related videos: ": "عرض مقاطع الفيديو ذات الصلة؟", - "Show annotations by default: ": "عرض الملاحظات فى الفيديو تلقائيا ؟", + "Show related videos: ": "اعرض الفيديوهات ذات الصلة: ", + "Show annotations by default: ": "اعرض الملاحظات في الفيديو تلقائيا: ", "Visual preferences": "التفضيلات المرئية", - "Player style: ": "شكل مشغل الفيديوهات", + "Player style: ": "شكل مشغل الفيديوهات: ", "Dark mode: ": "الوضع الليلى: ", - "Theme: ": "اللون", + "Theme: ": "المظهر: ", "dark": "غامق (اسود)", "light": "فاتح (ابيض)", "Thin mode: ": "الوضع الخفيف: ", "Subscription preferences": "تفضيلات الإشتراك", - "Show annotations by default for subscribed channels: ": "عرض الملاحظات فى الفيديوهات تلقائيا فى القنوات المشترك بها فقط ؟", + "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", "Sort videos by: ": "ترتيب الفيديو بـ: ", @@ -102,12 +102,12 @@ "Delete account": "حذف الحساب", "Administrator preferences": "إعدادات المدير", "Default homepage: ": "الصفحة الرئيسية الافتراضية ", - "Feed menu: ": "قائمة التغذية", + "Feed menu: ": "قائمة التدفقات: ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", - "CAPTCHA enabled: ": "تفعيل الكابتشا ؟", - "Login enabled: ": "تفعيل تسجيل الدخول ؟", - "Registration enabled: ": "تفعيل التسجيل ؟", - "Report statistics: ": "إبلاغ الإحصائيات", + "CAPTCHA enabled: ": "تفعيل الكابتشا: ", + "Login enabled: ": "تفعيل الولوج: ", + "Registration enabled: ": "تفعيل التسجيل: ", + "Report statistics: ": "الإبلاغ عن الإحصائيات: ", "Save preferences": "حفظ التفضيلات", "Subscription manager": "مدير الإشتراكات", "Token manager": "إداره الرمز", @@ -118,7 +118,7 @@ "unsubscribe": "إلغاء الإشتراك", "revoke": "مسح", "Subscriptions": "الإشتراكات", - "`x` unseen notifications": "`x` إشعارات لم تشاهدها بعد ", + "`x` unseen notifications": "`x` إشعارات لم تشاهدها بعد", "search": "بحث", "Log out": "تسجيل الخروج", "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", @@ -297,20 +297,20 @@ "`x` hours": "`x` ساعات", "`x` minutes": "`x` دقائق", "`x` seconds": "`x` ثوانى", - "Fallback comments: ": "التعليقات المصاحبة", + "Fallback comments: ": "التعليقات البديلة: ", "Popular": "الأكثر شعبية", "Top": "الأفضل", "About": "حول", - "Rating: ": "التقييم", - "Language: ": "اللغة", + "Rating: ": "التقييم: ", + "Language: ": "اللغة: ", "View as playlist": "عرض كا قائمة التشغيل", "Default": "الكل", "Music": "الاغانى", "Gaming": "الألعاب", "News": "الأخبار", "Movies": "الأفلام", - "Download": "تحميل كـ", - "Download as: ": "تحميل", + "Download": "نزّل", + "Download as: ": "نزّله كـ: ", "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(تم تعديلة)", "YouTube comment permalink": "رابط التعليق على اليوتيوب", @@ -321,5 +321,5 @@ "Videos": "الفيديوهات", "Playlists": "قوائم التشغيل", "Community": "المجتمع", - "Current version: ": "الإصدار الحالى" + "Current version: ": "الإصدار الحالي: " } From f5c7bbfda868fd387c437d91dfacd35b1dfa6297 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Wed, 9 Oct 2019 10:23:26 -0400 Subject: [PATCH 16/39] Add support for zh-TW translation --- src/invidious.cr | 1 + 1 file changed, 1 insertion(+) diff --git a/src/invidious.cr b/src/invidious.cr index c5101e4f..256122aa 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -88,6 +88,7 @@ LOCALES = { "tr" => load_locale("tr"), "uk" => load_locale("uk"), "zh-CN" => load_locale("zh-CN"), + "zh-TW" => load_locale("zh-TW"), } config = CONFIG From dad885c051e7ea7fa0734dac01bf4bdaf8429c07 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Thu, 10 Oct 2019 22:03:25 -0400 Subject: [PATCH 17/39] Add YouTube-Client headers to HTTP requests --- src/invidious/helpers/proxy.cr | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index fde282cd..388d6827 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/helpers/proxy.cr @@ -86,6 +86,16 @@ class HTTPClient < HTTP::Client return opts end + + def exec(request) + if self.host == "www.youtube.com" + request.headers["x-youtube-client-name"] = "1" + request.headers["x-youtube-client-version"] = "2.20180719" + request.headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36" + end + + super + end end def get_proxies(country_code = "US") From 7aada3f328f6e62fc0a460576509c865af3cce30 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Thu, 10 Oct 2019 23:45:46 -0400 Subject: [PATCH 18/39] Avoid override for X-Client headers --- src/invidious/helpers/proxy.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index 388d6827..0ba3c1fe 100644 --- a/src/invidious/helpers/proxy.cr +++ b/src/invidious/helpers/proxy.cr @@ -89,9 +89,9 @@ class HTTPClient < HTTP::Client def exec(request) if self.host == "www.youtube.com" - request.headers["x-youtube-client-name"] = "1" - request.headers["x-youtube-client-version"] = "2.20180719" - request.headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36" + request.headers["x-youtube-client-name"] ||= "1" + request.headers["x-youtube-client-version"] ||= "1.20180719" + request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36" end super From 55f0a8224939a8eb421d4402e5441e9cda867977 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Sat, 12 Oct 2019 10:07:18 -0400 Subject: [PATCH 19/39] Remove Patreon links --- README.md | 1 - src/invidious/views/template.ecr | 2 -- 2 files changed, 3 deletions(-) diff --git a/README.md b/README.md index be7c5580..1041b879 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ - Developer [API](https://github.com/omarroth/invidious/wiki/API) Liberapay: https://liberapay.com/omarroth -Patreon: https://patreon.com/omarroth BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index cfefbc2e..148f179a 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -126,8 +126,6 @@
    From bc1784ed2b24ac400717e7070149e7b410ef5180 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Sat, 12 Oct 2019 23:11:40 +0200 Subject: [PATCH 20/39] French Translation updated, rewording and corrections --- locales/fr.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/locales/fr.json b/locales/fr.json index c3934701..d917e29f 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -13,10 +13,10 @@ "last": "Dernières", "Next page": "Page suivante", "Previous page": "Page précédente", - "Clear watch history?": "Supprimer l'historique des vidéos regardées ?", + "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?", "New password": "Nouveau mot de passe", - "New passwords must match": "Les nouveaux mots de passe doivent être identiques", - "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé", + "New passwords must match": "Les champs \"Nouveau mot de passe\" doivent être identiques", + "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé depuis Invidious", "Authorize token?": "Autoriser le token ?", "Authorize token for `x`?": "Autoriser le token pour `x` ?", "Yes": "Oui", @@ -29,8 +29,8 @@ "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)", "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)", "Export": "Exporter", - "Export subscriptions as OPML": "Exporter les abonnements en OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements en OPML (pour NewPipe & FreeTube)", + "Export subscriptions as OPML": "Exporter les abonnements au format OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)", "Export data as JSON": "Exporter les données au format JSON", "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?", "History": "Historique", @@ -52,9 +52,9 @@ "Preferences": "Préférences", "Player preferences": "Préférences du lecteur", "Always loop: ": "Lire en boucle : ", - "Autoplay: ": "Lire automatiquement : ", - "Play next by default: ": "Lire les vidéos suivantes par défaut (similaire a YouTube) : ", - "Autoplay next video: ": "Lire automatiquement la vidéo en file d'attente : ", + "Autoplay: ": "Lecture automatique : ", + "Play next by default: ": "Lire les vidéos suivantes par défaut : ", + "Autoplay next video: ": "Lecture automatique pour la vidéo suivante : ", "Listen by default: ": "Audio uniquement : ", "Proxy videos: ": "Charger les vidéos à travers un proxy : ", "Default speed: ": "Vitesse par défaut : ", @@ -66,7 +66,7 @@ "Default captions: ": "Sous-titres par défaut : ", "Fallback captions: ": "Sous-titres alternatifs : ", "Show related videos: ": "Voir les vidéos liées : ", - "Show annotations by default: ": "Voir les annotations par défaut : ", + "Show annotations by default: ": "Afficher les annotations par défaut : ", "Visual preferences": "Préférences du site", "Player style: ": "Style du lecteur : ", "Dark mode: ": "Mode Sombre : ", @@ -75,7 +75,7 @@ "light": "clair", "Thin mode: ": "Mode Simplifié : ", "Subscription preferences": "Préférences de la page d'abonnements", - "Show annotations by default for subscribed channels: ": "Voir les annotations par défaut sur les chaînes suivies : ", + "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", "Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ", "Sort videos by: ": "Trier les vidéos par : ", @@ -85,9 +85,9 @@ "alphabetically - reverse": "alphabétiquement - inversé", "channel name": "nom de la chaîne", "channel name - reverse": "nom de la chaîne - inversé", - "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne : ", - "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ", - "Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ", + "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaîne auxquelles vous êtes abonnés : ", + "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaîne auxquelles vous êtes abonnés que n'a pas était regardée : ", + "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas étaient regardées : ", "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", "Enable web notifications": "Activer les notifications web", "`x` uploaded a video": "`x` a partagé(e) une video", @@ -150,7 +150,7 @@ "Show replies": "Afficher les réponses", "Incorrect password": "Mot de passe incorrect", "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassé, réessayez dans quelques heures", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Si vous ne parvenez pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", "Invalid TFA code": "Code d'authentification à deux facteurs invalide", "Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Wrong answer": "Réponse invalide", @@ -178,7 +178,7 @@ "Not a playlist.": "Liste de lecture invalide.", "Playlist does not exist.": "La liste de lecture n'existe pas.", "Could not pull trending pages.": "Impossible de charger les pages de tendances.", - "Hidden field \"challenge\" is a required field": "Le champ masqué « challenge » est un champ obligatoire", + "Hidden field \"challenge\" is a required field": "Le champ masqué \"challenge\" est un champ obligatoire", "Hidden field \"token\" is a required field": "Le champ caché \"token\" est requis", "Erroneous challenge": "Challenge Erroné", "Erroneous token": "Token Erroné", From 15a3c8408f3c27c439b5844a96e8061c2dbe0d34 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Sat, 12 Oct 2019 23:15:53 +0200 Subject: [PATCH 21/39] Assume feed means subscriptions feed --- locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/fr.json b/locales/fr.json index d917e29f..80579a66 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -102,7 +102,7 @@ "Delete account": "Supprimer votre compte", "Administrator preferences": "Préferences d'Administrateur", "Default homepage: ": "Page d'accueil par défaut : ", - "Feed menu: ": "Menu des Flux : ", + "Feed menu: ": "Préferences des abonnements : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Connexion activé : ", From 330ffb803f42f367c39d26e898ad76d097a8de00 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 14 Oct 2019 16:47:11 -0400 Subject: [PATCH 22/39] Remove invalid source map directive for videojs-quality-selector --- assets/js/silvermine-videojs-quality-selector.min.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 3ad2c2ab..e4869564 100644 --- a/assets/js/silvermine-videojs-quality-selector.min.js +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -1,4 +1,3 @@ /*! @silvermine/videojs-quality-selector 2019-09-26 v1.2.2-4-gc134430-dirty */ -!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n":">",'"':""","'":"'","`":"`"},B=h.invert(D);h.escape=W(D),h.unescape=W(B),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function z(n){return"\\"+K[n]}var Y=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},V=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||Y).source,(n.interpolate||Y).source,(n.evaluate||Y).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(V,z),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function J(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),J(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],J(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return J(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected"}},{}],6:[function(n,e,t){"use strict";var c=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),a=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(o){o.on(r.QUALITY_REQUESTED,function(n,e){var t=o.currentSources(),r=o.currentTime(),i=o.playbackRate(),u=o.paused();c.each(t,function(n){n.selected=!1}),c.findWhere(t,{src:e.src}).selected=!0,o._qualitySelectorSafeSeek&&o._qualitySelectorSafeSeek.onQualitySelectionChange(),o.src(t),o.ready(function(){o._qualitySelectorSafeSeek&&!o._qualitySelectorSafeSeek.hasFinished()||(o._qualitySelectorSafeSeek=new a(o,r),o.playbackRate(i)),u||o.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); -//# sourceMappingURL=silvermine-videojs-quality-selector.min.js.map \ No newline at end of file +!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n":">",'"':""","'":"'","`":"`"},B=h.invert(D);h.escape=W(D),h.unescape=W(B),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function z(n){return"\\"+K[n]}var Y=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},V=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||Y).source,(n.interpolate||Y).source,(n.evaluate||Y).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(V,z),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function J(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),J(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],J(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return J(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected"}},{}],6:[function(n,e,t){"use strict";var c=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),a=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(o){o.on(r.QUALITY_REQUESTED,function(n,e){var t=o.currentSources(),r=o.currentTime(),i=o.playbackRate(),u=o.paused();c.each(t,function(n){n.selected=!1}),c.findWhere(t,{src:e.src}).selected=!0,o._qualitySelectorSafeSeek&&o._qualitySelectorSafeSeek.onQualitySelectionChange(),o.src(t),o.ready(function(){o._qualitySelectorSafeSeek&&!o._qualitySelectorSafeSeek.hasFinished()||(o._qualitySelectorSafeSeek=new a(o,r),o.playbackRate(i)),u||o.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); \ No newline at end of file From 97bd1da2a2dc1f80bd5c054158ad4999893828af Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 14 Oct 2019 18:40:08 -0400 Subject: [PATCH 23/39] Remove SSL redirect --- src/invidious.cr | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/invidious.cr b/src/invidious.cr index 256122aa..4cdf8932 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -5294,27 +5294,6 @@ error 500 do |env| templated "error" end -# Add redirect if SSL is enabled -if Kemal.config.ssl - spawn do - server = HTTP::Server.new do |env| - redirect_url = "https://#{env.request.host}#{env.request.path}" - if env.request.query - redirect_url += "?#{env.request.query}" - end - - if config.hsts - env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - end - env.response.headers["Location"] = redirect_url - env.response.status_code = 301 - end - - server.bind_tcp "0.0.0.0", 80 - server.listen - end -end - static_headers do |response, filepath, filestat| response.headers.add("Cache-Control", "max-age=2629800") end From 1e34a61911bf786497793b6fe3f309a411a32aae Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 14 Oct 2019 21:06:41 -0400 Subject: [PATCH 24/39] Fix white-space for RSS feeds --- src/invidious/search.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invidious/search.cr b/src/invidious/search.cr index b2fa78f0..e62d1310 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -25,7 +25,7 @@ struct SearchVideo xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") end - xml.element("p", style: "white-space:pre") { xml.text html_to_content(self.description_html) } + xml.element("p", style: "white-space:pre-wrap") { xml.text html_to_content(self.description_html) } end end From be055d9dcb31fe64cb682d50dc70101484605741 Mon Sep 17 00:00:00 2001 From: Omar Roth Date: Mon, 5 Aug 2019 18:49:13 -0500 Subject: [PATCH 25/39] Add support for custom playlists --- assets/css/darktheme.css | 7 +- assets/js/embed.js | 6 +- assets/js/playlist_widget.js | 47 + assets/js/watch.js | 6 +- config/sql/playlist_videos.sql | 19 + config/sql/playlists.sql | 18 + config/sql/privacy.sql | 10 + locales/ar.json | 12 +- locales/de.json | 12 +- locales/el.json | 10 + locales/en-US.json | 29 +- locales/eo.json | 12 +- locales/es.json | 12 +- locales/eu.json | 10 + locales/fr.json | 12 +- locales/is.json | 12 +- locales/it.json | 10 + locales/nb_NO.json | 12 +- locales/nl.json | 10 + locales/pl.json | 10 + locales/ru.json | 10 + locales/uk.json | 10 + locales/zh-CN.json | 10 + src/invidious.cr | 944 +++++++++++++++++---- src/invidious/helpers/handlers.cr | 7 +- src/invidious/helpers/helpers.cr | 12 +- src/invidious/playlists.cr | 328 ++++++- src/invidious/search.cr | 66 ++ src/invidious/users.cr | 43 + src/invidious/videos.cr | 14 + src/invidious/views/add_playlist_items.ecr | 56 ++ src/invidious/views/components/item.ecr | 28 +- src/invidious/views/create_playlist.ecr | 39 + src/invidious/views/delete_playlist.ecr | 24 + src/invidious/views/edit_playlist.ecr | 81 ++ src/invidious/views/embed.ecr | 1 + src/invidious/views/playlist.ecr | 71 +- src/invidious/views/preferences.ecr | 4 + src/invidious/views/view_all_playlists.ecr | 22 + src/invidious/views/watch.ecr | 1 + 40 files changed, 1802 insertions(+), 245 deletions(-) create mode 100644 assets/js/playlist_widget.js create mode 100644 config/sql/playlist_videos.sql create mode 100644 config/sql/playlists.sql create mode 100644 config/sql/privacy.sql create mode 100644 src/invidious/views/add_playlist_items.ecr create mode 100644 src/invidious/views/create_playlist.ecr create mode 100644 src/invidious/views/delete_playlist.ecr create mode 100644 src/invidious/views/edit_playlist.ecr create mode 100644 src/invidious/views/view_all_playlists.ecr diff --git a/assets/css/darktheme.css b/assets/css/darktheme.css index 1b70956b..92da15b6 100644 --- a/assets/css/darktheme.css +++ b/assets/css/darktheme.css @@ -21,10 +21,9 @@ body { color: #f0f0f0; } -.pure-form > fieldset > input, -.pure-control-group > input, -.pure-form > fieldset > select, -.pure-control-group > select { +input, +select, +textarea { color: rgba(35, 35, 35, 1); } diff --git a/assets/js/embed.js b/assets/js/embed.js index d9af1f5b..074a9d8d 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -12,7 +12,8 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } else { var plid_url = '/api/v1/playlists/' + plid + - '?continuation=' + video_data.id + + '?index=' + video_data.index + + '&continuation' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } @@ -45,6 +46,9 @@ function get_playlist(plid, retries) { } url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) { + url.searchParams.set('index', xhr.response.index); + } location.assign(url.pathname + url.search); }); } diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js new file mode 100644 index 00000000..5d6ddf87 --- /dev/null +++ b/assets/js/playlist_widget.js @@ -0,0 +1,47 @@ +function add_playlist_item(target) { + var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; + tile.style.display = 'none'; + + var url = '/playlist_ajax?action_add_video=1&redirect=false' + + '&video_id=' + target.getAttribute('data-id') + + '&playlist_id=' + target.getAttribute('data-plid'); + var xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.timeout = 10000; + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + tile.style.display = ''; + } + } + } + + xhr.send('csrf_token=' + playlist_data.csrf_token); +} + +function remove_playlist_item(target) { + var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; + tile.style.display = 'none'; + + var url = '/playlist_ajax?action_remove_video=1&redirect=false' + + '&set_video_id=' + target.getAttribute('data-index') + + '&playlist_id=' + target.getAttribute('data-plid'); + var xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.timeout = 10000; + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + tile.style.display = ''; + } + } + } + + xhr.send('csrf_token=' + playlist_data.csrf_token); +} \ No newline at end of file diff --git a/assets/js/watch.js b/assets/js/watch.js index 0f3e8123..80cb1769 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -133,7 +133,8 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } else { var plid_url = '/api/v1/playlists/' + plid + - '?continuation=' + video_data.id + + '?index=' + video_data.index + + '&continuation=' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } @@ -168,6 +169,9 @@ function get_playlist(plid, retries) { } url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) { + url.searchParams.set('index', xhr.response.index); + } location.assign(url.pathname + url.search); }); } diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql new file mode 100644 index 00000000..b2b8d5c4 --- /dev/null +++ b/config/sql/playlist_videos.sql @@ -0,0 +1,19 @@ +-- Table: public.playlist_videos + +-- DROP TABLE public.playlist_videos; + +CREATE TABLE playlist_videos +( + title text, + id text, + author text, + ucid text, + length_seconds integer, + published timestamptz, + plid text references playlists(id), + index int8, + live_now boolean, + PRIMARY KEY (index,plid) +); + +GRANT ALL ON TABLE public.playlist_videos TO kemal; diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql new file mode 100644 index 00000000..46ff30ec --- /dev/null +++ b/config/sql/playlists.sql @@ -0,0 +1,18 @@ +-- Table: public.playlists + +-- DROP TABLE public.playlists; + +CREATE TABLE public.playlists +( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] +); + +GRANT ALL ON public.playlists TO kemal; diff --git a/config/sql/privacy.sql b/config/sql/privacy.sql new file mode 100644 index 00000000..4356813e --- /dev/null +++ b/config/sql/privacy.sql @@ -0,0 +1,10 @@ +-- Type: public.privacy + +-- DROP TYPE public.privacy; + +CREATE TYPE public.privacy AS ENUM +( + 'Public', + 'Unlisted', + 'Private' +); diff --git a/locales/ar.json b/locales/ar.json index c29a88ab..182feed5 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", "Trending": "الشائع", + "Public": "", "Unlisted": "غير مصنف", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Hide annotations": "إخفاء الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات فى الفيديو", @@ -322,4 +332,4 @@ "Playlists": "قوائم التشغيل", "Community": "المجتمع", "Current version: ": "الإصدار الحالي: " -} +} \ No newline at end of file diff --git a/locales/de.json b/locales/de.json index 03cdd398..2d604115 100644 --- a/locales/de.json +++ b/locales/de.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", "Trending": "Trending", + "Public": "", "Unlisted": "Nicht aufgeführt", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Video auf YouTube ansehen", "Hide annotations": "Anmerkungen ausblenden", "Show annotations": "Anmerkungen anzeigen", @@ -322,4 +332,4 @@ "Playlists": "Wiedergabelisten", "Community": "Gemeinschaft", "Current version: ": "Aktuelle Version: " -} +} \ No newline at end of file diff --git a/locales/el.json b/locales/el.json index 222b7d0a..063d724b 100644 --- a/locales/el.json +++ b/locales/el.json @@ -141,7 +141,17 @@ "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", "Trending": "Τάσεις", + "Public": "", "Unlisted": "Κρυφό", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Προβολή στο YouTube", "Hide annotations": "Απόκρυψη σημειώσεων", "Show annotations": "Προβολή σημειώσεων", diff --git a/locales/en-US.json b/locales/en-US.json index 8aaeee48..e0b2dab4 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -7,6 +7,10 @@ "([^0-9]|^)1([^,0-9]|$)": "`x` video", "": "`x` videos" }, + "`x` playlists": { + "(\\D|^)1(\\D|$)": "`x` playlist", + "": "`x` playlists" + }, "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -74,11 +78,11 @@ "Show related videos: ": "Show related videos: ", "Show annotations by default: ": "Show annotations by default: ", "Visual preferences": "Visual preferences", - "Player style: ": "", + "Player style: ": "Player style: ", "Dark mode: ": "Dark mode: ", - "Theme: ": "", - "dark": "", - "light": "", + "Theme: ": "Theme: ", + "dark": "dark", + "light": "light", "Thin mode: ": "Thin mode: ", "Subscription preferences": "Subscription preferences", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", @@ -141,7 +145,17 @@ "View JavaScript license information.": "View JavaScript license information.", "View privacy policy.": "View privacy policy.", "Trending": "Trending", + "Public": "Public", "Unlisted": "Unlisted", + "Private": "Private", + "View all playlists": "View all playlists", + "Updated `x` ago": "Updated `x` ago", + "Delete playlist `x`?": "Delete playlist `x`?", + "Delete playlist": "Delete playlist", + "Create playlist": "Create playlist", + "Title": "Title", + "Playlist privacy": "Playlist privacy", + "Editing playlist `x`": "Editing playlist `x`", "Watch on YouTube": "Watch on YouTube", "Hide annotations": "Hide annotations", "Show annotations": "Show annotations", @@ -162,7 +176,10 @@ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", "View YouTube comments": "View YouTube comments", "View more comments on Reddit": "View more comments on Reddit", - "View `x` comments": "View `x` comments", + "View `x` comments": { + "(\\D|^)1(\\D|$)": "View `x` comment", + "": "View `x` comments" + }, "View Reddit comments": "View Reddit comments", "Hide replies": "Hide replies", "Show replies": "Show replies", @@ -359,7 +376,7 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "YouTube comment permalink": "YouTube comment permalink", - "permalink": "", + "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", "Audio mode": "Audio mode", "Video mode": "Video mode", diff --git a/locales/eo.json b/locales/eo.json index cbdccfca..2da8c9ed 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View privacy policy.": "Vidi regularon pri privateco.", "Trending": "Tendencoj", + "Public": "", "Unlisted": "Ne listigita", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Vidi videon en Youtube", "Hide annotations": "Kaŝi prinotojn", "Show annotations": "Montri prinotojn", @@ -322,4 +332,4 @@ "Playlists": "Ludlistoj", "Community": "Komunumo", "Current version: ": "Nuna versio: " -} +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index cafbf12e..cca88e76 100644 --- a/locales/es.json +++ b/locales/es.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Ver información de licencia de JavaScript.", "View privacy policy.": "Ver la política de privacidad.", "Trending": "Tendencias", + "Public": "", "Unlisted": "No listado", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Ver el vídeo en Youtube", "Hide annotations": "Ocultar anotaciones", "Show annotations": "Mostrar anotaciones", @@ -322,4 +332,4 @@ "Playlists": "Listas de reproducción", "Community": "", "Current version: ": "Versión actual: " -} +} \ No newline at end of file diff --git a/locales/eu.json b/locales/eu.json index cbdbbefc..c65f38a8 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "", "View privacy policy.": "", "Trending": "", + "Public": "", "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "", "Hide annotations": "", "Show annotations": "", diff --git a/locales/fr.json b/locales/fr.json index 80579a66..4e9d89ed 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", "Trending": "Tendances", + "Public": "", "Unlisted": "Non répertoriée", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Voir la vidéo sur Youtube", "Hide annotations": "Masquer les annotations", "Show annotations": "Afficher les annotations", @@ -322,4 +332,4 @@ "Playlists": "Liste de lecture", "Community": "Communauté", "Current version: ": "Version actuelle : " -} +} \ No newline at end of file diff --git a/locales/is.json b/locales/is.json index 808063c4..bbf0411b 100644 --- a/locales/is.json +++ b/locales/is.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", "Trending": "Vinsælt", + "Public": "", "Unlisted": "Óskráð", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Horfa á YouTube", "Hide annotations": "Fela glósur", "Show annotations": "Sýna glósur", @@ -320,4 +330,4 @@ "Videos": "Myndbönd", "Playlists": "Spilunarlistar", "Current version: ": "Núverandi útgáfa: " -} +} \ No newline at end of file diff --git a/locales/it.json b/locales/it.json index c2cd5d30..3878cca9 100644 --- a/locales/it.json +++ b/locales/it.json @@ -141,7 +141,17 @@ "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View privacy policy.": "Vedi la politica sulla privacy", "Trending": "Tendenze", + "Public": "", "Unlisted": "Non elencati", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Guarda su YouTube", "Hide annotations": "Nascondi annotazioni", "Show annotations": "Mostra annotazioni", diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 9028d285..1fba258e 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", "Trending": "Trendsettende", + "Public": "", "Unlisted": "Ulistet", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Vis video på YouTube", "Hide annotations": "Skjul merknader", "Show annotations": "Vis merknader", @@ -322,4 +332,4 @@ "Playlists": "Spillelister", "Community": "Gemenskap", "Current version: ": "Nåværende versjon: " -} +} \ No newline at end of file diff --git a/locales/nl.json b/locales/nl.json index 3e2c6c64..5af8ae75 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View privacy policy.": "Privacybeleid tonen", "Trending": "Uitgelicht", + "Public": "", "Unlisted": "Verborgen", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Video bekijken op YouTube", "Hide annotations": "Annotaties verbergen", "Show annotations": "Annotaties tonen", diff --git a/locales/pl.json b/locales/pl.json index 1e3a2068..44767751 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View privacy policy.": "Polityka prywatności.", "Trending": "Na czasie", + "Public": "", "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Zobacz film na YouTube", "Hide annotations": "", "Show annotations": "", diff --git a/locales/ru.json b/locales/ru.json index 90aa4a3b..1fd540a3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View privacy policy.": "Посмотреть политику конфиденциальности.", "Trending": "В тренде", + "Public": "", "Unlisted": "Нет в списке", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Смотреть на YouTube", "Hide annotations": "Скрыть аннотации", "Show annotations": "Показать аннотации", diff --git a/locales/uk.json b/locales/uk.json index e537008c..53b0c571 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View privacy policy.": "Переглянути політику приватності.", "Trending": "У тренді", + "Public": "", "Unlisted": "Немає в списку", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Дивитися на YouTube", "Hide annotations": "Приховати анотації", "Show annotations": "Показати анотації", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 23617d04..ba91d34e 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "查看 JavaScript 协议信息。", "View privacy policy.": "查看隐私政策。", "Trending": "时下流行", + "Public": "", "Unlisted": "不公开", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "在 YouTube 观看", "Hide annotations": "隐藏注释", "Show annotations": "显示注释", diff --git a/src/invidious.cr b/src/invidious.cr index 4cdf8932..ad313269 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -126,15 +126,19 @@ Kemal::CLI.new ARGV # Check table integrity if CONFIG.check_tables - analyze_table(PG_DB, logger, "channels", InvidiousChannel) - analyze_table(PG_DB, logger, "channel_videos", ChannelVideo) - analyze_table(PG_DB, logger, "nonces", Nonce) - analyze_table(PG_DB, logger, "session_ids", SessionId) - analyze_table(PG_DB, logger, "users", User) - analyze_table(PG_DB, logger, "videos", Video) + check_enum(PG_DB, logger, "privacy", PlaylistPrivacy) + + check_table(PG_DB, logger, "channels", InvidiousChannel) + check_table(PG_DB, logger, "channel_videos", ChannelVideo) + check_table(PG_DB, logger, "playlists", InvidiousPlaylist) + check_table(PG_DB, logger, "playlist_videos", PlaylistVideo) + check_table(PG_DB, logger, "nonces", Nonce) + check_table(PG_DB, logger, "session_ids", SessionId) + check_table(PG_DB, logger, "users", User) + check_table(PG_DB, logger, "videos", Video) if CONFIG.cache_annotations - analyze_table(PG_DB, logger, "annotations", Annotation) + check_table(PG_DB, logger, "annotations", Annotation) end end @@ -248,7 +252,14 @@ before_all do |env| if !env.request.cookies.has_key? "SSID" if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) - csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences @@ -262,7 +273,14 @@ before_all do |env| begin user, sid = get_user(sid, headers, PG_DB, false) - csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences @@ -371,6 +389,8 @@ get "/watch" do |env| end plid = env.params.query["list"]? + continuation = process_continuation(PG_DB, env.params.query, plid, id) + nojs = env.params.query["nojs"]? nojs ||= "0" @@ -555,7 +575,9 @@ get "/embed/" do |env| if plid = env.params.query["list"]? begin - videos = fetch_playlist_videos(plid, 1, 1, locale: locale) + playlist = get_playlist(PG_DB, plid, locale: locale) + offset = env.params.query["index"]?.try &.to_i? || 0 + videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) rescue ex error_message = ex.message env.response.status_code = 500 @@ -577,7 +599,9 @@ end get "/embed/:id" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? id = env.params.url["id"] + plid = env.params.query["list"]? + continuation = process_continuation(PG_DB, env.params.query, plid, id) if md = env.params.query["playlist"]? .try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/) @@ -607,7 +631,9 @@ get "/embed/:id" do |env| if plid begin - videos = fetch_playlist_videos(plid, 1, 1, locale: locale) + playlist = get_playlist(PG_DB, plid, locale: locale) + offset = env.params.query["index"]?.try &.to_i? || 0 + videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) rescue ex error_message = ex.message env.response.status_code = 500 @@ -757,10 +783,447 @@ end # Playlists +get "/view_all_playlists" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + + items = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 ORDER BY created", user.email, as: InvidiousPlaylist) + items.map! do |item| + item.author = "" + item + end + + templated "view_all_playlists" +end + +get "/create_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) + + templated "create_playlist" +end + +post "/create_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + title = env.params.body["title"]?.try &.as(String) + if !title || title.empty? + error_message = "Title cannot be empty." + next templated "error" + end + + privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "") + if !privacy + error_message = "Invalid privacy setting." + next templated "error" + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + error_message = "User cannot have more than 100 playlists." + next templated "error" + end + + playlist = create_playlist(PG_DB, title, privacy, user) + + env.redirect "/playlist?list=#{playlist.id}" +end + +get "/delete_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) + + templated "delete_playlist" +end + +post "/delete_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + plid = env.params.query["list"]? + if !plid + next env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) + PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + + env.redirect "/view_all_playlists" +end + +get "/edit_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + begin + playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + rescue ex + next env.redirect referer + end + + begin + videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + rescue ex + videos = [] of PlaylistVideo + end + + csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) + + templated "edit_playlist" +end + +post "/edit_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + plid = env.params.query["list"]? + if !plid + next env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + title = env.params.body["title"]?.try &.delete("<>") || "" + privacy = PlaylistPrivacy.parse(env.params.body["privacy"]? || "Public") + description = env.params.body["description"]?.try &.delete("\r") || "" + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + + env.redirect "/playlist?list=#{plid}" +end + +get "/add_playlist_items" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + begin + playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + rescue ex + next env.redirect referer + end + + query = env.params.query["q"]? + if query + begin + search_query, count, items = process_search_query(query, page, user, region: nil) + videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } + rescue ex + videos = [] of SearchVideo + count = 0 + end + else + videos = [] of SearchVideo + count = 0 + end + + env.set "add_playlist_items", plid + templated "add_playlist_items" +end + +post "/playlist_ajax" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/") + + redirect = env.params.query["redirect"]? + redirect ||= "true" + redirect = redirect == "true" + + if !user + if redirect + next env.redirect referer + else + error_message = {"error" => "No such user"}.to_json + env.response.status_code = 403 + next error_message + end + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + if redirect + error_message = ex.message + env.response.status_code = 400 + next templated "error" + else + error_message = {"error" => ex.message}.to_json + env.response.status_code = 400 + next error_message + end + end + + if env.params.query["action_create_playlist"]? + action = "action_create_playlist" + elsif env.params.query["action_delete_playlist"]? + action = "action_delete_playlist" + elsif env.params.query["action_edit_playlist"]? + action = "action_edit_playlist" + elsif env.params.query["action_add_video"]? + action = "action_add_video" + video_id = env.params.query["video_id"] + elsif env.params.query["action_remove_video"]? + action = "action_remove_video" + elsif env.params.query["action_move_video_before"]? + action = "action_move_video_before" + else + next env.redirect referer + end + + begin + playlist_id = env.params.query["playlist_id"] + playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) + raise "Invalid user" if playlist.author != user.email + rescue ex + if redirect + error_message = ex.message + env.response.status_code = 400 + next templated "error" + else + error_message = {"error" => ex.message}.to_json + env.response.status_code = 400 + next error_message + end + end + + if !user.password + # TODO: Playlist stub, sync with YouTube for Google accounts + # playlist_ajax(playlist_id, action, env.request.headers) + end + email = user.email + + case action + when "action_edit_playlist" + # TODO: Playlist stub + when "action_add_video" + if playlist.index.size >= 500 + env.response.status_code = 400 + if redirect + error_message = "Playlist cannot have more than 500 videos" + next templated "error" + else + error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json + next error_message + end + end + + video_id = env.params.query["video_id"] + + begin + video = get_video(video_id, PG_DB) + rescue ex + env.response.status_code = 500 + if redirect + error_message = ex.message + next templated "error" + else + error_message = {"error" => ex.message}.to_json + next error_message + end + end + + playlist_video = PlaylistVideo.new( + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: playlist_id, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX) + ) + + video_array = playlist_video.to_a + args = arg_array(video_array) + + PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) + PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) + when "action_remove_video" + index = env.params.query["set_video_id"] + PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) + PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = video_count - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) + when "action_move_video_before" + # TODO: Playlist stub + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end +end + get "/playlist" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? + user = env.get?("user").try &.as(User) plid = env.params.query["list"]? + referer = get_referer(env) + if !plid next env.redirect "/" end @@ -773,19 +1236,29 @@ get "/playlist" do |env| end begin - playlist = fetch_playlist(plid, locale) + playlist = get_playlist(PG_DB, plid, locale) rescue ex error_message = ex.message env.response.status_code = 500 next templated "error" end + if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email + error_message = "This playlist is private." + env.response.status_code = 403 + next templated "error" + end + begin - videos = fetch_playlist_videos(plid, page, playlist.video_count, locale: locale) + videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) rescue ex videos = [] of PlaylistVideo end + if playlist.author == user.try &.email + env.set "remove_playlist_items", plid + end + templated "playlist" end @@ -864,72 +1337,13 @@ get "/search" do |env| page ||= 1 user = env.get? "user" - if user - user = user.as(User) - view_name = "subscriptions_#{sha256(user.email)}" - end - channel = nil - content_type = "all" - 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.downcase.split(":") - - case key - when "channel", "user" - channel = operator.split(":")[-1] - when "content_type", "type" - content_type = value - when "date" - date = value - when "duration" - duration = value - when "feature", "features" - features = value.split(",") - when "sort" - sort = value - when "subscriptions" - subscriptions = value == "true" - else - operators.delete(operator) - end - end - - search_query = (query.split(" ") - operators).join(" ") - - if channel - count, videos = channel_search(search_query, page, channel) - elsif subscriptions - if view_name - videos = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( - SELECT *, - to_tsvector(#{view_name}.title) || - to_tsvector(#{view_name}.author) - as document - FROM #{view_name} - ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) - count = videos.size - else - videos = [] of ChannelVideo - count = 0 - end - else - begin - search_params = produce_search_params(sort: sort, date: date, content_type: content_type, - duration: duration, features: features) - rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" - end - - count, videos = search(search_query, page, search_params, region).as(Tuple) + begin + search_query, count, videos = process_search_query(query, page, user, region: nil) + rescue ex + error_message = ex.message + env.response.status_code = 500 + next templated "error" end env.set "search", query @@ -1746,13 +2160,12 @@ post "/watch_ajax" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex + env.response.status_code = 400 if redirect error_message = ex.message - env.response.status_code = 400 next templated "error" else error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 next error_message end end @@ -2771,6 +3184,35 @@ get "/feed/playlist/:plid" do |env| host_url = make_host_url(config, Kemal.config) path = env.request.path + if plid.starts_with? "IV" + if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) + + next XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{host_url}#{env.request.resource}") + xml.element("id") { xml.text "iv:playlist:#{plid}" } + xml.element("iv:playlistId") { xml.text plid } + xml.element("title") { xml.text playlist.title } + xml.element("link", rel: "alternate", href: "#{host_url}/playlist?list=#{plid}") + + xml.element("author") do + xml.element("name") { xml.text playlist.author } + end + + videos.each do |video| + video.to_xml(host_url, false, xml) + end + end + end + else + env.response.status_code = 404 + next + end + end + client = make_client(YT_URL) response = client.get("/feeds/videos.xml?playlist_id=#{plid}") document = XML.parse(response.body) @@ -4125,92 +4567,58 @@ get "/api/v1/search/suggestions" do |env| end end -get "/api/v1/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| + get route do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - plid = env.params.url["plid"] + env.response.content_type = "application/json" + plid = env.params.url["plid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + offset = env.params.query["index"]?.try &.to_i? + offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } + offset ||= 0 - format = env.params.query["format"]? - format ||= "json" + continuation = env.params.query["continuation"]? - continuation = env.params.query["continuation"]? + format = env.params.query["format"]? + format ||= "json" - if plid.starts_with? "RD" - next env.redirect "/api/v1/mixes/#{plid}" - end - - begin - playlist = fetch_playlist(plid, locale) - rescue ex - error_message = {"error" => "Playlist is empty"}.to_json - env.response.status_code = 410 - next error_message - end - - begin - videos = fetch_playlist_videos(plid, page, playlist.video_count, continuation, locale) - rescue ex - videos = [] of PlaylistVideo - end - - response = JSON.build do |json| - json.object do - json.field "type", "playlist" - json.field "title", playlist.title - json.field "playlistId", playlist.id - json.field "playlistThumbnail", playlist.thumbnail - - json.field "author", playlist.author - json.field "authorId", playlist.ucid - json.field "authorUrl", "/channel/#{playlist.ucid}" - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", playlist.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "description", html_to_content(playlist.description_html) - json.field "descriptionHtml", playlist.description_html - json.field "videoCount", playlist.video_count - - json.field "viewCount", playlist.views - json.field "updated", playlist.updated.to_unix - - json.field "videos" do - json.array do - videos.each do |video| - video.to_json(locale, config, Kemal.config, json) - end - end - end + if plid.starts_with? "RD" + next env.redirect "/api/v1/mixes/#{plid}" end + + begin + playlist = get_playlist(PG_DB, plid, locale) + rescue ex + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + user = env.get?("user").try &.as(User) + if !playlist || !playlist.privacy.public? && playlist.author != user.try &.email + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + response = playlist.to_json(offset, locale, config, Kemal.config, continuation: continuation) + + if format == "html" + response = JSON.parse(response) + playlist_html = template_playlist(response) + index = response["videos"].as_a[1]?.try &.["index"] + next_video = response["videos"].as_a[1]?.try &.["videoId"] + + response = { + "playlistHtml" => playlist_html, + "index" => index, + "nextVideo" => next_video, + }.to_json + end + + response end - - if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - next_video = response["videos"].as_a[1]?.try &.["videoId"] - - response = { - "playlistHtml" => playlist_html, - "nextVideo" => next_video, - }.to_json - end - - response end get "/api/v1/mixes/:rdid" do |env| @@ -4418,6 +4826,224 @@ delete "/api/v1/auth/subscriptions/:ucid" do |env| env.response.status_code = 204 end +get "/api/v1/auth/playlists" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) + + JSON.build do |json| + json.array do + playlists.each do |playlist| + playlist.to_json(0, locale, config, Kemal.config, json) + end + end + end +end + +post "/api/v1/auth/playlists" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) + if !title + error_message = {"error" => "Invalid title."}.to_json + env.response.status_code = 400 + next error_message + end + + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } + if !privacy + error_message = {"error" => "Invalid privacy setting."}.to_json + env.response.status_code = 400 + next error_message + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + error_message = {"error" => "User cannot have more than 100 playlists."}.to_json + env.response.status_code = 400 + next error_message + end + + host_url = make_host_url(config, Kemal.config) + + playlist = create_playlist(PG_DB, title, privacy, user) + env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{playlist.id}" + env.response.status_code = 201 + { + "title" => title, + "playlistId" => playlist.id, + }.to_json +end + +patch "/api/v1/auth/playlists/:plid" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy + description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + env.response.status_code = 204 +end + +delete "/api/v1/auth/playlists/:plid" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) + PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + + env.response.status_code = 204 +end + +post "/api/v1/auth/playlists/:plid/videos" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + if playlist.index.size >= 500 + env.response.status_code = 400 + error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json + next error_message + end + + video_id = env.params.json["videoId"].try &.as(String) + if !video_id + env.response.status_code = 403 + error_message = {"error" => "Invalid videoId"}.to_json + next error_message + end + + begin + video = get_video(video_id, PG_DB) + rescue ex + error_message = {"error" => ex.message}.to_json + env.response.status_code = 500 + next error_message + end + + playlist_video = PlaylistVideo.new( + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: plid, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX) + ) + + video_array = playlist_video.to_a + args = arg_array(video_array) + + PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) + PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + + host_url = make_host_url(config, Kemal.config) + + env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index}" + env.response.status_code = 201 + playlist_video.to_json(locale, config, Kemal.config, index: playlist.index.size) +end + +delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + index = env.params.url["index"].to_i64(16) + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + if !playlist.index.includes? index + env.response.status_code = 404 + error_message = {"error" => "Playlist does not contain index"}.to_json + next error_message + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) + PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = video_count - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + + env.response.status_code = 204 +end + +# patch "/api/v1/auth/playlists/:plid/videos/:index" do |env| +# TODO: Playlist stub +# end + get "/api/v1/auth/tokens" do |env| env.response.content_type = "application/json" user = env.get("user").as(User) diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index f2240691..a3dfd062 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -129,7 +129,7 @@ class AuthHandler < Kemal::Handler error_message = {"error" => ex.message}.to_json env.response.status_code = 403 - env.response.puts error_message + env.response.print error_message end end end @@ -159,7 +159,8 @@ class APIHandler < Kemal::Handler env.response.output.rewind - if env.response.headers.includes_word?("Content-Type", "application/json") + if env.response.output.as(IO::Memory).size != 0 && + env.response.headers.includes_word?("Content-Type", "application/json") response = JSON.parse(env.response.output) if fields_text = env.params.query["fields"]? @@ -194,7 +195,7 @@ class APIHandler < Kemal::Handler end ensure env.response.output = output - env.response.puts response + env.response.print response env.response.flush end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 615e62df..d227fdf9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -598,7 +598,17 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) return items end -def analyze_table(db, logger, table_name, struct_type = nil) +def check_enum(db, logger, enum_name, struct_type = nil) + if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + logger.puts("CREATE TYPE #{enum_name}") + + db.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) + end + end +end + +def check_table(db, logger, table_name, struct_type = nil) # Create table if it doesn't exist begin db.exec("SELECT * FROM #{table_name} LIMIT 0") diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a5383daf..f65e434d 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -1,5 +1,51 @@ struct PlaylistVideo - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_xml(host_url, auto_generated, xml : XML::Builder) + xml.element("entry") do + xml.element("id") { xml.text "yt:video:#{self.id}" } + xml.element("yt:videoId") { xml.text self.id } + xml.element("yt:channelId") { xml.text self.ucid } + xml.element("title") { xml.text self.title } + xml.element("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") + + xml.element("author") do + if auto_generated + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" } + else + xml.element("name") { xml.text author } + xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" } + end + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + xml.element("a", href: "#{host_url}/watch?v=#{self.id}") do + xml.element("img", src: "#{host_url}/vi/#{self.id}/mqdefault.jpg") + end + end + end + + xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } + + xml.element("media:group") do + xml.element("media:title") { xml.text self.title } + xml.element("media:thumbnail", url: "#{host_url}/vi/#{self.id}/mqdefault.jpg", + width: "320", height: "180") + end + end + end + + def to_xml(host_url, auto_generated, xml : XML::Builder? = nil) + if xml + to_xml(host_url, auto_generated, xml) + else + XML.build do |json| + to_xml(host_url, auto_generated, xml) + end + end + end + + def to_json(locale, config, kemal_config, json : JSON::Builder, index : Int32?) json.object do json.field "title", self.title json.field "videoId", self.id @@ -12,17 +58,23 @@ struct PlaylistVideo generate_thumbnails(json, self.id, config, kemal_config) end - json.field "index", self.index + if index + json.field "index", index + json.field "indexId", self.index.to_u64.to_s(16).upcase + else + json.field "index", self.index + end + json.field "lengthSeconds", self.length_seconds end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, config, kemal_config, json : JSON::Builder? = nil, index : Int32? = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, config, kemal_config, json, index: index) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, config, kemal_config, json, index: index) end end end @@ -35,12 +87,66 @@ struct PlaylistVideo length_seconds: Int32, published: Time, plid: String, - index: Int32, + index: Int64, live_now: Bool, }) end struct Playlist + def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + json.object do + json.field "type", "playlist" + json.field "title", self.title + json.field "playlistId", self.id + json.field "playlistThumbnail", self.thumbnail + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", self.author_thumbnail.not_nil!.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + json.field "videoCount", self.video_count + + json.field "viewCount", self.views + json.field "updated", self.updated.to_unix + json.field "isListed", self.privacy.public? + + json.field "videos" do + json.array do + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos.each_with_index do |video, index| + video.to_json(locale, config, Kemal.config, json) + end + end + end + end + end + + def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + if json + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + else + JSON.build do |json| + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + end + end + end + db_mapping({ title: String, id: String, @@ -53,57 +159,122 @@ struct Playlist updated: Time, thumbnail: String?, }) + + def privacy + PlaylistPrivacy::Public + end end -def fetch_playlist_videos(plid, page, video_count, continuation = nil, locale = nil) - client = make_client(YT_URL) +enum PlaylistPrivacy + Public = 0 + Unlisted = 1 + Private = 2 +end - if continuation - html = client.get("/watch?v=#{continuation}&list=#{plid}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") - html = XML.parse_html(html.body) +struct InvidiousPlaylist + def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + json.object do + json.field "type", "invidiousPlaylist" + json.field "title", self.title + json.field "playlistId", self.id - index = html.xpath_node(%q(//span[@id="playlist-current-index"])).try &.content.to_i? - if index - index -= 1 - end - index ||= 0 - else - index = (page - 1) * 100 - end + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", nil + json.field "authorThumbnails", [] of String - if video_count > 100 - url = produce_playlist_url(plid, index) + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + json.field "videoCount", self.video_count - response = client.get(url) - response = JSON.parse(response.body) - if !response["content_html"]? || response["content_html"].as_s.empty? - raise translate(locale, "Empty playlist") - end + json.field "viewCount", self.views + json.field "updated", self.updated.to_unix + json.field "isListed", self.privacy.public? - document = XML.parse_html(response["content_html"].as_s) - nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) - videos = extract_playlist(plid, nodeset, index) - else - # Playlist has less than one page of videos, so subsequent pages will be empty - if page > 1 - videos = [] of PlaylistVideo - else - # Extract first page of videos - response = client.get("/playlist?list=#{plid}&gl=US&hl=en&disable_polymer=1") - document = XML.parse_html(response.body) - nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) - - videos = extract_playlist(plid, nodeset, 0) - - if continuation - until videos[0].id == continuation - videos.shift + json.field "videos" do + json.array do + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos.each_with_index do |video, index| + video.to_json(locale, config, Kemal.config, json, offset + index) + end end end end end - return videos + def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + if json + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + else + JSON.build do |json| + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + end + end + end + + property thumbnail_id + + module PlaylistPrivacyConverter + def self.from_rs(rs) + return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8)))) + end + end + + db_mapping({ + title: String, + id: String, + author: String, + description: {type: String, default: ""}, + video_count: Int32, + created: Time, + updated: Time, + privacy: {type: PlaylistPrivacy, default: PlaylistPrivacy::Private, converter: PlaylistPrivacyConverter}, + index: Array(Int64), + }) + + def thumbnail + @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------" + "/vi/#{@thumbnail_id}/mqdefault.jpg" + end + + def author_thumbnail + nil + end + + def ucid + nil + end + + def views + 0_i64 + end + + def description_html + HTML.escape(self.description).gsub("\n", "
    ") + end +end + +def create_playlist(db, title, privacy, user) + plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" + + playlist = InvidiousPlaylist.new( + title: title.byte_slice(0, 150), + id: plid, + author: user.email, + description: "", # Max 5000 characters + video_count: 0, + created: Time.utc, + updated: Time.utc, + privacy: privacy, + index: [] of Int64, + ) + + playlist_array = playlist.to_a + args = arg_array(playlist_array) + + db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + + return playlist end def extract_playlist(plid, nodeset, index) @@ -144,7 +315,7 @@ def extract_playlist(plid, nodeset, index) length_seconds: length_seconds, published: Time.utc, plid: plid, - index: index + offset, + index: (index + offset).to_i64, live_now: live_now ) end @@ -200,6 +371,18 @@ def produce_playlist_url(id, index) return url end +def get_playlist(db, plid, locale, refresh = true, force_refresh = false) + if plid.starts_with? "IV" + if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + return playlist + else + raise "Playlist does not exist." + end + else + return fetch_playlist(plid, locale) + end +end + def fetch_playlist(plid, locale) client = make_client(YT_URL) @@ -261,6 +444,59 @@ def fetch_playlist(plid, locale) return playlist end +def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) + if playlist.is_a? InvidiousPlaylist + if !offset + index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", playlist.id, continuation, as: Int64) + offset = playlist.index.index(index) || 0 + end + + db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", playlist.id, playlist.index, offset, as: PlaylistVideo) + else + fetch_playlist_videos(playlist.id, playlist.video_count, offset, locale, continuation) + end +end + +def fetch_playlist_videos(plid, video_count, offset = 0, locale = nil, continuation = nil) + client = make_client(YT_URL) + + if continuation + html = client.get("/watch?v=#{continuation}&list=#{plid}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") + html = XML.parse_html(html.body) + + index = html.xpath_node(%q(//span[@id="playlist-current-index"])).try &.content.to_i?.try &.- 1 + offset = index || offset + end + + if video_count > 100 + url = produce_playlist_url(plid, offset) + + response = client.get(url) + response = JSON.parse(response.body) + if !response["content_html"]? || response["content_html"].as_s.empty? + raise translate(locale, "Empty playlist") + end + + document = XML.parse_html(response["content_html"].as_s) + nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) + videos = extract_playlist(plid, nodeset, offset) + elsif offset > 100 + return [] of PlaylistVideo + else # Extract first page of videos + response = client.get("/playlist?list=#{plid}&gl=US&hl=en&disable_polymer=1") + document = XML.parse_html(response.body) + nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) + + videos = extract_playlist(plid, nodeset, 0) + end + + until videos.empty? || videos[0].index == offset + videos.shift + end + + return videos +end + def template_playlist(playlist) html = <<-END_HTML

    diff --git a/src/invidious/search.cr b/src/invidious/search.cr index e62d1310..3a31c5e7 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -431,3 +431,69 @@ def produce_channel_search_url(ucid, query, page) return url end + +def process_search_query(query, page, user, region) + if user + user = user.as(User) + view_name = "subscriptions_#{sha256(user.email)}" + end + + channel = nil + content_type = "all" + 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.downcase.split(":") + + case key + when "channel", "user" + channel = operator.split(":")[-1] + when "content_type", "type" + content_type = value + when "date" + date = value + when "duration" + duration = value + when "feature", "features" + features = value.split(",") + when "sort" + sort = value + when "subscriptions" + subscriptions = value == "true" + else + operators.delete(operator) + end + end + + search_query = (query.split(" ") - operators).join(" ") + + if channel + count, items = channel_search(search_query, page, channel) + elsif subscriptions + if view_name + items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( + SELECT *, + to_tsvector(#{view_name}.title) || + to_tsvector(#{view_name}.author) + as document + FROM #{view_name} + ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) + count = items.size + else + items = [] of ChannelVideo + count = 0 + end + else + search_params = produce_search_params(sort: sort, date: date, content_type: content_type, + duration: duration, features: features) + + count, items = search(search_query, page, search_params, region).as(Tuple) + end + + {search_query, count, items} +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 6149ae7a..f2ebb66f 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -282,6 +282,49 @@ def subscribe_ajax(channel_id, action, env_headers) end end +# TODO: Playlist stub, sync with YouTube for Google accounts +# def playlist_ajax(video_ids, source_playlist_id, name, privacy, action, env_headers) +# headers = HTTP::Headers.new +# headers["Cookie"] = env_headers["Cookie"] +# +# client = make_client(YT_URL) +# html = client.get("/view_all_playlists?disable_polymer=1", headers) +# +# cookies = HTTP::Cookies.from_headers(headers) +# html.cookies.each do |cookie| +# if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name +# if cookies[cookie.name]? +# cookies[cookie.name] = cookie +# else +# cookies << cookie +# end +# end +# end +# headers = cookies.add_request_headers(headers) +# +# if match = html.body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) +# session_token = match["session_token"] +# +# headers["content-type"] = "application/x-www-form-urlencoded" +# +# post_req = { +# video_ids: [] of String, +# source_playlist_id: "", +# n: name, +# p: privacy, +# session_token: session_token, +# } +# post_url = "/playlist_ajax?#{action}=1" +# +# response = client.post(post_url, headers, form: post_req) +# if response.status_code == 200 +# return JSON.parse(response.body)["result"]["playlistId"].as_s +# else +# return nil +# end +# end +# end + def get_subscription_feed(db, user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index a6e1aadb..1ae31257 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1274,6 +1274,20 @@ def itag_to_metadata?(itag : String) return VIDEO_FORMATS[itag]? end +def process_continuation(db, query, plid, id) + continuation = nil + if plid + if index = query["index"]?.try &.to_i? + continuation = index + else + continuation = id + end + continuation ||= 0 + end + + continuation +end + def process_video_params(query, preferences) annotations = query["iv_load_policy"]?.try &.to_i? autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr new file mode 100644 index 00000000..f1899faa --- /dev/null +++ b/src/invidious/views/add_playlist_items.ecr @@ -0,0 +1,56 @@ +<% content_for "header" do %> +<%= playlist.title %> - Invidious + +<% end %> + +
    +
    +
    +
    +
    + <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %> + +
    + value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>> + +
    +
    +
    +
    +
    +
    + + + + +
    + <% videos.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
    + +<% if query %> +
    +
    + <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
    +
    +
    + <% if count >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
    +
    +<% end %> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index d78d8c4b..f7b9cce6 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -13,7 +13,7 @@

    <%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %>

    <% if !item.auto_generated %>

    <%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %>

    <% end %>
    <%= item.description_html %>
    - <% when SearchPlaylist %> + <% when SearchPlaylist, InvidiousPlaylist %> <% if item.id.starts_with? "RD" %> <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").full_path.split("/")[2]}" %> <% else %> @@ -56,6 +56,19 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    + <% if plid = env.get?("remove_playlist_items") %> +
    " method="post"> + "> +

    + + + +

    +
    + <% end %> + <% if item.responds_to?(:live_now) && item.live_now %>

    <%= translate(locale, "LIVE") %>

    <% elsif item.length_seconds != 0 %> @@ -63,7 +76,7 @@ <% end %>
    <% end %> -

    <%= item.title %>

    +

    <%= item.title %>

    @@ -103,6 +116,17 @@

    + <% elsif plid = env.get? "add_playlist_items" %> +
    " method="post"> + "> +

    + + + +

    +
    <% end %> <% if item.responds_to?(:live_now) && item.live_now %> diff --git a/src/invidious/views/create_playlist.ecr b/src/invidious/views/create_playlist.ecr new file mode 100644 index 00000000..14f3673e --- /dev/null +++ b/src/invidious/views/create_playlist.ecr @@ -0,0 +1,39 @@ +<% content_for "header" do %> +<%= translate(locale, "Create playlist") %> - Invidious +<% end %> + +
    +
    +
    +
    +
    +
    + <%= translate(locale, "Create playlist") %> + +
    + + "> +
    + +
    + + +
    + +
    + +
    + + +
    +
    +
    +
    +
    +
    diff --git a/src/invidious/views/delete_playlist.ecr b/src/invidious/views/delete_playlist.ecr new file mode 100644 index 00000000..480e36f4 --- /dev/null +++ b/src/invidious/views/delete_playlist.ecr @@ -0,0 +1,24 @@ +<% content_for "header" do %> +<%= translate(locale, "Delete playlist") %> - Invidious +<% end %> + +
    +
    + <%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %> + +
    +
    + +
    + +
    + + +
    +
    diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr new file mode 100644 index 00000000..bd8d6207 --- /dev/null +++ b/src/invidious/views/edit_playlist.ecr @@ -0,0 +1,81 @@ +<% content_for "header" do %> +<%= playlist.title %> - Invidious + +<% end %> + +
    +
    +
    +

    + + <%= playlist.author %> | + <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | + <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | + "> + + +
    +
    +

    +
    + +
    +
    +
    +

    +
    +
    + +
    + +
    + +
    + +<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> +
    +

    + +

    +
    +<% end %> + +
    +
    +
    + +
    + <% videos.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
    + +
    +
    + <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
    +
    +
    + <% if videos.size == 100 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
    +
    diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 1a253026..6c06bf2e 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -29,6 +29,7 @@ + +<% end %> +
    <% videos.each_slice(4) do |slice| %> <% slice.each do |item| %> diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 56334dd9..1183fba8 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -261,6 +261,10 @@ function update_value(element) { <%= translate(locale, "Manage tokens") %>
    + + diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr new file mode 100644 index 00000000..ef9c85c0 --- /dev/null +++ b/src/invidious/views/view_all_playlists.ecr @@ -0,0 +1,22 @@ +<% content_for "header" do %> +<%= translate(locale, "Playlists") %> - Invidious +<% end %> + +
    +
    +

    <%= translate(locale, "`x` playlists", %(#{items.size})) %>

    +
    + +
    + +
    + <% items.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 6e37f7a6..00a493af 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -29,6 +29,7 @@