diff --git a/assets/css/default.css b/assets/css/default.css index 159c4500..8f2308be 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -312,10 +312,10 @@ input[type="search"]::-webkit-search-cancel-button { } /* Control Bar */ -@media screen and (max-width: 480px) { +@media screen and (max-width: 640px) { .video-js .vjs-control-bar, .vjs-menu-button-popup .vjs-menu .vjs-menu-content { - overflow: -webkit-paged-x; + overflow-x: scroll; } } diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 9d27202e..22387d04 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 2018-01-09 v1.1.2 */ +/*! @silvermine/videojs-quality-selector 2019-09-21 v1.2.2-4-gc134430-dirty */ -!function n(t,e,r){function i(o,c){if(!e[o]){if(!t[o]){var a="function"==typeof require&&require;if(!c&&a)return a(o,!0);if(u)return u(o,!0);var s=new Error("Cannot find module '"+o+"'");throw s.code="MODULE_NOT_FOUND",s}var l=e[o]={exports:{}};t[o][0].call(l.exports,function(n){var e=t[o][1][n];return i(e||n)},l,l.exports,n,t,e,r)}return e[o].exports}for(var u="function"==typeof require&&require,o=0;o=0&&u0?0:c-1;return arguments.length<3&&(i=e[o?o[a]:a],a+=n),t(e,r,i,o,a,c)}}function r(n){return function(t,e,r){e=x(e,r);for(var i=T(t),u=n>0?0:i-1;u>=0&&u0?o=u>=0?u:Math.max(u+c,o):c=u>=0?Math.min(u+1,c):u+c+1;else if(e&&u&&c)return u=e(r,i),r[u]===i?u:-1;if(i!==i)return(u=t(p.call(r,o,c),m.isNaN))>=0?u+o:-1;for(u=n>0?o:c-1;u>=0&&u=0&&t<=E};m.each=m.forEach=function(n,t,e){t=b(t,e);var r,i;if(A(n))for(r=0,i=n.length;r=0},m.invoke=function(n,t){var e=p.call(arguments,2),r=m.isFunction(t);return m.map(n,function(n){var i=r?t:n[t];return null==i?i:i.apply(n,e)})},m.pluck=function(n,t){return m.map(n,m.property(t))},m.where=function(n,t){return m.filter(n,m.matcher(t))},m.findWhere=function(n,t){return m.find(n,m.matcher(t))},m.max=function(n,t,e){var r,i,u=-1/0,o=-1/0;if(null==t&&null!=n)for(var c=0,a=(n=A(n)?n:m.values(n)).length;cu&&(u=r);else t=x(t,e),m.each(n,function(n,e,r){((i=t(n,e,r))>o||i===-1/0&&u===-1/0)&&(u=n,o=i)});return u},m.min=function(n,t,e){var r,i,u=1/0,o=1/0;if(null==t&&null!=n)for(var c=0,a=(n=A(n)?n:m.values(n)).length;cr||void 0===e)return 1;if(et?(o&&(clearTimeout(o),o=null),c=s,u=n.apply(r,i),o||(r=i=null)):o||!1===e.trailing||(o=setTimeout(a,l)),u}},m.debounce=function(n,t,e){var r,i,u,o,c,a=function(){var s=m.now()-o;s=0?r=setTimeout(a,t-s):(r=null,e||(c=n.apply(u,i),r||(u=i=null)))};return function(){u=this,i=arguments,o=m.now();var s=e&&!r;return r||(r=setTimeout(a,t)),s&&(c=n.apply(u,i),u=i=null),c}},m.wrap=function(n,t){return m.partial(t,n)},m.negate=function(n){return function(){return!n.apply(this,arguments)}},m.compose=function(){var n=arguments,t=n.length-1;return function(){for(var e=t,r=n[t].apply(this,arguments);e--;)r=n[e].call(this,r);return r}},m.after=function(n,t){return function(){if(--n<1)return t.apply(this,arguments)}},m.before=function(n,t){var e;return function(){return--n>0&&(e=t.apply(this,arguments)),n<=1&&(t=null),e}},m.once=m.partial(m.before,2);var F=!{toString:null}.propertyIsEnumerable("toString"),q=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"];m.keys=function(n){if(!m.isObject(n))return[];if(y)return y(n);var t=[];for(var e in n)m.has(n,e)&&t.push(e);return F&&u(n,t),t},m.allKeys=function(n){if(!m.isObject(n))return[];var t=[];for(var e in n)t.push(e);return F&&u(n,t),t},m.values=function(n){for(var t=m.keys(n),e=t.length,r=Array(e),i=0;i":">",'"':""","'":"'","`":"`"},N=m.invert(M),L=function(n){var t=function(t){return n[t]},e="(?:"+m.keys(n).join("|")+")",r=RegExp(e),i=RegExp(e,"g");return function(n){return n=null==n?"":""+n,r.test(n)?n.replace(i,t):n}};m.escape=L(M),m.unescape=L(N),m.result=function(n,t,e){var r=null==n?void 0:n[t];return void 0===r&&(r=e),m.isFunction(r)?r.call(n):r};var U=0;m.uniqueId=function(n){var t=++U+"";return n?n+t:t},m.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var D=/(.)^/,R={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},W=/\\|'|\r|\n|\u2028|\u2029/g,P=function(n){return"\\"+R[n]};m.template=function(n,t,e){!t&&e&&(t=e),t=m.defaults({},t,m.templateSettings);var r=RegExp([(t.escape||D).source,(t.interpolate||D).source,(t.evaluate||D).source].join("|")+"|$","g"),i=0,u="__p+='";n.replace(r,function(t,e,r,o,c){return u+=n.slice(i,c).replace(W,P),i=c+t.length,e?u+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":r?u+="'+\n((__t=("+r+"))==null?'':__t)+\n'":o&&(u+="';\n"+o+"\n__p+='"),t}),u+="';\n",t.variable||(u="with(obj||{}){\n"+u+"}\n"),u="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+u+"return __p;\n";try{var o=new Function(t.variable||"obj","_",u)}catch(n){throw n.source=u,n}var c=function(n){return o.call(this,n,m)},a=t.variable||"obj";return c.source="function("+a+"){\n"+u+"}",c},m.chain=function(n){var t=m(n);return t._chain=!0,t};var B=function(n,t){return n._chain?m(t).chain():t};m.mixin=function(n){m.each(m.functions(n),function(t){var e=m[t]=n[t];m.prototype[t]=function(){var n=[this._wrapped];return f.apply(n,arguments),B(this,e.apply(m,n))}})},m.mixin(m),m.each(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=a[n];m.prototype[n]=function(){var e=this._wrapped;return t.apply(e,arguments),"shift"!==n&&"splice"!==n||0!==e.length||delete e[0],B(this,e)}}),m.each(["concat","join","slice"],function(n){var t=a[n];m.prototype[n]=function(){return B(this,t.apply(this._wrapped,arguments))}}),m.prototype.value=function(){return this._wrapped},m.prototype.valueOf=m.prototype.toJSON=m.prototype.value,m.prototype.toString=function(){return""+this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return m})}).call(this)},{}],3:[function(n,t,e){"use strict";var r=n("underscore"),i=n("../events");t.exports=function(n){var t=n.getComponent("MenuItem");return n.extend(t,{constructor:function(n,e){var i=e.source;if(!r.isObject(i))throw new Error('was not provided a "source" object, but rather: '+typeof i);e=r.extend({selectable:!0,label:i.label},e),t.call(this,n,e),this.source=i},handleClick:function(n){t.prototype.handleClick.call(this,n),this.player().trigger(i.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,t,e){"use strict";var r=n("underscore"),i=n("../events"),u=n("./QualityOption");t.exports=function(n){var t,e=n.getComponent("MenuButton"),o=u(n);return t=n.extend(e,{constructor:function(n,t){e.call(this,n,t),n.on(i.QUALITY_REQUESTED,function(t,e){this.setSelectedSource(e),n.addClass("vjs-quality-changing"),n.one("loadeddata",function(){n.removeClass("vjs-quality-changing")})}.bind(this)),n.on(i.QUALITY_SELECTED,function(n,t){this.setSelectedSource(t)}.bind(this)),n.one("ready",function(){this.selectedSrc=n.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var t=n?n.src:void 0;this.selectedSrc!==t&&(this.selectedSrc=t,this.update())},createItems:function(){var n=this.player(),t=n.currentSources();return!t||t.length<2?[]:r.map(t,function(t){return new o(n,{source:t,selected:t.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+e.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",t),t}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,t,e){"use strict";t.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected"}},{}],6:[function(n,t,e){"use strict";var r=n("underscore"),i=n("./events"),u=n("./components/QualitySelector"),o=n("./middleware/SourceInterceptor"),c=n("./util/SafeSeek");t.exports=function(n){n=n||window.videojs,u(n),o(n),n.hook("setup",function(n){n.on(i.QUALITY_REQUESTED,function(t,e){var i=n.currentSources(),u=n.currentTime(),o=n.paused();r.each(i,function(n){n.selected=!1}),r.findWhere(i,{src:e.src}).selected=!0,n._qualitySelectorSafeSeek&&n._qualitySelectorSafeSeek.onQualitySelectionChange(),n.src(i),n.ready(function(){n._qualitySelectorSafeSeek&&!n._qualitySelectorSafeSeek.hasFinished()||(n._qualitySelectorSafeSeek=new c(n,u)),o||n.play()})})})},t.exports.EVENTS=i},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,t,e){"use strict";var r=n("underscore"),i=n("../events");t.exports=function(n){n.use("*",function(n){return{setSource:function(t,e){var u,o=n.currentSources();n._qualitySelectorSafeSeek&&n._qualitySelectorSafeSeek.onPlayerSourcesChange(),u=r.find(o,function(n){return!0===n.selected||"true"===n.selected})||t,n.trigger(i.QUALITY_SELECTED,u),e(null,u)}}})}},{"../events":5,underscore:2}],8:[function(n,t,e){"use strict";n("./index")()},{"./index":6}],9:[function(n,t,e){"use strict";var r=n("class.extend");t.exports=r.extend({init:function(n,t){this._player=n,this._seekToTime=t,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 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 diff --git a/docker/Dockerfile b/docker/Dockerfile index f29de7e1..224c0bf2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM alpine:edge AS builder -RUN apk add -u crystal shards libc-dev \ +RUN apk add --no-cache crystal shards libc-dev \ yaml-dev libxml2-dev sqlite-dev zlib-dev openssl-dev \ sqlite-static zlib-static openssl-libs-static WORKDIR /invidious @@ -15,7 +15,7 @@ RUN crystal build --static --release \ ./src/invidious.cr FROM alpine:latest -RUN apk add -u imagemagick ttf-opensans +RUN apk add --no-cache imagemagick ttf-opensans WORKDIR /invidious RUN addgroup -g 1000 -S invidious && \ adduser -u 1000 -S invidious -G invidious diff --git a/locales/el.json b/locales/el.json index 32f154a1..222b7d0a 100644 --- a/locales/el.json +++ b/locales/el.json @@ -1,10 +1,10 @@ { "`x` subscribers": { - "(\\D|^)1(\\D|$)": "`x` συνδρομητής", + "([^0-9]|^)1([^,0-9]|$)": "`x` συνδρομητής", "": "`x` συνδρομητές" }, "`x` videos": { - "(\\D|^)1(\\D|$)": "`x` βίντεο", + "([^0-9]|^)1([^,0-9]|$)": "`x` βίντεο", "": "`x` βίντεο" }, "LIVE": "ΖΩΝΤΑΝΑ", @@ -119,11 +119,11 @@ "Token manager": "Διαχειριστής διασυνδέσεων", "Token": "Διασύνδεση", "`x` subscriptions": { - "(\\D|^)1(\\D|$)": "`x` συνδρομή", + "([^0-9]|^)1([^,0-9]|$)": "`x` συνδρομή", "": "`x` συνδρομές" }, "`x` tokens": { - "(\\D|^)1(\\D|$)": "`x` διασύνδεση", + "([^0-9]|^)1([^,0-9]|$)": "`x` διασύνδεση", "": "`x` διασυνδέσεις" }, "Import/export": "Εισαγωγή/εξαγωγή", @@ -131,7 +131,7 @@ "revoke": "ανάκληση", "Subscriptions": "Συνδρομές", "`x` unseen notifications": { - "(\\D|^)1(\\D|$)": "`x` καινούρια ειδοποίηση", + "([^0-9]|^)1([^,0-9]|$)": "`x` καινούρια ειδοποίηση", "": "`x` καινούριες ειδοποιήσεις" }, "search": "αναζήτηση", @@ -154,7 +154,7 @@ "Blacklisted regions: ": "Μη-επιτρεπτές περιοχές: ", "Shared `x`": "Μοιράστηκε το `x`", "`x` views": { - "(\\D|^)1(\\D|$)": "`x` προβολή", + "([^0-9]|^)1([^,0-9]|$)": "`x` προβολή", "": "`x` προβολές" }, "Premieres in `x`": "Πρώτη προβολή σε `x`", @@ -188,13 +188,13 @@ "Could not get channel info.": "Αδύναμια εύρεσης πληροφοριών καναλιού.", "Could not fetch comments": "Αδυναμία λήψης σχολίων", "View `x` replies": { - "(\\D|^)1(\\D|$)": "Προβολή `x` απάντησης", + "([^0-9]|^)1([^,0-9]|$)": "Προβολή `x` απάντησης", "": "Προβολή `x` απαντήσεων" }, "`x` ago": "Πριν `x`", "Load more": "Φόρτωση περισσότερων", "`x` points": { - "(\\D|^)1(\\D|$)": "`x` βαθμός", + "([^0-9]|^)1([^,0-9]|$)": "`x` βαθμός", "": "`x` βαθμοί" }, "Could not create mix.": "Αδυναμία δημιουργίας μίξης.", @@ -315,31 +315,31 @@ "Yoruba": "Γιορούμπα", "Zulu": "Ζουλού", "`x` years": { - "(\\D|^)1(\\D|$)": "`x` χρόνο", + "([^0-9]|^)1([^,0-9]|$)": "`x` χρόνο", "": "`x` χρόνια" }, "`x` months": { - "(\\D|^)1(\\D|$)": "`x` μήνα", + "([^0-9]|^)1([^,0-9]|$)": "`x` μήνα", "": "`x` μήνες" }, "`x` weeks": { - "(\\D|^)1(\\D|$)": "`x` εβδομάδα", + "([^0-9]|^)1([^,0-9]|$)": "`x` εβδομάδα", "": "`x` εβδομάδες" }, "`x` days": { - "(\\D|^)1(\\D|$)": "`x` ημέρα", + "([^0-9]|^)1([^,0-9]|$)": "`x` ημέρα", "": "`x` ημέρες" }, "`x` hours": { - "(\\D|^)1(\\D|$)": "`x` ώρα", + "([^0-9]|^)1([^,0-9]|$)": "`x` ώρα", "": "`x` ώρες" }, "`x` minutes": { - "(\\D|^)1(\\D|$)": "`x` λεπτό", + "([^0-9]|^)1([^,0-9]|$)": "`x` λεπτό", "": "`x` λεπτά" }, "`x` seconds": { - "(\\D|^)1(\\D|$)": "`x` δευτερόλεπτο", + "([^0-9]|^)1([^,0-9]|$)": "`x` δευτερόλεπτο", "": "`x` δευτερόλεπτα" }, "Fallback comments: ": "Εναλλακτικά σχόλια: ", diff --git a/locales/en-US.json b/locales/en-US.json index 37566439..524de4c5 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -1,10 +1,10 @@ { "`x` subscribers": { - "(\\D|^)1(\\D|$)": "`x` subscriber", + "([^0-9]|^)1([^,0-9]|$)": "`x` subscriber", "": "`x` subscribers" }, "`x` videos": { - "(\\D|^)1(\\D|$)": "`x` video", + "([^0-9]|^)1([^,0-9]|$)": "`x` video", "": "`x` videos" }, "LIVE": "LIVE", @@ -122,11 +122,11 @@ "Token manager": "Token manager", "Token": "Token", "`x` subscriptions": { - "(\\D|^)1(\\D|$)": "`x` subscription", + "([^0-9]|^)1([^,0-9]|$)": "`x` subscription", "": "`x` subscriptions" }, "`x` tokens": { - "(\\D|^)1(\\D|$)": "`x` token", + "([^0-9]|^)1([^,0-9]|$)": "`x` token", "": "`x` tokens" }, "Import/export": "Import/export", @@ -134,7 +134,7 @@ "revoke": "revoke", "Subscriptions": "Subscriptions", "`x` unseen notifications": { - "(\\D|^)1(\\D|$)": "`x` unseen notification", + "([^0-9]|^)1([^,0-9]|$)": "`x` unseen notification", "": "`x` unseen notifications" }, "search": "search", @@ -157,7 +157,7 @@ "Blacklisted regions: ": "Blacklisted regions: ", "Shared `x`": "Shared `x`", "`x` views": { - "(\\D|^)1(\\D|$)": "`x` views", + "([^0-9]|^)1([^,0-9]|$)": "`x` views", "": "`x` views" }, "Premieres in `x`": "Premieres in `x`", @@ -191,13 +191,13 @@ "Could not get channel info.": "Could not get channel info.", "Could not fetch comments": "Could not fetch comments", "View `x` replies": { - "(\\D|^)1(\\D|$)": "View `x` reply", + "([^0-9]|^)1([^,0-9]|$)": "View `x` reply", "": "View `x` replies" }, "`x` ago": "`x` ago", "Load more": "Load more", "`x` points": { - "(\\D|^)1(\\D|$)": "`x` point", + "([^0-9]|^)1([^,0-9]|$)": "`x` point", "": "`x` points" }, "Could not create mix.": "Could not create mix.", @@ -318,31 +318,31 @@ "Yoruba": "Yoruba", "Zulu": "Zulu", "`x` years": { - "(\\D|^)1(\\D|$)": "`x` year", + "([^0-9]|^)1([^,0-9]|$)": "`x` year", "": "`x` years" }, "`x` months": { - "(\\D|^)1(\\D|$)": "`x` month", + "([^0-9]|^)1([^,0-9]|$)": "`x` month", "": "`x` months" }, "`x` weeks": { - "(\\D|^)1(\\D|$)": "`x` week", + "([^0-9]|^)1([^,0-9]|$)": "`x` week", "": "`x` weeks" }, "`x` days": { - "(\\D|^)1(\\D|$)": "`x` day", + "([^0-9]|^)1([^,0-9]|$)": "`x` day", "": "`x` days" }, "`x` hours": { - "(\\D|^)1(\\D|$)": "`x` hour", + "([^0-9]|^)1([^,0-9]|$)": "`x` hour", "": "`x` hours" }, "`x` minutes": { - "(\\D|^)1(\\D|$)": "`x` minute", + "([^0-9]|^)1([^,0-9]|$)": "`x` minute", "": "`x` minutes" }, "`x` seconds": { - "(\\D|^)1(\\D|$)": "`x` second", + "([^0-9]|^)1([^,0-9]|$)": "`x` second", "": "`x` seconds" }, "Fallback comments: ": "Fallback comments: ", diff --git a/locales/it.json b/locales/it.json index 74d42675..c2cd5d30 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,10 +1,10 @@ { "`x` subscribers": { - "(\\D|^)1(\\D|$)": "`x` iscritto", + "([^0-9]|^)1([^,0-9]|$)": "`x` iscritto", "": "`x` iscritti" }, "`x` videos": { - "(\\D|^)1(\\D|$)": "`x` video", + "([^0-9]|^)1([^,0-9]|$)": "`x` video", "": "`x` video" }, "LIVE": "IN DIRETTA", @@ -119,11 +119,11 @@ "Token manager": "Gestione dei gettoni", "Token": "Gettone", "`x` subscriptions": { - "(\\D|^)1(\\D|$)": "`x` iscrizione", + "([^0-9]|^)1([^,0-9]|$)": "`x` iscrizione", "": "`x` iscrizioni" }, "`x` tokens": { - "(\\D|^)1(\\D|$)": "`x` gettone", + "([^0-9]|^)1([^,0-9]|$)": "`x` gettone", "": "`x` gettoni" }, "Import/export": "Importa/esporta", @@ -131,7 +131,7 @@ "revoke": "revoca", "Subscriptions": "Iscrizioni", "`x` unseen notifications": { - "(\\D|^)1(\\D|$)": "`x` notifica non visualizzata", + "([^0-9]|^)1([^,0-9]|$)": "`x` notifica non visualizzata", "": "`x` notifiche non visualizzate" }, "search": "Cerca", @@ -154,7 +154,7 @@ "Blacklisted regions: ": "Regioni in lista nera: ", "Shared `x`": "Condiviso `x`", "`x` views": { - "(\\D|^)1(\\D|$)": "`x` visualizzazione", + "([^0-9]|^)1([^,0-9]|$)": "`x` visualizzazione", "": "`x` visualizzazioni" }, "Premieres in `x`": "", @@ -188,13 +188,13 @@ "Could not get channel info.": "Impossibile ottenere le informazioni del canale.", "Could not fetch comments": "Impossibile recuperare i commenti", "View `x` replies": { - "(\\D|^)1(\\D|$)": "Visualizza `x` risposta", + "([^0-9]|^)1([^,0-9]|$)": "Visualizza `x` risposta", "": "Visualizza `x` risposte" }, "`x` ago": "`x` fa", "Load more": "Carica altro", "`x` points": { - "(\\D|^)1(\\D|$)": "`x` punto", + "([^0-9]|^)1([^,0-9]|$)": "`x` punto", "": "`x` punti" }, "Could not create mix.": "Impossibile creare il mix.", @@ -315,31 +315,31 @@ "Yoruba": "Yoruba", "Zulu": "Zulu", "`x` years": { - "(\\D|^)1(\\D|$)": "`x` anno", + "([^0-9]|^)1([^,0-9]|$)": "`x` anno", "": "`x` anni" }, "`x` months": { - "(\\D|^)1(\\D|$)": "`x` mese", + "([^0-9]|^)1([^,0-9]|$)": "`x` mese", "": "`x` mesi" }, "`x` weeks": { - "(\\D|^)1(\\D|$)": "`x` settimana", + "([^0-9]|^)1([^,0-9]|$)": "`x` settimana", "": "`x` settimane" }, "`x` days": { - "(\\D|^)1(\\D|$)": "`x` giorno", + "([^0-9]|^)1([^,0-9]|$)": "`x` giorno", "": "`x` giorni" }, "`x` hours": { - "(\\D|^)1(\\D|$)": "`x` ora", + "([^0-9]|^)1([^,0-9]|$)": "`x` ora", "": "`x` ore" }, "`x` minutes": { - "(\\D|^)1(\\D|$)": "`x` minuto", + "([^0-9]|^)1([^,0-9]|$)": "`x` minuto", "": "`x` minuti" }, "`x` seconds": { - "(\\D|^)1(\\D|$)": "`x` secondo", + "([^0-9]|^)1([^,0-9]|$)": "`x` secondo", "": "`x` secondi" }, "Fallback comments: ": "Commenti alternativi: ", @@ -367,4 +367,4 @@ "Playlists": "Playlist", "Community": "Comunità", "Current version: ": "Versione attuale: " -} +} \ No newline at end of file diff --git a/locales/tr.json b/locales/tr.json new file mode 100644 index 00000000..c47170b9 --- /dev/null +++ b/locales/tr.json @@ -0,0 +1,325 @@ +{ + "`x` subscribers.": "`x` abone.", + "`x` videos.": "`x` video.", + "LIVE": "CANLI", + "Shared `x` ago": "`x` önce paylaşıldı", + "Unsubscribe": "Abonelikten çık", + "Subscribe": "Abone ol", + "View channel on YouTube": "Kanalı YouTube'da görüntüle", + "View playlist on YouTube": "Çalma listesini YouTube'da görüntüle", + "newest": "en yeni", + "oldest": "en eski", + "popular": "popüler", + "last": "son", + "Next page": "Sonraki sayfa", + "Previous page": "Önceki sayfa", + "Clear watch history?": "İzleme geçmisini temizle?", + "New password": "Yeni parola", + "New passwords must match": "Yeni parolalar eşleşmek zorunda", + "Cannot change password for Google accounts": "Google hesapları için parola değiştirilemez", + "Authorize token?": "Jetonu yetkilendir?", + "Authorize token for `x`?": "`x` için jetonu yetkilendir?", + "Yes": "Evet", + "No": "Hayır", + "Import and Export Data": "Verileri İçe ve Dışa Aktar", + "Import": "İçe aktar", + "Import Invidious data": "İnvidious verilerini içe aktar", + "Import YouTube subscriptions": "YouTube aboneliklerini içe aktar", + "Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)", + "Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)", + "Export": "Dışa aktar", + "Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)", + "Export data as JSON": "Verileri JSON olarak dışa aktar", + "Delete account?": "Hesabı sil?", + "History": "Geçmiş", + "An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz", + "JavaScript license information": "JavaScript lisans bilgileri", + "source": "kaynak", + "Log in": "Oturum aç", + "Log in/register": "Oturum aç/kayıt ol", + "Log in with Google": "Google ile oturum aç", + "User ID": "Kullanıcı kimliği", + "Password": "Parola", + "Time (h:mm:ss):": "Zaman (h:mm:ss):", + "Text CAPTCHA": "Metin CAPTCHA", + "Image CAPTCHA": "Resim CAPTCHA", + "Sign In": "Oturum Aç", + "Register": "Kayıt Ol", + "E-mail": "E-posta", + "Google verification code": "Google doğrulama kodu", + "Preferences": "Tercihler", + "Player preferences": "Oynatıcı tercihleri", + "Always loop: ": "Sürekli döngü: ", + "Autoplay: ": "Otomatik oynat: ", + "Play next by default: ": "Varsayılan olarak sonrakini oynat: ", + "Autoplay next video: ": "Sonraki videoyu otomatik oynat: ", + "Listen by default: ": "Varsayılan olarak dinle: ", + "Proxy videos: ": "Videoları proxy'le: ", + "Default speed: ": "Varsayılan hız: ", + "Preferred video quality: ": "Tercih edilen video kalitesi: ", + "Player volume: ": "Oynatıcı ses seviyesi: ", + "Default comments: ": "Varsayılan yorumlar: ", + "youtube": "youtube", + "reddit": "reddit", + "Default captions: ": "Varsayılan altyazılar: ", + "Fallback captions: ": "Yedek altyazılar: ", + "Show related videos: ": "İlgili videoları göster: ", + "Show annotations by default: ": "Varsayılan olarak ek açıklamaları göster: ", + "Visual preferences": "Görsel tercihler", + "Player style: ": "Oynatıcı biçimi: ", + "Dark mode: ": "Karanlık mod: ", + "Theme: ": "Tema: ", + "dark": "karanlık", + "light": "aydınlık", + "Thin mode: ": "İnce mod: ", + "Subscription preferences": "Abonelik tercihleri", + "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları varsayılan olarak göster: ", + "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", + "Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ", + "Sort videos by: ": "Videoları sıralama kriteri: ", + "published": "yayınlandı", + "published - reverse": "yayınlandı - ters", + "alphabetically": "alfabetik olarak", + "alphabetically - reverse": "alfabetik olarak - ters", + "channel name": "kanal adı", + "channel name - reverse": "kanal adı - ters", + "Only show latest video from channel: ": "Sadece kanaldaki en son videoyu göster: ", + "Only show latest unwatched video from channel: ": "Sadece kanaldaki en son izlenmemiş videoyu göster: ", + "Only show unwatched: ": "Sadece izlenmemişleri göster: ", + "Only show notifications (if there are any): ": "Sadece bildirimleri göster (eğer varsa): ", + "Enable web notifications": "Ağ bildirimlerini etkinleştir", + "`x` uploaded a video": "`x` bir video yükledi", + "`x` is live": "`x` canlı yayında", + "Data preferences": "Veri tercihleri", + "Clear watch history": "İzleme geçmişini temizle", + "Import/export data": "Verileri içe/dışa aktar", + "Change password": "Parolayı değiştir", + "Manage subscriptions": "Abonelikleri yönet", + "Manage tokens": "Jetonları yönet", + "Watch history": "İzleme geçmişi", + "Delete account": "Hesap silme", + "Administrator preferences": "Yönetici tercihleri", + "Default homepage: ": "Varsayılan ana sayfa: ", + "Feed menu: ": "Akış menüsü: ", + "Top enabled: ": "Top etkin: ", + "CAPTCHA enabled: ": "CAPTCHA etkin: ", + "Login enabled: ": "Oturum açma etkin: ", + "Registration enabled: ": "Kayıt olma etkin: ", + "Report statistics: ": "Rapor istatistikleri: ", + "Save preferences": "Tercihleri kaydet", + "Subscription manager": "Abonelik yöneticisi", + "Token manager": "Jeton yöneticisi", + "Token": "Jeton", + "`x` subscriptions.": "`x` abonelik.", + "`x` tokens.": "`x` jeton.", + "Import/export": "İçe/dışa aktar", + "unsubscribe": "abonelikten çık", + "revoke": "geri al", + "Subscriptions": "Abonelikler", + "`x` unseen notifications.": "`x` okunmamış bildirim.", + "search": "ara", + "Log out": "Çıkış yap", + "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", + "Source available here.": "Kaynak kodu burada mevcut.", + "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", + "View privacy policy.": "Gizlilik politikasını görüntüle.", + "Trending": "Trendler", + "Unlisted": "Listelenmemiş", + "Watch on YouTube": "YouTube'da izle", + "Hide annotations": "Ek açıklamaları gizle", + "Show annotations": "Ek açıklamaları göster", + "Genre: ": "Tür: ", + "License: ": "Lisans: ", + "Family friendly? ": "Aile için uygun? ", + "Wilson score: ": "Wilson puanı: ", + "Engagement: ": "İzleyenlerin oy verme oranı: ", + "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", + "Blacklisted regions: ": "Kara listeye alınan bölgeler: ", + "Shared `x`": "`x` paylaşıldı", + "`x` views.": "`x` izlenme.", + "Premieres in `x`": "`x`içinde ilk gösterim", + "Premieres `x`": "`x` ilk gösterim", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Merhaba! JavaScript'i kapatmış gibi görünüyorsun. Yorumları görüntülemek için buraya tıkla, yüklenmelerinin biraz uzun sürebileceğini unutma.", + "View YouTube comments": "YouTube yorumlarını görüntüle", + "View more comments on Reddit": "Reddit'te daha fazla yorum görüntüle", + "View `x` comments": "`x` yorum görüntüle", + "View Reddit comments": "Reddit yorumlarını görüntüle", + "Hide replies": "Cevapları gizle", + "Show replies": "Cevapları göster", + "Incorrect password": "Yanlış parola", + "Quota exceeded, try again in a few hours": "Kota aşıldı, birkaç saat içinde tekrar deneyin", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Oturum açılamadı, iki faktörlü kimlik doğrulamanın (Authenticator ya da SMS) açık olduğundan emin olun.", + "Invalid TFA code": "Geçersiz TFA kodu", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Giriş başarısız. Bunun nedeni, hesabınız için iki faktörlü kimlik doğrulamanın açık olmaması olabilir.", + "Wrong answer": "Yanlış cevap", + "Erroneous CAPTCHA": "Hatalı CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA zorunlu bir alandır", + "User ID is a required field": "Kullanıcı kimliği zorunlu bir alandır", + "Password is a required field": "Parola zorunlu bir alandır", + "Wrong username or password": "Yanlış kullanıcı adı ya da parola", + "Please sign in using 'Log in with Google'": "Lütfen 'Google ile giriş yap' seçeneğini kullanarak oturum açın", + "Password cannot be empty": "Parola boş olamaz", + "Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz", + "Please log in": "Lütfen oturum açın", + "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", + "channel:`x`": "kanal:`x`", + "Deleted or invalid channel": "Silinmiş ya da geçersiz kanal", + "This channel does not exist.": "Bu kanal mevcut değil.", + "Could not get channel info.": "Kanal bilgisi alınamadı.", + "Could not fetch comments": "Yorumlar alınamadı", + "View `x` replies.": "`x` yanıtı görüntüle.", + "`x` ago": "`x` önce", + "Load more": "Daha fazla yükle", + "`x` points.": "`x` puan.", + "Could not create mix.": "Mix oluşturulamadı.", + "Empty playlist": "Boş oynatma listesi", + "Not a playlist.": "Oynatma listesi değil.", + "Playlist does not exist.": "Oynatma listesi mevcut değil.", + "Could not pull trending pages.": "Trend sayfaları alınamıyor.", + "Hidden field \"challenge\" is a required field": "Gizli alan \"challenge\" zorunlu bir alandır", + "Hidden field \"token\" is a required field": "Gizli alan \"jeton\" zorunlu bir alandır", + "Erroneous challenge": "Hatalı challenge", + "Erroneous token": "Hatalı jeton", + "No such user": "Böyle bir kullanıcı yok", + "Token is expired, please try again": "Jetonun süresi doldu, lütfen tekrar deneyin", + "English": "İngilizce", + "English (auto-generated)": "İngilizce (otomatik oluşturuldu)", + "Afrikaans": "Afrikanca", + "Albanian": "Arnavutça", + "Amharic": "Amharca", + "Arabic": "Arapça", + "Armenian": "Ermenice", + "Azerbaijani": "Azerice", + "Bangla": "Bengalce", + "Basque": "Baskça", + "Belarusian": "Belarusça", + "Bosnian": "Boşnakça", + "Bulgarian": "Bulgarca", + "Burmese": "Birmanca", + "Catalan": "Katalanca", + "Cebuano": "Sebuanca", + "Chinese (Simplified)": "Çince (Basitleştirilmiş)", + "Chinese (Traditional)": "Çince (Geleneksel)", + "Corsican": "Korsikaca", + "Croatian": "Hırvatça", + "Czech": "Çekçe", + "Danish": "Danca", + "Dutch": "Flemenkçe", + "Esperanto": "Esperanto", + "Estonian": "Estonca", + "Filipino": "Filipince", + "Finnish": "Fince", + "French": "Fransızca", + "Galician": "Galiçyaca", + "Georgian": "Gürcüce", + "German": "Almanca", + "Greek": "Yunanca", + "Gujarati": "Guceratça", + "Haitian Creole": "Haiti Creole dili", + "Hausa": "Hausaca", + "Hawaiian": "Hawaii dili", + "Hebrew": "İbranice", + "Hindi": "Hintçe", + "Hmong": "Hmong", + "Hungarian": "Macarca", + "Icelandic": "İzlandaca", + "Igbo": "İgbo", + "Indonesian": "Endonezce", + "Irish": "İrlandaca", + "Italian": "İtalyanca", + "Japanese": "Japonca", + "Javanese": "Cava dili", + "Kannada": "Kannada dili", + "Kazakh": "Kazakça", + "Khmer": "Kmerce", + "Korean": "Korece", + "Kurdish": "Kürtçe", + "Kyrgyz": "Kırgızca", + "Lao": "Laoca", + "Latin": "Latince", + "Latvian": "Letonca", + "Lithuanian": "Litvanyaca", + "Luxembourgish": "Lüksemburgca", + "Macedonian": "Makedonca", + "Malagasy": "Malgaşça", + "Malay": "Malayca", + "Malayalam": "Malayalam dili", + "Maltese": "Maltaca", + "Maori": "Maori dili", + "Marathi": "Marati dili", + "Mongolian": "Moğolca", + "Nepali": "Nepalce", + "Norwegian Bokmål": "Norveççe Bokmål", + "Nyanja": "Çevaca", + "Pashto": "Peştuca", + "Persian": "Farsça", + "Polish": "Lehçe", + "Portuguese": "Portekizce", + "Punjabi": "Pencap dili", + "Romanian": "Rumence", + "Russian": "Rusça", + "Samoan": "Samoa dili", + "Scottish Gaelic": "İskoç Galcesi", + "Serbian": "Sırpça", + "Shona": "Şona dili", + "Sindhi": "Sintçe", + "Sinhala": "Seylanca", + "Slovak": "Slovakça", + "Slovenian": "Slovence", + "Somali": "Somalice", + "Southern Sotho": "Güney Sotho dili", + "Spanish": "İspanyolca", + "Spanish (Latin America)": "İspanyolca (Latin Amerika)", + "Sundanese": "Sundaca", + "Swahili": "Svahili dili", + "Swedish": "İsveççe", + "Tajik": "Tacikçe", + "Tamil": "Tamilce", + "Telugu": "Telugu dili", + "Thai": "Tayca", + "Turkish": "Türkçe", + "Ukrainian": "Ukraynaca", + "Urdu": "Urduca", + "Uzbek": "Özbekçe", + "Vietnamese": "Vietnamca", + "Welsh": "Galce", + "Western Frisian": "Batı Frizcesi", + "Xhosa": "Xhosa dili", + "Yiddish": "Yiddiş", + "Yoruba": "Yoruba dili", + "Zulu": "Zuluca", + "`x` years": "`x` yıl", + "`x` months": "`x` ay", + "`x` weeks": "`x` hafta", + "`x` days": "`x` gün", + "`x` hours": "`x` saat", + "`x` minutes": "`x` dakika", + "`x` seconds": "`x` saniye", + "Fallback comments: ": "Yedek yorumlar: ", + "Popular": "Popüler", + "Top": "Enler", + "About": "Hakkında", + "Rating: ": "Değerlendirme: ", + "Language: ": "Dil: ", + "View as playlist": "Oynatma listesi olarak görüntüle", + "Default": "Varsayılan", + "Music": "Müzik", + "Gaming": "Oyun", + "News": "Haberler", + "Movies": "Filmler", + "Download": "İndir", + "Download as: ": "Şu şekilde indir: ", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(düzenlendi)", + "YouTube comment permalink": "YouTube yorumu kalıcı linki", + "permalink": "kalıcı link", + "`x` marked it with a ❤": "`x` ❤ ile işaretlendi", + "Audio mode": "Ses modu", + "Video mode": "Video modu", + "Videos": "Videolar", + "Playlists": "Oynatma listeleri", + "Community": "Topluluk", + "Current version: ": "Şu anki versiyon: " +} \ No newline at end of file diff --git a/shard.yml b/shard.yml index 0f9beaf2..3980201d 100644 --- a/shard.yml +++ b/shard.yml @@ -11,14 +11,14 @@ targets: dependencies: pg: github: will/crystal-pg - version: ~> 0.18.1 + version: ~> 0.19.0 sqlite3: github: crystal-lang/crystal-sqlite3 - version: ~> 0.13.0 + version: ~> 0.14.0 kemal: github: kemalcr/kemal version: ~> 0.26.0 -crystal: 0.30.1 +crystal: 0.31.0 license: AGPLv3 diff --git a/src/invidious.cr b/src/invidious.cr index 96fa36c0..6fc11356 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -17,7 +17,6 @@ require "digest/md5" require "file_utils" require "kemal" -require "markdown" require "openssl/hmac" require "option_parser" require "pg" @@ -86,6 +85,7 @@ LOCALES = { "nl" => load_locale("nl"), "pl" => load_locale("pl"), "ru" => load_locale("ru"), + "tr" => load_locale("tr"), "uk" => load_locale("uk"), "zh-CN" => load_locale("zh-CN"), } @@ -300,7 +300,7 @@ before_all do |env| current_page += "?#{query}" end - env.set "current_page", URI.escape(current_page) + env.set "current_page", URI.encode_www_form(current_page) end get "/" do |env| @@ -395,7 +395,7 @@ get "/watch" do |env| begin video = get_video(id, PG_DB, region: params.region) rescue ex : VideoRedirect - next env.redirect "/watch?v=#{ex.message}" + next env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -672,7 +672,7 @@ get "/embed/:id" do |env| begin video = get_video(id, PG_DB, region: params.region) rescue ex : VideoRedirect - next env.redirect "/embed/#{ex.message}" + next env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -845,7 +845,7 @@ get "/results" do |env| page ||= 1 if query - env.redirect "/search?q=#{URI.escape(query)}&page=#{page}" + env.redirect "/search?q=#{URI.encode_www_form(query)}&page=#{page}" else env.redirect "/" end @@ -1022,6 +1022,7 @@ post "/login" do |env| headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" headers["Google-Accounts-XSRF"] = "1" + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36" response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) lookup_results = JSON.parse(response.body[5..-1]) @@ -1053,7 +1054,7 @@ post "/login" do |env| traceback << "done, returned #{response.status_code}.
" - headers["Cookie"] = URI.unescape(headers["Cookie"]) + headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) if challenge_results[0][3]?.try &.== 7 error_message = translate(locale, "Account has temporarily been disabled") @@ -1061,6 +1062,13 @@ post "/login" do |env| next templated "error" end + # TODO: Handle Google's CAPTCHA + if captcha = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a? + error_message = "Unhandled CAPTCHA. Please try again later." + env.response.status_code = 401 + next templated "error" + end + if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" error_message = translate(locale, "Incorrect password") env.response.status_code = 401 @@ -1078,7 +1086,7 @@ post "/login" do |env| end # Prefer Authenticator app and SMS over unsupported protocols - if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8]) && prompt_type == 4 + if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] traceback << "Selecting challenge #{tfa[8]}..." @@ -1186,8 +1194,12 @@ post "/login" do |env| break end - # TODO: Occasionally there will be a second page after login confirming - # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently choke on. + # Occasionally there will be a second page after login confirming + # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. + + if location.includes? "/b/0/SmsAuthInterstitial" + traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." + end login = client.get(location, headers) headers = login.cookies.add_request_headers(headers) @@ -1389,7 +1401,7 @@ post "/login" do |env| user_array[4] = user_array[4].to_json args = arg_array(user_array) - PG_DB.exec("INSERT INTO users VALUES (#{args})", user_array) + PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) view_name = "subscriptions_#{sha256(user.email)}" @@ -2475,7 +2487,7 @@ post "/authorize_token" do |env| access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB) if callback_url - access_token = URI.escape(access_token) + access_token = URI.encode_www_form(access_token) url = URI.parse(callback_url) if url.query @@ -2696,6 +2708,8 @@ get "/feed/channel/:ucid" do |env| begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -2958,7 +2972,7 @@ post "/feed/webhook/:token" do |env| PG_DB.exec("INSERT INTO channel_videos VALUES (#{args}) \ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, premiere_timestamp = $9, views = $10", video_array) + live_now = $8, premiere_timestamp = $9, views = $10", args: video_array) # Update all users affected by insert if emails.empty? @@ -3099,6 +3113,8 @@ get "/channel/:ucid" do |env| begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -3166,6 +3182,8 @@ get "/channel/:ucid/playlists" do |env| begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -3204,6 +3222,8 @@ get "/channel/:ucid/community" do |env| begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex error_message = ex.message env.response.status_code = 500 @@ -3259,7 +3279,10 @@ get "/api/v1/storyboards/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - next env.redirect "/api/v1/storyboards/#{ex.message}" + error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + next error_message rescue ex env.response.status_code = 500 next @@ -3344,7 +3367,10 @@ get "/api/v1/captions/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - next env.redirect "/api/v1/captions/#{ex.message}" + error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + next error_message rescue ex env.response.status_code = 500 next @@ -3365,7 +3391,7 @@ get "/api/v1/captions/:id" do |env| json.object do json.field "label", caption.name.simpleText json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" end end end @@ -3444,7 +3470,7 @@ get "/api/v1/captions/:id" do |env| if title = env.params.query["title"]? # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.escape(title)}\"; filename*=UTF-8''#{URI.escape(title)}" + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" end webvtt @@ -3632,7 +3658,7 @@ get "/api/v1/annotations/:id" do |env| id = id.sub(/^-/, 'A') end - file = URI.escape("#{id[0, 3]}/#{id}.xml") + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") client = make_client(ARCHIVE_URL) location = client.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}") @@ -3684,7 +3710,10 @@ get "/api/v1/videos/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - next env.redirect "/api/v1/videos/#{ex.message}" + error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + next error_message rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -3787,6 +3816,11 @@ get "/api/v1/channels/:ucid" do |env| begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + next error_message rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -3917,6 +3951,11 @@ end begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + next error_message rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -3981,6 +4020,11 @@ end begin channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json + env.response.status_code = 302 + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + next error_message rescue ex error_message = {"error" => ex.message}.to_json env.response.status_code = 500 @@ -4113,7 +4157,7 @@ get "/api/v1/search/suggestions" do |env| begin client = make_client(URI.parse("https://suggestqueries.google.com")) - response = client.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.escape(query)}&callback=suggestCallback").body + response = client.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback").body body = response[35..-2] body = JSON.parse(body).as_a @@ -4497,7 +4541,7 @@ post "/api/v1/auth/tokens/register" do |env| access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) if callback_url - access_token = URI.escape(access_token) + access_token = URI.encode_www_form(access_token) if query = callback_url.query query = HTTP::Params.parse(query.not_nil!) @@ -4565,11 +4609,7 @@ get "/api/manifest/dash/id/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - url = "/api/manifest/dash/id/#{ex.message}" - if env.params.query - url += "?#{env.params.query}" - end - next env.redirect url + next env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex env.response.status_code = 403 next @@ -4736,7 +4776,7 @@ get "/api/manifest/hls_playlist/*" do |env| raw_params = {} of String => Array(String) path.each_slice(2) do |pair| key, value = pair - value = URI.unescape(value) + value = URI.decode_www_form(value) if raw_params[key]? raw_params[key] << value @@ -4861,7 +4901,7 @@ get "/videoplayback/*" do |env| raw_params = {} of String => Array(String) path.each_slice(2) do |pair| key, value = pair - value = URI.unescape(value) + value = URI.decode_www_form(value) if raw_params[key]? raw_params[key] << value @@ -5035,7 +5075,7 @@ get "/videoplayback" do |env| if title = query_params["title"]? # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.escape(title)}\"; filename*=UTF-8''#{URI.escape(title)}" + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" end if !response.headers.includes_word?("Transfer-Encoding", "chunked") @@ -5348,4 +5388,6 @@ add_context_storage_type(Preferences) add_context_storage_type(User) Kemal.config.logger = logger +Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding +Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.run diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 00eac902..fa05dce4 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -118,7 +118,7 @@ struct AboutChannel description_html: String, paid: Bool, total_views: Int64, - sub_count: Int64, + sub_count: Int32, joined: Time, is_family_friendly: Bool, allowed_regions: Array(String), @@ -127,6 +127,13 @@ struct AboutChannel }) end +class ChannelRedirect < Exception + property channel_id : String + + def initialize(@channel_id) + end +end + def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) finished_channel = Channel(String | Nil).new @@ -172,14 +179,14 @@ def get_channel(id, db, refresh = true, pull_all_videos = true) args = arg_array(channel_array) db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", channel_array) + ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) end else channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) channel_array = channel.to_a args = arg_array(channel_array) - db.exec("INSERT INTO channels VALUES (#{args})", channel_array) + db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) end return channel @@ -268,7 +275,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) db.exec("INSERT INTO channel_videos VALUES (#{args}) \ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10", video_array) + live_now = $8, views = $10", args: video_array) # Update all users affected by insert if emails.empty? @@ -336,7 +343,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) db.exec("INSERT INTO channel_videos VALUES (#{args}) \ ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10", video_array) + live_now = $8, views = $10", args: video_array) # Update all users affected by insert if emails.empty? @@ -469,7 +476,7 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = " end data = Base64.urlsafe_encode(data) - cursor = URI.escape(data) + cursor = URI.encode_www_form(data) data = IO::Memory.new @@ -490,7 +497,7 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = " IO.copy data, buffer continuation = Base64.urlsafe_encode(buffer) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" @@ -540,7 +547,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated data.rewind data = Base64.urlsafe_encode(data) - continuation = URI.escape(data) + continuation = URI.encode_www_form(data) data = IO::Memory.new @@ -561,7 +568,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated IO.copy data, buffer continuation = Base64.urlsafe_encode(buffer) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" @@ -571,7 +578,7 @@ end def extract_channel_playlists_cursor(url, auto_generated) continuation = HTTP::Params.parse(URI.parse(url).query.not_nil!)["continuation"] - continuation = URI.unescape(continuation) + continuation = URI.decode_www_form(continuation) data = IO::Memory.new(Base64.decode(continuation)) # 0xe2 0xa9 0x85 0xb2 0x02 @@ -590,7 +597,7 @@ def extract_channel_playlists_cursor(url, auto_generated) data.read inner_continuation continuation = String.new(inner_continuation) - continuation = URI.unescape(continuation) + continuation = URI.decode_www_form(continuation) data = IO::Memory.new(Base64.decode(continuation)) # 0x12 0x09 playlists @@ -607,7 +614,7 @@ def extract_channel_playlists_cursor(url, auto_generated) cursor = String.new(cursor) if !auto_generated - cursor = URI.unescape(cursor) + cursor = URI.decode_www_form(cursor) cursor = Base64.decode_string(cursor) end @@ -870,7 +877,7 @@ def fetch_channel_community(ucid, continuation, locale, config, kemal_config, fo end def produce_channel_community_continuation(ucid, cursor) - cursor = URI.escape(cursor) + cursor = URI.encode_www_form(cursor) data = IO::Memory.new @@ -891,13 +898,13 @@ def produce_channel_community_continuation(ucid, cursor) IO.copy data, buffer continuation = Base64.urlsafe_encode(buffer) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) return continuation end def extract_channel_community_cursor(continuation) - continuation = URI.unescape(continuation) + continuation = URI.decode_www_form(continuation) data = IO::Memory.new(Base64.decode(continuation)) # 0xe2 0xa9 0x85 0xb2 0x02 @@ -916,7 +923,7 @@ def extract_channel_community_cursor(continuation) data.read_byte end - return URI.unescape(data.gets_to_end) + return URI.decode_www_form(data.gets_to_end) end def get_about_info(ucid, locale) @@ -927,6 +934,10 @@ def get_about_info(ucid, locale) about = client.get("/user/#{ucid}/about?disable_polymer=1&gl=US&hl=en") end + if md = about.headers["location"]?.try &.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) + raise ChannelRedirect.new(channel_id: md["ucid"]) + end + about = XML.parse_html(about.body) if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) @@ -940,12 +951,6 @@ def get_about_info(ucid, locale) raise error_message end - sub_count = about.xpath_node(%q(//span[contains(text(), "subscribers")])) - if sub_count - sub_count = sub_count.content.delete(", subscribers").to_i? - end - sub_count ||= 0 - author = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!.content author_url = about.xpath_node(%q(//span[contains(@class,"qualified-channel-title-text")]/a)).not_nil!["href"] author_thumbnail = about.xpath_node(%q(//img[@class="channel-header-profile-image"])).not_nil!["src"] @@ -989,21 +994,14 @@ def get_about_info(ucid, locale) ) end - total_views = 0_i64 - sub_count = 0_i64 + joined = about.xpath_node(%q(//span[contains(., "Joined")])) + .try &.content.try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - joined = Time.unix(0) - metadata = about.xpath_nodes(%q(//span[@class="about-stat"])) - metadata.each do |item| - case item.content - when .includes? "views" - total_views = item.content.gsub(/\D/, "").to_i64 - when .includes? "subscribers" - sub_count = item.content.delete("subscribers").gsub(/\D/, "").to_i64 - when .includes? "Joined" - joined = Time.parse(item.content.lchop("Joined "), "%b %-d, %Y", Time::Location.local) - end - end + total_views = about.xpath_node(%q(//span[contains(., "views")]/b)) + .try &.content.try &.gsub(/\D/, "").to_i64? || 0_i64 + + sub_count = about.xpath_node(%q(.//span[contains(@class, "subscriber-count")])) + .try &.["title"].try { |text| short_text_to_number(text) } || 0 # Auto-generated channels # https://support.google.com/youtube/answer/2579942 @@ -1015,7 +1013,7 @@ def get_about_info(ucid, locale) tabs = about.xpath_nodes(%q(//ul[@id="channel-navigation-menu"]/li/a/span)).map { |node| node.content.downcase } - return AboutChannel.new( + AboutChannel.new( ucid: ucid, author: author, auto_generated: auto_generated, diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 04ba6f5d..740449d3 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -573,7 +573,7 @@ def content_to_comment_html(content) end def extract_comment_cursor(continuation) - continuation = URI.unescape(continuation) + continuation = URI.decode_www_form(continuation) data = IO::Memory.new(Base64.decode(continuation)) # 0x12 0x26 @@ -653,7 +653,7 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top") end continuation = Base64.urlsafe_encode(data) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) return continuation end @@ -695,7 +695,7 @@ def produce_comment_reply_continuation(video_id, ucid, comment_id) data.write(Bytes[0x48, 0x0a]) continuation = Base64.urlsafe_encode(data.to_slice) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) return continuation end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 56c1c488..949eb335 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -95,8 +95,8 @@ class AuthHandler < Kemal::Handler begin if token = env.request.headers["Authorization"]? - token = JSON.parse(URI.unescape(token.lchop("Bearer "))) - session = URI.unescape(token["session"].as_s) + token = JSON.parse(URI.decode_www_form(token.lchop("Bearer "))) + session = URI.decode_www_form(token["session"].as_s) scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String) @@ -238,40 +238,15 @@ class HTTP::Client end end -# https://github.com/will/crystal-pg/pull/171 -class PG::Statement < ::DB::Statement - protected def perform_query(args : Enumerable) : ResultSet - params = args.map { |arg| PQ::Param.encode(arg) } - conn = self.conn - conn.send_parse_message(@sql) - conn.send_bind_message params - conn.send_describe_portal_message - conn.send_execute_message - conn.send_sync_message - conn.expect_frame PQ::Frame::ParseComplete - conn.expect_frame PQ::Frame::BindComplete - frame = conn.read - case frame - when PQ::Frame::RowDescription - fields = frame.fields - when PQ::Frame::NoData - fields = nil - else - raise "expected RowDescription or NoData, got #{frame}" - end - ResultSet.new(self, fields) - rescue IO::Error - raise DB::ConnectionLost.new(connection) - end +struct Crystal::ThreadLocalValue(T) + @values = Hash(Thread, T).new - protected def perform_exec(args : Enumerable) : ::DB::ExecResult - result = perform_query(args) - result.each { } - ::DB::ExecResult.new( - rows_affected: result.rows_affected, - last_insert_id: 0_i64 # postgres doesn't support this - ) - rescue IO::Error - raise DB::ConnectionLost.new(connection) + def get(&block : -> T) + th = Thread.current + if !@values[th]? + @values[th] = yield + else + @values[th] + end end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 96857551..67ba8167 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -236,6 +236,8 @@ struct Config hsts: {type: Bool?, default: true}, # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely disable_proxy: {type: Bool? | Array(String)?, default: false}, # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' force_resolve: {type: Socket::Family, default: Socket::Family::UNSPEC, converter: FamilyConverter}, # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) + port: {type: Int32, default: 3000}, # Port to listen for connections (overrided by command line argument) + host_binding: {type: String, default: "0.0.0.0"}, # Host to bind (overrided by command line argument) }) end @@ -416,19 +418,19 @@ def extract_items(nodeset, ucid = nil, author_name = nil) author_thumbnail ||= "" - subscriber_count = node.xpath_node(%q(.//span[contains(@class, "yt-subscriber-count")])).try &.["title"].gsub(/\D/, "").to_i? - subscriber_count ||= 0 + subscriber_count = node.xpath_node(%q(.//span[contains(@class, "subscriber-count")])) + .try &.["title"].try { |text| short_text_to_number(text) } || 0 video_count = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li)).try &.content.split(" ")[0].gsub(/\D/, "").to_i? - video_count ||= 0 items << SearchChannel.new( author: author, ucid: ucid, author_thumbnail: author_thumbnail, subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html + video_count: video_count || 0, + description_html: description_html, + auto_generated: video_count ? false : true, ) else id = id.lchop("/watch?v=") diff --git a/src/invidious/helpers/patch_mapping.cr b/src/invidious/helpers/patch_mapping.cr index e138aa1c..19bd8ca1 100644 --- a/src/invidious/helpers/patch_mapping.cr +++ b/src/invidious/helpers/patch_mapping.cr @@ -50,7 +50,7 @@ macro patched_json_mapping(_properties_, strict = false) rescue exc : ::JSON::ParseException raise ::JSON::MappingError.new(exc.message, self.class.to_s, nil, *%location, exc) end - while %pull.kind != :end_object + until %pull.kind.end_object? %key_location = %pull.location key = %pull.read_object_key case key diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index 87edbcd3..20d92b9c 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -119,7 +119,7 @@ module Kemal config = Kemal.config.serve_static original_path = context.request.path.not_nil! - request_path = URI.unescape(original_path) + request_path = URI.decode_www_form(original_path) # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index f946fc2c..30f7d4f4 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -69,7 +69,7 @@ end def validate_request(token, session, request, key, db, locale = nil) case token when String - token = JSON.parse(URI.unescape(token)).as_h + token = JSON.parse(URI.decode_www_form(token)).as_h when JSON::Any token = token.as_h when Nil diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 5a813486..ed55dc9c 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -157,7 +157,7 @@ def number_with_separator(number) number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse end -def short_text_to_number(short_text) +def short_text_to_number(short_text : String) : Int32 case short_text when .ends_with? "M" number = short_text.rstrip(" mM").to_f @@ -246,7 +246,7 @@ def get_referer(env, fallback = "/", unroll = true) if referer.query params = HTTP::Params.parse(referer.query.not_nil!) if params["referer"]? - referer = URI.parse(URI.unescape(params["referer"])) + referer = URI.parse(URI.decode_www_form(params["referer"])) else break end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7965d990..a5383daf 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -172,7 +172,7 @@ def produce_playlist_url(id, index) continuation.print data data = Base64.urlsafe_encode(continuation) - cursor = URI.escape(data) + cursor = URI.encode_www_form(data) data = IO::Memory.new @@ -193,7 +193,7 @@ def produce_playlist_url(id, index) IO.copy data, buffer continuation = Base64.urlsafe_encode(buffer) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" diff --git a/src/invidious/search.cr b/src/invidious/search.cr index a55bb216..10475a8f 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -185,8 +185,10 @@ struct SearchChannel end end + json.field "autoGenerated", self.auto_generated json.field "subCount", self.subscriber_count json.field "videoCount", self.video_count + json.field "description", html_to_content(self.description_html) json.field "descriptionHtml", self.description_html end @@ -209,6 +211,7 @@ struct SearchChannel subscriber_count: Int32, video_count: Int32, description_html: String, + auto_generated: Bool, }) end @@ -263,7 +266,7 @@ def search(query, page = 1, search_params = produce_search_params(content_type: return {0, [] of SearchItem} end - html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&hl=en&disable_polymer=1").body + html = client.get("/results?q=#{URI.encode_www_form(query)}&page=#{page}&sp=#{search_params}&hl=en&disable_polymer=1").body if html.empty? return {0, [] of SearchItem} end @@ -368,7 +371,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte end token = Base64.urlsafe_encode(token.to_slice) - token = URI.escape(token) + token = URI.encode_www_form(token) return token end @@ -393,7 +396,7 @@ def produce_channel_search_url(ucid, query, page) data.rewind data = Base64.urlsafe_encode(data) - continuation = URI.escape(data) + continuation = URI.encode_www_form(data) data = IO::Memory.new @@ -418,7 +421,7 @@ def produce_channel_search_url(ucid, query, page) IO.copy data, buffer continuation = Base64.urlsafe_encode(buffer) - continuation = URI.escape(continuation) + continuation = URI.encode_www_form(continuation) url = "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 32908157..26db51ea 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -42,7 +42,7 @@ end def extract_plid(url) wrapper = HTTP::Params.parse(URI.parse(url).query.not_nil!)["bp"] - wrapper = URI.unescape(wrapper) + wrapper = URI.decode_www_form(wrapper) wrapper = Base64.decode(wrapper) # 0xe2 0x02 0x2e diff --git a/src/invidious/users.cr b/src/invidious/users.cr index cd2e7808..3d28e582 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -108,7 +108,7 @@ def get_user(sid, headers, db, refresh = true) args = arg_array(user_array) db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) + ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) @@ -127,7 +127,7 @@ def get_user(sid, headers, db, refresh = true) args = arg_array(user.to_a) db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", user_array) + ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) @@ -296,7 +296,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) args = arg_array(notifications) - notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", notifications, as: ChannelVideo) + notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo) videos = [] of ChannelVideo notifications.sort_by! { |video| video.published }.reverse! diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 7b68656e..e175ae39 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -316,10 +316,10 @@ struct Video json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix end - if self.player_response["streamingData"]?.try &.["hlsManifestUrl"]? + if player_response["streamingData"]?.try &.["hlsManifestUrl"]? host_url = make_host_url(config, kemal_config) - hlsvp = self.player_response["streamingData"]["hlsManifestUrl"].as_s + hlsvp = player_response["streamingData"]["hlsManifestUrl"].as_s hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url) json.field "hlsUrl", hlsvp @@ -408,7 +408,7 @@ struct Video json.object do json.field "label", caption.name.simpleText json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" end end end @@ -489,7 +489,7 @@ struct Video end def live_now - live_now = self.player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool + live_now = player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool if live_now.nil? return false @@ -536,7 +536,7 @@ struct Video end def keywords - keywords = self.player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a + keywords = player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a keywords ||= [] of String return keywords @@ -545,7 +545,7 @@ struct Video def fmt_stream(decrypt_function) streams = [] of HTTP::Params - if fmt_streams = self.player_response["streamingData"]?.try &.["formats"]? + if fmt_streams = player_response["streamingData"]?.try &.["formats"]? fmt_streams.as_a.each do |fmt_stream| if !fmt_stream.as_h? next @@ -619,7 +619,7 @@ struct Video def adaptive_fmts(decrypt_function) adaptive_fmts = [] of HTTP::Params - if fmts = self.player_response["streamingData"]?.try &.["adaptiveFormats"]? + if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]? fmts.as_a.each do |adaptive_fmt| if !adaptive_fmt.as_h? next @@ -712,12 +712,12 @@ struct Video end def storyboards - storyboards = self.player_response["storyboards"]? + storyboards = player_response["storyboards"]? .try &.as_h .try &.["playerStoryboardSpecRenderer"]? if !storyboards - storyboards = self.player_response["storyboards"]? + storyboards = player_response["storyboards"]? .try &.as_h .try &.["playerLiveStoryboardSpecRenderer"]? @@ -784,13 +784,8 @@ struct Video end def paid - reason = self.player_response["playabilityStatus"]?.try &.["reason"]? - - if reason == "This video requires payment to watch." - paid = true - else - paid = false - end + reason = player_response["playabilityStatus"]?.try &.["reason"]? + paid = reason == "This video requires payment to watch." ? true : false return paid end @@ -836,7 +831,7 @@ struct Video end def length_seconds - self.player_response["videoDetails"]["lengthSeconds"].as_s.to_i + player_response["videoDetails"]["lengthSeconds"].as_s.to_i end db_mapping({ @@ -882,6 +877,10 @@ struct CaptionName end class VideoRedirect < Exception + property video_id : String + + def initialize(@video_id) + end end def get_video(id, db, refresh = true, region = nil, force_refresh = false) @@ -901,7 +900,7 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false) db.exec("UPDATE videos SET (info,updated,title,views,likes,dislikes,wilson_score,\ published,description,language,author,ucid,allowed_regions,is_family_friendly,\ genre,genre_url,license,sub_count_text,author_thumbnail)\ - = (#{args}) WHERE id = $1", video_array) + = (#{args}) WHERE id = $1", args: video_array) rescue ex db.exec("DELETE FROM videos * WHERE id = $1", id) raise ex @@ -914,7 +913,7 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false) args = arg_array(video_array) if !region - db.exec("INSERT INTO videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", video_array) + db.exec("INSERT INTO videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", args: video_array) end end @@ -931,6 +930,9 @@ def extract_recommended(recommended_videos) recommended_video = HTTP::Params.new recommended_video["id"] = video_renderer["videoId"].as_s recommended_video["title"] = video_renderer["title"]["simpleText"].as_s + + next if !video_renderer["shortBylineText"]? + recommended_video["author"] = video_renderer["shortBylineText"]["runs"].as_a[0]["text"].as_s recommended_video["ucid"] = video_renderer["shortBylineText"]["runs"].as_a[0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s recommended_video["author_thumbnail"] = video_renderer["channelThumbnail"]["thumbnails"][0]["url"].as_s @@ -1149,7 +1151,7 @@ def fetch_video(id, region) response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") if md = response.headers["location"]?.try &.match(/v=(?[a-zA-Z0-9_-]{11})/) - raise VideoRedirect.new(md["id"]) + raise VideoRedirect.new(video_id: md["id"]) end html = XML.parse_html(response.body) @@ -1203,6 +1205,11 @@ def fetch_video(id, region) player_json = JSON.parse(info["player_response"]) + reason = player_json["playabilityStatus"]?.try &.["reason"]?.try &.as_s + if reason == "This video is not available." + raise "This video is not available." + end + title = player_json["videoDetails"]["title"].as_s author = player_json["videoDetails"]["author"]?.try &.as_s || "" ucid = player_json["videoDetails"]["channelId"]?.try &.as_s || "" @@ -1255,7 +1262,7 @@ def fetch_video(id, region) end license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || "" - sub_count_text = html.xpath_node(%q(//span[contains(@class, "yt-subscriber-count")])).try &.["title"]? || "0" + sub_count_text = html.xpath_node(%q(//span[contains(@class, "subscriber-count")])).try &.["title"]? || "0" author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || "" video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html, diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr index 53b8f001..8ea99010 100644 --- a/src/invidious/views/authorize_token.ecr +++ b/src/invidious/views/authorize_token.ecr @@ -72,7 +72,7 @@ <% end %> - + <% end %> diff --git a/src/invidious/views/change_password.ecr b/src/invidious/views/change_password.ecr index 2e68556b..fb558f1d 100644 --- a/src/invidious/views/change_password.ecr +++ b/src/invidious/views/change_password.ecr @@ -6,7 +6,7 @@
-
+ <%= translate(locale, "Change password") %>
@@ -23,7 +23,7 @@ <%= translate(locale, "Change password") %> - +
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 1074598d..b5eb46ea 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -34,7 +34,7 @@
<% ucid = channel.ucid %> <% author = channel.author %> - <% sub_count_text = channel.sub_count.format %> + <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %>
diff --git a/src/invidious/views/clear_watch_history.ecr b/src/invidious/views/clear_watch_history.ecr index 2bb9884c..5f9d1032 100644 --- a/src/invidious/views/clear_watch_history.ecr +++ b/src/invidious/views/clear_watch_history.ecr @@ -3,7 +3,7 @@ <% end %>
-
+ <%= translate(locale, "Clear watch history?") %>
@@ -13,12 +13,12 @@
- +
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 9d086b5d..218cc2d4 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -33,7 +33,7 @@
<% ucid = channel.ucid %> <% author = channel.author %> - <% sub_count_text = channel.sub_count.format %> + <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 71ae70df..d78d8c4b 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -11,7 +11,7 @@

<%= item.author %>

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

-

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

+ <% if !item.auto_generated %>

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

<% end %>
<%= item.description_html %>
<% when SearchPlaylist %> <% if item.id.starts_with? "RD" %> @@ -91,7 +91,7 @@ <% if env.get? "show_watched" %>
" method="post"> - "> + ">

@@ -11,7 +11,7 @@ <% else %>

" method="post"> - "> + "> @@ -24,7 +24,7 @@ ucid: '<%= ucid %>', author: '<%= HTML.escape(author) %>', sub_count_text: '<%= HTML.escape(sub_count_text) %>', - csrf_token: '<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>', + csrf_token: '<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>', subscribe_text: '<%= HTML.escape(translate(locale, "Subscribe")) %>', unsubscribe_text: '<%= HTML.escape(translate(locale, "Unsubscribe")) %>' } diff --git a/src/invidious/views/data_control.ecr b/src/invidious/views/data_control.ecr index 6be54710..4cfe4e25 100644 --- a/src/invidious/views/data_control.ecr +++ b/src/invidious/views/data_control.ecr @@ -11,7 +11,7 @@ <%= translate(locale, "and upload the file below.") %> - +

<%= translate(locale, "Import") %> diff --git a/src/invidious/views/delete_account.ecr b/src/invidious/views/delete_account.ecr index 0fa1e77c..9103d5b7 100644 --- a/src/invidious/views/delete_account.ecr +++ b/src/invidious/views/delete_account.ecr @@ -3,7 +3,7 @@ <% end %> - + diff --git a/src/invidious/views/history.ecr b/src/invidious/views/history.ecr index e5154560..7d7ded2c 100644 --- a/src/invidious/views/history.ecr +++ b/src/invidious/views/history.ecr @@ -20,7 +20,7 @@ @@ -35,7 +35,7 @@ var watched_data = {
" method="post"> - "> + ">