diff --git a/.travis.yml b/.travis.yml index f5918bb1..314abc73 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,18 +3,24 @@ 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: - 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 - stage: build_docker + # TODO: Shallowly clone again once the .git folder is no longer required for building + git: + depth: false language: minimal services: - docker 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/assets/css/darktheme.css b/assets/css/darktheme.css index 92090494..405014a2 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/css/default.css b/assets/css/default.css index 8f2308be..94bff2eb 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -168,6 +168,7 @@ img.thumbnail { .navbar .index-link { font-weight: bold; + display: inline; } .navbar > .searchbar .pure-form input[type="search"] { @@ -319,6 +320,10 @@ input[type="search"]::-webkit-search-cancel-button { } } +ul.vjs-menu-content::-webkit-scrollbar { + display: none; +} + .vjs-user-inactive { cursor: none; } @@ -367,6 +372,11 @@ input[type="search"]::-webkit-search-cancel-button { .vjs-control-bar { display: flex; flex-direction: row; + scrollbar-width: none; +} + +.vjs-control-bar::-webkit-scrollbar { + display: none; } .video-js .vjs-icon-cog { @@ -423,6 +433,7 @@ span > select { /* ProgressBar marker */ .vjs-marker { background-color: rgba(255, 255, 255, 1); + z-index: 0; } /* Big "Play" Button */ diff --git a/assets/js/embed.js b/assets/js/embed.js index d9af1f5b..534c30ff 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); }); } @@ -65,32 +69,34 @@ function get_playlist(plid, retries) { xhr.send(); } -if (video_data.plid) { - get_playlist(video_data.plid); -} else if (video_data.video_series) { - player.on('ended', function () { - var url = new URL('https://example.com/embed/' + video_data.video_series.shift()); +window.addEventListener('load', function (e) { + if (video_data.plid) { + get_playlist(video_data.plid); + } else if (video_data.video_series) { + player.on('ended', function () { + var url = new URL('https://example.com/embed/' + video_data.video_series.shift()); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { - url.searchParams.set('autoplay', '1'); - } + if (video_data.params.autoplay || video_data.params.continue_autoplay) { + url.searchParams.set('autoplay', '1'); + } - if (video_data.params.listen !== video_data.preferences.listen) { - url.searchParams.set('listen', video_data.params.listen); - } + if (video_data.params.listen !== video_data.preferences.listen) { + url.searchParams.set('listen', video_data.params.listen); + } - if (video_data.params.speed !== video_data.preferences.speed) { - url.searchParams.set('speed', video_data.params.speed); - } + if (video_data.params.speed !== video_data.preferences.speed) { + url.searchParams.set('speed', video_data.params.speed); + } - if (video_data.params.local !== video_data.preferences.local) { - url.searchParams.set('local', video_data.params.local); - } + if (video_data.params.local !== video_data.preferences.local) { + url.searchParams.set('local', video_data.params.local); + } - if (video_data.video_series.length !== 0) { - url.searchParams.set('playlist', video_data.video_series.join(',')) - } + if (video_data.video_series.length !== 0) { + url.searchParams.set('playlist', video_data.video_series.join(',')) + } - location.assign(url.pathname + url.search); - }); -} + location.assign(url.pathname + url.search); + }); + } +}); diff --git a/assets/js/player.js b/assets/js/player.js index 0d0ecebd..e58af0cd 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -151,45 +151,47 @@ player.vttThumbnails({ // Enable annotations if (!video_data.params.listen && video_data.params.annotations) { - var video_container = document.getElementById('player'); - let xhr = new XMLHttpRequest(); - xhr.responseType = 'text'; - xhr.timeout = 60000; - xhr.open('GET', '/api/v1/annotations/' + video_data.id, true); + window.addEventListener('load', function (e) { + var video_container = document.getElementById('player'); + let xhr = new XMLHttpRequest(); + xhr.responseType = 'text'; + xhr.timeout = 60000; + xhr.open('GET', '/api/v1/annotations/' + video_data.id, true); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); - if (!player.paused()) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - } else { - player.one('play', function (event) { + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); + if (!player.paused()) { player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - }); + } else { + player.one('play', function (event) { + player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); + }); + } } } } - } - window.addEventListener('__ar_annotation_click', e => { - const { url, target, seconds } = e.detail; - var path = new URL(url); + window.addEventListener('__ar_annotation_click', e => { + const { url, target, seconds } = e.detail; + var path = new URL(url); - if (path.href.startsWith('https://www.youtube.com/watch?') && seconds) { - path.search += '&t=' + seconds; - } + if (path.href.startsWith('https://www.youtube.com/watch?') && seconds) { + path.search += '&t=' + seconds; + } - path = path.pathname + path.search; + path = path.pathname + path.search; - if (target === 'current') { - window.location.href = path; - } else if (target === 'new') { - window.open(path, '_blank'); - } + if (target === 'current') { + window.location.href = path; + } else if (target === 'new') { + window.open(path, '_blank'); + } + }); + + xhr.send(); }); - - xhr.send(); } function increase_volume(delta) { @@ -234,25 +236,25 @@ function toggle_play() { } } -const toggle_captions = (function() { +const toggle_captions = (function () { let toggledTrack = null; - const onChange = function(e) { + const onChange = function (e) { toggledTrack = null; }; - const bindChange = function(onOrOff) { + const bindChange = function (onOrOff) { player.textTracks()[onOrOff]('change', onChange); }; // Wrapper function to ignore our own emitted events and only listen // to events emitted by Video.js on click on the captions menu items. - const setMode = function(track, mode) { + const setMode = function (track, mode) { bindChange('off'); track.mode = mode; - window.setTimeout(function() { + window.setTimeout(function () { bindChange('on'); }, 0); }; bindChange('on'); - return function() { + return function () { if (toggledTrack !== null) { if (toggledTrack.mode !== 'showing') { setMode(toggledTrack, 'showing'); @@ -323,95 +325,95 @@ window.addEventListener('keydown', e => { || e.target === document.querySelector('.vjs-tech') || e.target === document.querySelector('.iframeblocker') || e.target === document.querySelector('.vjs-control-bar') - ; + ; let action = null; const code = e.keyCode; const decoratedKey = e.key - + (e.altKey ? '+alt' : '') + + (e.altKey ? '+alt' : '') + (e.ctrlKey ? '+ctrl' : '') + (e.metaKey ? '+meta' : '') - ; + ; switch (decoratedKey) { - case ' ': - case 'k': - action = toggle_play; - break; + case ' ': + case 'k': + action = toggle_play; + break; - case 'ArrowUp': - if (isPlayerFocused) { - action = increase_volume.bind(this, 0.1); - } - break; - case 'ArrowDown': - if (isPlayerFocused) { - action = increase_volume.bind(this, -0.1); - } - break; + case 'ArrowUp': + if (isPlayerFocused) { + action = increase_volume.bind(this, 0.1); + } + break; + case 'ArrowDown': + if (isPlayerFocused) { + action = increase_volume.bind(this, -0.1); + } + break; - case 'm': - action = toggle_muted; - break; + case 'm': + action = toggle_muted; + break; - case 'ArrowRight': - action = skip_seconds.bind(this, 5); - break; - case 'ArrowLeft': - action = skip_seconds.bind(this, -5); - break; - case 'l': - action = skip_seconds.bind(this, 10); - break; - case 'j': - action = skip_seconds.bind(this, -10); - break; + case 'ArrowRight': + action = skip_seconds.bind(this, 5); + break; + case 'ArrowLeft': + action = skip_seconds.bind(this, -5); + break; + case 'l': + action = skip_seconds.bind(this, 10); + break; + case 'j': + action = skip_seconds.bind(this, -10); + break; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - const percent = (code - 48) * 10; - action = set_time_percent.bind(this, percent); - break; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + const percent = (code - 48) * 10; + action = set_time_percent.bind(this, percent); + break; - case 'c': - action = toggle_captions; - break; - case 'f': - action = toggle_fullscreen; - break; + case 'c': + action = toggle_captions; + break; + case 'f': + action = toggle_fullscreen; + break; - case 'N': - action = next_video; - break; - case 'P': - // TODO: Add support to play back previous video. - break; + case 'N': + action = next_video; + break; + case 'P': + // TODO: Add support to play back previous video. + break; - case '.': - // TODO: Add support for next-frame-stepping. - break; - case ',': - // TODO: Add support for previous-frame-stepping. - break; + case '.': + // TODO: Add support for next-frame-stepping. + break; + case ',': + // TODO: Add support for previous-frame-stepping. + break; - case '>': - action = increase_playback_rate.bind(this, 1); - break; - case '<': - action = increase_playback_rate.bind(this, -1); - break; + case '>': + action = increase_playback_rate.bind(this, 1); + break; + case '<': + action = increase_playback_rate.bind(this, -1); + break; - default: - console.info('Unhandled key down event: %s:', decoratedKey, e); - break; + default: + console.info('Unhandled key down event: %s:', decoratedKey, e); + break; } if (action) { @@ -422,7 +424,7 @@ window.addEventListener('keydown', e => { // Add support for controlling the player volume by scrolling over it. Adapted from // https://github.com/ctd1500/videojs-hotkeys/blob/bb4a158b2e214ccab87c2e7b95f42bc45c6bfd87/videojs.hotkeys.js#L292-L328 -(function() { +(function () { const volumeStep = 0.05; const enableVolumeScroll = true; const enableHoverScroll = true; @@ -432,33 +434,33 @@ window.addEventListener('keydown', e => { var volumeHover = false; var volumeSelector = pEl.querySelector('.vjs-volume-menu-button') || pEl.querySelector('.vjs-volume-panel'); if (volumeSelector != null) { - volumeSelector.onmouseover = function() { volumeHover = true; }; - volumeSelector.onmouseout = function() { volumeHover = false; }; + volumeSelector.onmouseover = function () { volumeHover = true; }; + volumeSelector.onmouseout = function () { volumeHover = false; }; } var mouseScroll = function mouseScroll(event) { - var activeEl = doc.activeElement; - if (enableHoverScroll) { - // If we leave this undefined then it can match non-existent elements below - activeEl = 0; - } - - // When controls are disabled, hotkeys will be disabled as well - if (player.controls()) { - if (volumeHover) { - if (enableVolumeScroll) { - event = window.event || event; - var delta = Math.max(-1, Math.min(1, (event.wheelDelta || -event.detail))); - event.preventDefault(); - - if (delta == 1) { - increase_volume(volumeStep); - } else if (delta == -1) { - increase_volume(-volumeStep); - } - } + var activeEl = doc.activeElement; + if (enableHoverScroll) { + // If we leave this undefined then it can match non-existent elements below + activeEl = 0; + } + + // When controls are disabled, hotkeys will be disabled as well + if (player.controls()) { + if (volumeHover) { + if (enableVolumeScroll) { + event = window.event || event; + var delta = Math.max(-1, Math.min(1, (event.wheelDelta || -event.detail))); + event.preventDefault(); + + if (delta == 1) { + increase_volume(volumeStep); + } else if (delta == -1) { + increase_volume(-volumeStep); + } + } + } } - } }; player.on('mousewheel', mouseScroll); 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/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 22387d04..e4869564 100644 --- a/assets/js/silvermine-videojs-quality-selector.min.js +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -1,3 +1,3 @@ -/*! @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]); \ No newline at end of file diff --git a/assets/js/watch.js b/assets/js/watch.js index 0f3e8123..a26cb505 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); }); } @@ -435,19 +439,21 @@ if (video_data.play_next) { }); } -if (video_data.plid) { - get_playlist(video_data.plid); -} +window.addEventListener('load', function (e) { + if (video_data.plid) { + get_playlist(video_data.plid); + } -if (video_data.params.comments[0] === 'youtube') { - get_youtube_comments(); -} else if (video_data.params.comments[0] === 'reddit') { - get_reddit_comments(); -} else if (video_data.params.comments[1] === 'youtube') { - get_youtube_comments(); -} else if (video_data.params.comments[1] === 'reddit') { - get_reddit_comments(); -} else { - comments = document.getElementById('comments'); - comments.innerHTML = ''; -} + if (video_data.params.comments[0] === 'youtube') { + get_youtube_comments(); + } else if (video_data.params.comments[0] === 'reddit') { + get_reddit_comments(); + } else if (video_data.params.comments[1] === 'youtube') { + get_youtube_comments(); + } else if (video_data.params.comments[1] === 'reddit') { + get_reddit_comments(); + } else { + comments = document.getElementById('comments'); + comments.innerHTML = ''; + } +}); 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/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 diff --git a/locales/ar.json b/locales/ar.json index 09452eb2..c580a2d5 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` المشتركين", "`x` videos": "`x` الفيديوهات", + "`x` playlists": "`x` قوائم التشغيل", "LIVE": "مباشر", "Shared `x` ago": "تم رفع الفيديو منذ `x`", "Unsubscribe": "إلغاء الإشتراك", @@ -18,7 +19,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 +55,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 +66,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 +103,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 +119,7 @@ "unsubscribe": "إلغاء الإشتراك", "revoke": "مسح", "Subscriptions": "الإشتراكات", - "`x` unseen notifications": "`x` إشعارات لم تشاهدها بعد ", + "`x` unseen notifications": "`x` إشعارات لم تشاهدها بعد", "search": "بحث", "Log out": "تسجيل الخروج", "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", @@ -126,7 +127,17 @@ "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", "Trending": "الشائع", + "Public": "عام", "Unlisted": "غير مصنف", + "Private": "خاص", + "View all playlists": "عرض جميع قوائم التشغيل", + "Updated `x` ago": "تم تحديثه منذ `x`", + "Delete playlist `x`?": "حذف قائمه التشغيل `x` ?", + "Delete playlist": "حذف قائمه التغشيل", + "Create playlist": "إنشاء قائمه تشغيل", + "Title": "العنوان", + "Playlist privacy": "إعدادات الخصوصيه", + "Editing playlist `x`": "تعديل قائمه التشفيل `x`", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Hide annotations": "إخفاء الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات فى الفيديو", @@ -297,20 +308,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 +332,5 @@ "Videos": "الفيديوهات", "Playlists": "قوائم التشغيل", "Community": "المجتمع", - "Current version: ": "الإصدار الحالى" + "Current version: ": "الإصدار الحالي: " } diff --git a/locales/de.json b/locales/de.json index 97346168..c0ec648d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` Abonnenten", "`x` videos": "`x` Videos", + "`x` playlists": "", "LIVE": "LIVE", "Shared `x` ago": "Vor `x` geteilt", "Unsubscribe": "Abbestellen", @@ -129,7 +130,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", @@ -325,4 +336,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..5985a71c 100644 --- a/locales/el.json +++ b/locales/el.json @@ -7,6 +7,7 @@ "([^0-9]|^)1([^,0-9]|$)": "`x` βίντεο", "": "`x` βίντεο" }, + "`x` playlists": "", "LIVE": "ΖΩΝΤΑΝΑ", "Shared `x` ago": "Μοιράστηκε πριν `x`", "Unsubscribe": "Απεγγραφή", @@ -141,7 +142,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 524de4c5..ed6983a4 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", @@ -77,11 +81,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? ", @@ -114,9 +118,9 @@ "Feed menu: ": "Feed menu: ", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", - "Login enabled: ": "Login enabled? ", - "Registration enabled: ": "Registration enabled? ", - "Report statistics: ": "Report statistics? ", + "Login enabled: ": "Login enabled: ", + "Registration enabled: ": "Registration enabled: ", + "Report statistics: ": "Report statistics: ", "Save preferences": "Save preferences", "Subscription manager": "Subscription manager", "Token manager": "Token manager", @@ -144,7 +148,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", @@ -157,7 +171,7 @@ "Blacklisted regions: ": "Blacklisted regions: ", "Shared `x`": "Shared `x`", "`x` views": { - "([^0-9]|^)1([^,0-9]|$)": "`x` views", + "([^0-9]|^)1([^,0-9]|$)": "`x` view", "": "`x` views" }, "Premieres in `x`": "Premieres in `x`", @@ -165,7 +179,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", @@ -362,7 +379,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..07e3a2cf 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` abonantoj", "`x` videos": "`x` videoj", + "`x` playlists": "", "LIVE": "NUNA", "Shared `x` ago": "Konigita antaŭ `x`", "Unsubscribe": "Malaboni", @@ -126,7 +127,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 +333,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..31ffed3b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` suscriptores", "`x` videos": "`x` vídeos", + "`x` playlists": "", "LIVE": "DIRECTO", "Shared `x` ago": "Compartido hace `x`", "Unsubscribe": "Desuscribirse", @@ -126,7 +127,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 +333,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..352d84f1 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` harpidedun", "`x` videos": "`x` bideo", + "`x` playlists": "", "LIVE": "ZUZENEAN", "Shared `x` ago": "Duela `x` partekatua", "Unsubscribe": "Harpidetza kendu", @@ -126,7 +127,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": "", @@ -140,6 +151,7 @@ "Shared `x`": "", "`x` views": "", "Premieres in `x`": "", + "Premieres `x`": "", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", "View YouTube comments": "", "View more comments on Reddit": "", @@ -317,5 +329,8 @@ "`x` marked it with a ❤": "", "Audio mode": "", "Video mode": "", - "Videos": "" + "Videos": "", + "Playlists": "", + "Community": "", + "Current version: ": "" } \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index c3934701..928904c1 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` abonnés", "`x` videos": "`x` vidéos", + "`x` playlists": "`x` listes de lecture", "LIVE": "EN DIRECT", "Shared `x` ago": "Ajoutée il y a `x`", "Unsubscribe": "Se désabonner", @@ -13,10 +14,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 +30,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 +53,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: ": "Lancer la lecture automatiquement : ", + "Play next by default: ": "Lire les vidéos suivantes par défaut : ", + "Autoplay next video: ": "Lancer la lecture automatiquement pour la vidéo suivant la vidéo regardée : ", "Listen by default: ": "Audio uniquement : ", "Proxy videos: ": "Charger les vidéos à travers un proxy : ", "Default speed: ": "Vitesse par défaut : ", @@ -66,16 +67,16 @@ "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 : ", + "Dark mode: ": "Mode sombre : ", "Theme: ": "Thème : ", "dark": "sombre", "light": "clair", - "Thin mode: ": "Mode Simplifié : ", + "Thin mode: ": "Mode léger : ", "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,12 +86,12 @@ "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înes auxquelles vous êtes abonnés : ", + "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui 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", + "`x` uploaded a video": "`x` a partagé(e) une vidéo", "`x` is live": "`x` est en direct", "Data preferences": "Préférences liées aux données", "Clear watch history": "Supprimer l'historique des vidéos regardées", @@ -100,9 +101,9 @@ "Manage tokens": "Gérer les tokens", "Watch history": "Historique de visionnage", "Delete account": "Supprimer votre compte", - "Administrator preferences": "Préferences d'Administrateur", + "Administrator preferences": "Préferences d'Administration", "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é : ", @@ -122,19 +123,29 @@ "search": "rechercher", "Log out": "Déconnexion", "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", - "Source available here.": "Code Source disponible ici.", + "Source available here.": "Code source disponible ici.", "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", "Trending": "Tendances", + "Public": "Publique", "Unlisted": "Non répertoriée", + "Private": "Privée", + "View all playlists": "Voir toutes vos playlists", + "Updated `x` ago": "Dernière mise à jour il y a `x`", + "Delete playlist `x`?": "Êtes-vous sûr de vouloir supprimer la liste de lecture ?", + "Delete playlist": "Supprimer la liste de lecture", + "Create playlist": "Créer une liste de lecture", + "Title": "Titre", + "Playlist privacy": "Paramètres de confidentialité de la liste de lecture", + "Editing playlist `x`": "Liste de lecture modifier le `x`", "Watch on YouTube": "Voir la vidéo sur Youtube", "Hide annotations": "Masquer les annotations", "Show annotations": "Afficher les annotations", "Genre: ": "Genre : ", "License: ": "Licence : ", - "Family friendly? ": "Tout Public ? ", + "Family friendly? ": "Vidéo tout public ? ", "Wilson score: ": "Score de Wilson : ", - "Engagement: ": "Poucentage de spectateur aillant Like ou Dislike la vidéo : ", + "Engagement: ": "Pourcentage de spectateur aillant appuyé sur \"J'aime\" ou \"J'aime Pas\" : ", "Whitelisted regions: ": "Régions sur liste blanche : ", "Blacklisted regions: ": "Régions sur liste noire : ", "Shared `x`": "Ajoutée le `x`", @@ -149,8 +160,8 @@ "Hide replies": "Masquer les réponses", "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.", + "Quota exceeded, try again in a few hours": "Nombre de tentative de connexion dépassée, réessayez dans quelques heures", + "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", @@ -171,17 +182,17 @@ "Could not fetch comments": "Impossible de charger les commentaires", "View `x` replies": "Voir `x` réponses", "`x` ago": "il y a `x`", - "Load more": "Charger plus", + "Load more": "Voir plus", "`x` points": "`x` points", "Could not create mix.": "Impossible de charger cette liste de lecture.", - "Empty playlist": "Liste de lecture vide", - "Not a playlist.": "Liste de lecture invalide.", + "Empty playlist": "La liste de lecture est vide", + "Not a playlist.": "La liste de lecture est 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é", + "Erroneous challenge": "Challenge invalide", + "Erroneous token": "Token invalide", "No such user": "Cet utilisateur n'existe pas", "Token is expired, please try again": "Le token est expiré, veuillez réessayer", "English": "Anglais", @@ -314,12 +325,12 @@ "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(modifié)", "YouTube comment permalink": "Lien permanent vers le commentaire sur YouTube", - "permalink": "permalien", + "permalink": "Lien permanent", "`x` marked it with a ❤": "`x` l'a marqué d'un ❤", - "Audio mode": "Mode Audio", - "Video mode": "Mode Vidéo", + "Audio mode": "Mode audio", + "Video mode": "Mode vidéo", "Videos": "Vidéos", - "Playlists": "Liste de lecture", + "Playlists": "Listes de lecture", "Community": "Communauté", "Current version: ": "Version actuelle : " } diff --git a/locales/is.json b/locales/is.json index 808063c4..4cd15076 100644 --- a/locales/is.json +++ b/locales/is.json @@ -1,4 +1,7 @@ { + "`x` subscribers": "", + "`x` videos": "", + "`x` playlists": "", "`x` subscribers.": "`x` áskrifandar.", "`x` videos.": "`x` myndbönd.", "LIVE": "BEINT", @@ -110,10 +113,13 @@ "Report statistics: ": "Skrá talnagögn? ", "Save preferences": "Vista stillingar", "Subscription manager": "Áskriftarstjóri", + "`x` subscriptions": "", + "`x` tokens": "", "Token manager": "Táknstjóri", "Token": "Tákn", "`x` subscriptions.": "`x` áskriftir.", "`x` tokens.": "`x` tákn.", + "`x` unseen notifications": "", "Import/export": "Flytja inn/út", "unsubscribe": "afskrá", "revoke": "afturkalla", @@ -126,13 +132,24 @@ "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", "Genre: ": "Tegund: ", "License: ": "Notkunarleyfi: ", "Family friendly? ": "Fjölskylduvænt? ", + "`x` views": "", "Wilson score: ": "Wilson stig: ", "Engagement: ": "Þátttöku: ", "Whitelisted regions: ": "Svæði á hvítum lista: ", @@ -163,8 +180,10 @@ "Password cannot be empty": "Lykilorð má ekki vera autt", "Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir", "Please log in": "Vinsamlegast skráðu þig inn", + "View `x` replies": "", "Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`", "channel:`x`": "rás:`x`", + "`x` points": "", "Deleted or invalid channel": "Eytt eða ógild rás", "This channel does not exist.": "Þessi rás er ekki til.", "Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.", @@ -282,6 +301,13 @@ "Turkish": "Tyrkneska", "Ukrainian": "Úkraníska", "Urdu": "Úrdú", + "`x` years": "", + "`x` months": "", + "`x` weeks": "", + "`x` days": "", + "`x` hours": "", + "`x` minutes": "", + "`x` seconds": "", "Uzbek": "Úsbekíska", "Vietnamese": "Víetnamska", "Welsh": "Velska", @@ -299,11 +325,13 @@ "`x` seconds.": "`x` sekúndur.", "Fallback comments: ": "Vara ummæli: ", "Popular": "Vinsælt", + "permalink": "", "Top": "Topp", "About": "Um", "Rating: ": "Einkunn: ", "Language: ": "Tungumál: ", "View as playlist": "Skoða sem spilunarlista", + "Community": "", "Default": "Sjálfgefið", "Music": "Tónlist", "Gaming": "Tólvuleikja", @@ -320,4 +348,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..15d732a6 100644 --- a/locales/it.json +++ b/locales/it.json @@ -7,6 +7,7 @@ "([^0-9]|^)1([^,0-9]|$)": "`x` video", "": "`x` video" }, + "`x` playlists": "", "LIVE": "IN DIRETTA", "Shared `x` ago": "Condiviso `x` fa", "Unsubscribe": "Disiscriviti", @@ -141,7 +142,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..d3bce6ac 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` abonnenter", "`x` videos": "`x` videoer", + "`x` playlists": "", "LIVE": "SANNTIDSVISNING", "Shared `x` ago": "Delt for `x` siden", "Unsubscribe": "Opphev abonnement", @@ -126,7 +127,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 +333,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..cb233cea 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` abonnees", "`x` videos": "`x` video's", + "`x` playlists": "", "LIVE": "LIVE", "Shared `x` ago": "Gedeeld: `x` geleden", "Unsubscribe": "Deabonneren", @@ -126,7 +127,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..74847608 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` subskrybcji", "`x` videos": "`x` filmów", + "`x` playlists": "", "LIVE": "NA ŻYWO", "Shared `x` ago": "Udostępniono `x` temu", "Unsubscribe": "Odsubskrybuj", @@ -126,7 +127,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..29eb0197 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` подписчиков", "`x` videos": "`x` видео", + "`x` playlists": "", "LIVE": "ПРЯМОЙ ЭФИР", "Shared `x` ago": "Опубликовано `x` назад", "Unsubscribe": "Отписаться", @@ -126,7 +127,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/tr.json b/locales/tr.json index c47170b9..604d5afd 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -1,4 +1,7 @@ { + "`x` subscribers": "", + "`x` videos": "", + "`x` playlists": "", "`x` subscribers.": "`x` abone.", "`x` videos.": "`x` video.", "LIVE": "CANLI", @@ -110,10 +113,13 @@ "Report statistics: ": "Rapor istatistikleri: ", "Save preferences": "Tercihleri kaydet", "Subscription manager": "Abonelik yöneticisi", + "`x` subscriptions": "", + "`x` tokens": "", "Token manager": "Jeton yöneticisi", "Token": "Jeton", "`x` subscriptions.": "`x` abonelik.", "`x` tokens.": "`x` jeton.", + "`x` unseen notifications": "", "Import/export": "İçe/dışa aktar", "unsubscribe": "abonelikten çık", "revoke": "geri al", @@ -121,7 +127,17 @@ "`x` unseen notifications.": "`x` okunmamış bildirim.", "search": "ara", "Log out": "Çıkış yap", + "Public": "", "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "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.", @@ -133,6 +149,7 @@ "Genre: ": "Tür: ", "License: ": "Lisans: ", "Family friendly? ": "Aile için uygun? ", + "`x` views": "", "Wilson score: ": "Wilson puanı: ", "Engagement: ": "İzleyenlerin oy verme oranı: ", "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", @@ -163,8 +180,10 @@ "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", + "View `x` replies": "", "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", "channel:`x`": "kanal:`x`", + "`x` points": "", "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ı.", diff --git a/locales/uk.json b/locales/uk.json index e537008c..0f8aa1b6 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` підписників", "`x` videos": "`x` відео", + "`x` playlists": "", "LIVE": "ПРЯМИЙ ЕФІР", "Shared `x` ago": "Розміщено `x` назад", "Unsubscribe": "Відписатися", @@ -126,7 +127,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..ed8090ba 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1,6 +1,7 @@ { "`x` subscribers": "`x` 订阅者", "`x` videos": "`x` 视频", + "`x` playlists": "", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", "Unsubscribe": "取消订阅", @@ -126,7 +127,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-TW.json b/locales/zh-TW.json new file mode 100644 index 00000000..0b0451bc --- /dev/null +++ b/locales/zh-TW.json @@ -0,0 +1,381 @@ +{ + "`x` subscribers": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 個訂閱者", + "": "`x` 個訂閱者" + }, + "`x` videos": { + "([^0-9]|^)1([^,0-9]|$)": "`x` 部影片", + "": "`x` 部影片" + }, + "`x` playlists": "", + "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": "趨勢", + "Public": "公開", + "Unlisted": "未列出", + "Private": "私人", + "View all playlists": "檢視所有播放清單", + "Updated `x` ago": "更新於 `x` 之前", + "Delete playlist `x`?": "刪除播放清單", + "Delete playlist": "刪除播放清單", + "Create playlist": "建立播放清單", + "Title": "標題", + "Playlist privacy": "播放清單隱私", + "Editing playlist `x`": "已編輯播放清單 `x`", + "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: ": "目前版本:" +} 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.cr b/src/invidious.cr index 6fc11356..6b941496 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"), } DISMISSALS = [ @@ -129,15 +130,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 @@ -251,7 +256,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 @@ -265,7 +277,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 @@ -278,10 +297,8 @@ before_all do |env| end dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s - thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s thin_mode = thin_mode == "true" - locale = env.params.query["hl"]? || preferences.locale preferences.dark_mode = dark_mode @@ -304,21 +321,21 @@ before_all do |env| end get "/" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + preferences = env.get("preferences").as(Preferences) + locale = LOCALES[preferences.locale]? user = env.get? "user" - if user - user = user.as(User) - if user.preferences.redirect_feed - next env.redirect "/feed/subscriptions" - end - end - - case config.default_home + case preferences.default_home + when "" + templated "empty" when "Popular" templated "popular" when "Top" - templated "top" + if config.top_enabled + templated "top" + else + templated "empty" + end when "Trending" env.redirect "/feed/trending" when "Subscriptions" @@ -327,6 +344,12 @@ get "/" do |env| else templated "popular" end + when "Playlists" + if user + env.redirect "/view_all_playlists" + else + templated "popular" + end end end @@ -374,6 +397,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" @@ -472,7 +497,7 @@ get "/watch" do |env| # Older videos may not have audio sources available. # We redirect here so they're not unplayable - if audio_streams.empty? + if audio_streams.empty? && !video.live_now if params.quality == "dash" env.params.query.delete_all("quality") env.params.query["quality"] = "medium" @@ -558,7 +583,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 @@ -580,7 +607,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})*/) @@ -610,7 +639,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 @@ -706,14 +737,14 @@ get "/embed/:id" do |env| video_streams = video.video_streams(adaptive_fmts) audio_streams = video.audio_streams(adaptive_fmts) - if audio_streams.empty? + if audio_streams.empty? && !video.live_now if params.quality == "dash" env.params.query.delete_all("quality") - next env.redirect "/embed/#{video_id}?#{env.params.query}" + next env.redirect "/embed/#{id}?#{env.params.query}" elsif params.listen env.params.query.delete_all("listen") env.params.query["listen"] = "0" - next env.redirect "/embed/#{video_id}?#{env.params.query}" + next env.redirect "/embed/#{id}?#{env.params.query}" end end @@ -760,10 +791,451 @@ end # Playlists +get "/feed/playlists" do |env| + env.redirect "/view_all_playlists" +end + +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 @@ -776,19 +1248,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 @@ -867,72 +1349,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 @@ -1543,9 +1966,15 @@ post "/preferences" do |env| related_videos ||= "off" related_videos = related_videos == "on" - redirect_feed = env.params.body["redirect_feed"]?.try &.as(String) - redirect_feed ||= "off" - redirect_feed = redirect_feed == "on" + default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home + + feed_menu = [] of String + 5.times do |index| + option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || "" + if !option.empty? + feed_menu << option + end + end locale = env.params.body["locale"]?.try &.as(String) locale ||= CONFIG.default_user_preferences.locale @@ -1593,7 +2022,8 @@ post "/preferences" do |env| notifications_only: notifications_only, player_style: player_style, quality: quality, - redirect_feed: redirect_feed, + default_home: default_home, + feed_menu: feed_menu, related_videos: related_videos, sort: sort, speed: speed, @@ -1608,16 +2038,16 @@ post "/preferences" do |env| PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) if config.admins.includes? user.email - config.default_home = env.params.body["default_home"]?.try &.as(String) || config.default_home + config.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || config.default_user_preferences.default_home - feed_menu = [] of String - 4.times do |index| - option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || "" + admin_feed_menu = [] of String + 5.times do |index| + option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || "" if !option.empty? - feed_menu << option + admin_feed_menu << option end end - config.feed_menu = feed_menu + config.default_user_preferences.feed_menu = admin_feed_menu top_enabled = env.params.body["top_enabled"]?.try &.as(String) top_enabled ||= "off" @@ -1639,6 +2069,7 @@ post "/preferences" do |env| statistics_enabled ||= "off" config.statistics_enabled = statistics_enabled == "on" + CONFIG.default_user_preferences = config.default_user_preferences File.write("config/config.yml", config.to_yaml) end else @@ -1809,13 +2240,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 @@ -2706,6 +3136,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 : ChannelRedirect @@ -2768,7 +3200,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 @@ -2799,6 +3231,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) @@ -2812,7 +3246,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 @@ -2825,9 +3259,40 @@ 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 + 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) @@ -2835,10 +3300,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 @@ -4182,92 +4647,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.private? && 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| @@ -4475,6 +4906,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.private? + 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.private? + 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.private? + 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.to_u64.to_s(16).upcase}" + 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.private? + 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) @@ -5351,27 +6000,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 diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index fa05dce4..cce98310 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/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 949eb335..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 @@ -237,16 +238,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 diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 67ba8167..72bec8e6 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -94,9 +94,7 @@ struct ConfigPreferences result end rescue ex - result = value.read_bool - - if result + if value.read_bool "dark" else "light" @@ -110,7 +108,7 @@ struct ConfigPreferences def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected sequence, not #{node.class}" + node.raise "Expected scalar, not #{node.class}" end case node.value @@ -134,8 +132,8 @@ struct ConfigPreferences comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, continue: {type: Bool, default: false}, continue_autoplay: {type: Bool, default: true}, - dark_mode: {type: String, default: "", converter: BoolToString}, dismissals: {type: String, default: ""}, + dark_mode: {type: String, default: "light", converter: BoolToString}, latest_only: {type: Bool, default: false}, listen: {type: Bool, default: false}, local: {type: Bool, default: false}, @@ -144,7 +142,8 @@ struct ConfigPreferences notifications_only: {type: Bool, default: false}, player_style: {type: String, default: "invidious"}, quality: {type: String, default: "hd720"}, - redirect_feed: {type: Bool, default: false}, + default_home: {type: String, default: "Popular"}, + feed_menu: {type: Array(String), default: ["Popular", "Trending", "Subscriptions", "Playlists"]}, related_videos: {type: Bool, default: true}, sort: {type: String, default: "published"}, speed: {type: Float32, default: 1.0_f32}, @@ -599,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/helpers/proxy.cr b/src/invidious/helpers/proxy.cr index fde282cd..0ba3c1fe 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"] ||= "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 + end end def get_proxies(country_code = "US") diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ed55dc9c..117a5dbe 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -20,7 +20,7 @@ end def make_client(url : URI, region = nil) client = HTTPClient.new(url) - client.family = CONFIG.force_resolve + client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC client.read_timeout = 15.seconds client.connect_timeout = 15.seconds 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 10475a8f..3a31c5e7 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,9 +21,11 @@ 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 + + xml.element("p", style: "white-space:pre-wrap") { xml.text html_to_content(self.description_html) } end end @@ -40,12 +44,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 @@ -427,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 3d28e582..50cfc05c 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -85,7 +85,8 @@ struct Preferences notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, player_style: {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString}, quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString}, - redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, + default_home: {type: String, default: CONFIG.default_user_preferences.default_home}, + feed_menu: {type: Array(String), default: CONFIG.default_user_preferences.feed_menu}, related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, sort: {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString}, speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, @@ -283,6 +284,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 e175ae39..1ae31257 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 @@ -1229,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"] @@ -1275,21 +1274,35 @@ 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 &.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 @@ -1342,17 +1355,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"]) 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/feed_menu.ecr b/src/invidious/views/components/feed_menu.ecr index a93d232e..dce6d7fb 100644 --- a/src/invidious/views/components/feed_menu.ecr +++ b/src/invidious/views/components/feed_menu.ecr @@ -6,9 +6,9 @@
- <% feed_menu = config.feed_menu.dup %> + <% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %> <% if !env.get?("user") %> - <% feed_menu.reject! {|feed| feed == "Subscriptions"} %> + <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> <% end %> <% feed_menu.each do |feed| %>
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/popular.ecr b/src/invidious/views/popular.ecr index ecebc686..7916e045 100644 --- a/src/invidious/views/popular.ecr +++ b/src/invidious/views/popular.ecr @@ -1,7 +1,7 @@ <% content_for "header" do %> "> - <% if config.default_home != "Popular" %> + <% if env.get("preferences").as(Preferences).default_home != "Popular" %> <%= translate(locale, "Popular") %> - Invidious <% else %> Invidious diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 56334dd9..17e5804e 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -135,6 +135,32 @@ function update_value(element) { <input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>> </div> + <% if env.get?("user") %> + <% feed_options = {"", "Popular", "Top", "Trending", "Subscriptions", "Playlists"} %> + <% else %> + <% feed_options = {"", "Popular", "Top", "Trending"} %> + <% end %> + + <div class="pure-control-group"> + <label for="default_home"><%= translate(locale, "Default homepage: ") %></label> + <select name="default_home" id="default_home"> + <% feed_options.each do |option| %> + <option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option> + <% end %> + </select> + </div> + + <div class="pure-control-group"> + <label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label> + <% (feed_options.size - 1).times do |index| %> + <select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]"> + <% feed_options.each do |option| %> + <option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option> + <% end %> + </select> + <% end %> + </div> + <% if env.get? "user" %> <legend><%= translate(locale, "Subscription preferences") %></legend> @@ -143,11 +169,6 @@ function update_value(element) { <input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>> </div> - <div class="pure-control-group"> - <label for="redirect_feed"><%= translate(locale, "Redirect homepage to feed: ") %></label> - <input name="redirect_feed" id="redirect_feed" type="checkbox" <% if preferences.redirect_feed %>checked<% end %>> - </div> - <div class="pure-control-group"> <label for="max_results"><%= translate(locale, "Number of videos shown in feed: ") %></label> <input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>"> @@ -193,20 +214,20 @@ function update_value(element) { <legend><%= translate(locale, "Administrator preferences") %></legend> <div class="pure-control-group"> - <label for="default_home"><%= translate(locale, "Default homepage: ") %></label> - <select name="default_home" id="default_home"> - <% {"Popular", "Top", "Trending", "Subscriptions"}.each do |option| %> - <option value="<%= option %>" <% if config.default_home == option %> selected <% end %>><%= translate(locale, option) %></option> + <label for="admin_default_home"><%= translate(locale, "Default homepage: ") %></label> + <select name="admin_default_home" id="admin_default_home"> + <% feed_options.each do |option| %> + <option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option> <% end %> </select> </div> <div class="pure-control-group"> - <label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label> - <% 4.times do |index| %> - <select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]"> - <% {"", "Popular", "Top", "Trending", "Subscriptions"}.each do |option| %> - <option value="<%= option %>" <% if config.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option> + <label for="admin_feed_menu"><%= translate(locale, "Feed menu: ") %></label> + <% (feed_options.size - 1).times do |index| %> + <select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]"> + <% feed_options.each do |option| %> + <option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option> <% end %> </select> <% end %> @@ -261,6 +282,10 @@ function update_value(element) { <a href="/token_manager"><%= translate(locale, "Manage tokens") %></a> </div> + <div class="pure-control-group"> + <a href="/view_all_playlists"><%= translate(locale, "View all playlists") %></a> + </div> + <div class="pure-control-group"> <a href="/feed/history"><%= translate(locale, "Watch history") %></a> </div> 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 @@ <li>a randomly generated token for providing an RSS feed of a user's subscriptions</li> <li>a list of video IDs identifying watched videos</li> </ul> - <p>The above list reflects <a href="https://github.com/omarroth/invidious/blob/master/src/invidious/users.cr#L14-L51">this code</a>.</p> <p>Users can clear their watch history using the <a href="/clear_watch_history">clear watch history</a> page.</p> <p>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.</p> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 501dcfd7..d55a4212 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -117,17 +117,15 @@ </div> <div class="pure-u-1 pure-u-md-1-3"> <i class="icon ion-logo-bitcoin"></i> - BTC: 356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY + BTC: <a href="bitcoin:356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY">356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY</a> </div> <div class="pure-u-1 pure-u-md-1-3"> <i class="icon ion-logo-bitcoin"></i> - BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk + BCH: <a href="bitcoincash:qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk">qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk</a> </div> <div class="pure-u-1 pure-u-md-1-3"> <i class="icon ion-logo-usd"></i> <a href="https://liberapay.com/omarroth">Liberapay</a> - / - <a href="https://patreon.com/omarroth">Patreon</a> </div> <div class="pure-u-1 pure-u-md-1-3"> <i class="icon ion-logo-javascript"></i> diff --git a/src/invidious/views/top.ecr b/src/invidious/views/top.ecr index d5fb3de6..f5db3aaa 100644 --- a/src/invidious/views/top.ecr +++ b/src/invidious/views/top.ecr @@ -1,7 +1,7 @@ <% content_for "header" do %> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <title> - <% if config.default_home != "Top" %> + <% if env.get("preferences").as(Preferences).default_home != "Top" %> <%= translate(locale, "Top") %> - Invidious <% else %> Invidious diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/trending.ecr index d7784928..42acb15c 100644 --- a/src/invidious/views/trending.ecr +++ b/src/invidious/views/trending.ecr @@ -1,7 +1,7 @@ <% content_for "header" do %> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <title> - <% if config.default_home != "Trending" %> + <% if env.get("preferences").as(Preferences).default_home != "Trending" %> <%= translate(locale, "Trending") %> - Invidious <% else %> Invidious diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr new file mode 100644 index 00000000..0fa7a325 --- /dev/null +++ b/src/invidious/views/view_all_playlists.ecr @@ -0,0 +1,24 @@ +<% content_for "header" do %> +<title><%= translate(locale, "Playlists") %> - Invidious +<% end %> + +<%= rendered "components/feed_menu" %> + +
+
+

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